Basic XMPP

This commit is contained in:
Arcady Chumachenko 2021-11-12 13:07:57 +00:00
parent 282e4da983
commit db76a70295
11 changed files with 7867 additions and 788 deletions

5
.gitignore vendored
View File

@ -1,5 +1,8 @@
node_modules
config.yaml
build
skype-registration.yaml
registration.yaml
xmpp-registration.yaml
*.db
*-audit.json
*.log.*

View File

@ -1,11 +1,11 @@
FROM node:alpine AS builder
WORKDIR /opt/mx-puppet-skype
WORKDIR /opt/mx-puppet-xmpp
# run build process as user in case of npm pre hooks
# pre hooks are not executed while running as root
RUN chown node:node /opt/mx-puppet-skype
RUN apk --no-cache add git python make g++ pkgconfig \
RUN chown node:node /opt/mx-puppet-xmpp
RUN apk update && apk --no-cache add git python3 make g++ pkgconfig \
build-base \
cairo-dev \
jpeg-dev \
@ -15,7 +15,8 @@ RUN apk --no-cache add git python make g++ pkgconfig \
pixman-dev \
pangomm-dev \
libjpeg-turbo-dev \
freetype-dev
freetype-dev \
&& rm -rf /var/cache/apk/*
RUN wget -O /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub && \
wget -O glibc-2.32-r0.apk https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.32-r0/glibc-2.32-r0.apk && \
@ -38,10 +39,10 @@ FROM node:alpine
VOLUME /data
ENV CONFIG_PATH=/data/config.yaml \
REGISTRATION_PATH=/data/skype-registration.yaml
REGISTRATION_PATH=/data/xmpp-registration.yaml
# su-exec is used by docker-run.sh to drop privileges
RUN apk add --no-cache su-exec \
RUN apk update && apk add --no-cache su-exec \
cairo \
jpeg \
pango \
@ -50,15 +51,16 @@ RUN apk add --no-cache su-exec \
pixman \
pangomm \
libjpeg-turbo \
freetype
freetype \
&& rm -rf /var/cache/apk/*
WORKDIR /opt/mx-puppet-skype
WORKDIR /opt/mx-puppet-xmpp
COPY docker-run.sh ./
COPY --from=builder /opt/mx-puppet-skype/node_modules/ ./node_modules/
COPY --from=builder /opt/mx-puppet-skype/build/ ./build/
COPY --from=builder /opt/mx-puppet-xmpp/node_modules/ ./node_modules/
COPY --from=builder /opt/mx-puppet-xmpp/build/ ./build/
# change workdir to /data so relative paths in the config.yaml
# point to the persisten volume
WORKDIR /data
ENTRYPOINT ["/opt/mx-puppet-skype/docker-run.sh"]
ENTRYPOINT ["/opt/mx-puppet-xmpp/docker-run.sh"]

View File

@ -1,11 +1,17 @@
[![Support room on Matrix](https://img.shields.io/matrix/mx-puppet-bridge:sorunome.de.svg?label=%23mx-puppet-bridge%3Asorunome.de&logo=matrix&server_fqdn=sorunome.de)](https://matrix.to/#/#mx-puppet-bridge:sorunome.de)[![donate](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.com/Sorunome/donate)
[![donate](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.com/rkd/donate)
# mx-puppet-skype
This is a skype puppeting bridge for matrix. It is based on [mx-puppet-bridge](https://github.com/Sorunome/mx-puppet-bridge) and provide multi-user instances.
# [WIP] mx-puppet-xmpp
This is a xmpp puppeting bridge for matrix. It is based on [mx-puppet-bridge](https://github.com/Sorunome/mx-puppet-bridge) and provide multi-user instances.
##
## Quick start using Docker
Docker image can be found at https://hub.docker.com/r/sorunome/mx-puppet-skype
To build docker image:
```
docker build -t mx-puppet-xmpp:latest .
```
For docker you probably want the following changes in `config.yaml`:
@ -21,16 +27,16 @@ Also check the config for other values, like your homeserver domain.
* Clone and install:
```
git clone https://github.com/Sorunome/mx-puppet-skype.git
cd mx-puppet-skype
git clone https://github.com/Sorunome/mx-puppet-xmpp.git
cd mx-puppet-xmpp
npm install
* Edit the configuration file and generate the registration file:
```
cp sample.config.yaml config.yaml
# fill info about your homeserver and skype app credentials to config.yaml manually
# fill info about your homeserver and xmpp app credentials to config.yaml manually
npm run start -- -r # generate registration file
or
docker run -v </path/to/host>/data:/data -it sorunome/mx-puppet-skype -r
docker run -v </path/to/host>/data:/data -it mx-puppet-xmpp -r
```
* Copy the registration file to your synapse config directory.
* Add the registration file to the list under `app_service_config_files:` in your synapse config.
@ -39,9 +45,9 @@ Also check the config for other values, like your homeserver domain.
```
npm run start
```
* Start a direct chat with the bot user (`@_skypepuppet_bot:domain.tld` unless you changed the config).
* Start a direct chat with the bot user (`@_xmpppuppet_bot:domain.tld` unless you changed the config).
(Give it some time after the invite, it'll join after a minute maybe.)
* Get your Skype username and password as below, and tell the bot user to link your skype account:
* Get your Xmpp username and password as below, and tell the bot user to link your xmpp account:
```
link <username> <password>
```
@ -50,3 +56,17 @@ Also check the config for other values, like your homeserver domain.
list
```
Clicking rooms in the list will result in you receiving an invite to the bridged room.
## Working
- link
- text messages (mx -> xmpp)
- text messages (xmpp -> mx)
## TODO
- replies
- edits
- deletes
- images
- files

View File

@ -32,7 +32,7 @@ else
fi
# $su_exec is used in case we have to drop the privileges
exec $su_exec /usr/local/bin/node '/opt/mx-puppet-skype/build/index.js' \
exec $su_exec /usr/local/bin/node '/opt/mx-puppet-xmpp/build/index.js' \
-c "$CONFIG_PATH" \
-f "$REGISTRATION_PATH" \
$args

7629
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
{
"name": "mx-puppet-skype",
"name": "mx-puppet-xmpp",
"version": "0.0.0",
"description": "",
"main": "index.js",
@ -9,9 +9,9 @@
"start": "npm run-script build && node ./build/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Sorunome",
"author": "rkd",
"dependencies": {
"@sorunome/skype-http": "^1.5.2",
"@xmpp/client": "^0.13.0",
"cheerio": "^1.0.0-rc.3",
"command-line-args": "^5.1.1",
"command-line-usage": "^5.0.5",
@ -20,7 +20,7 @@
"events": "^3.0.0",
"expire-set": "^1.0.0",
"js-yaml": "^3.13.1",
"mx-puppet-bridge": "0.1.4",
"mx-puppet-bridge": "0.1.6",
"node-emoji": "^1.10.0",
"node-html-parser": "^1.2.13",
"tough-cookie": "^4.0.0",

View File

@ -1,5 +1,5 @@
/*
Copyright 2020 mx-puppet-skype
Copyright 2020 mx-puppet-xmpp
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
@ -11,185 +11,108 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Log, IRemoteRoom, Util } from "mx-puppet-bridge";
import { Log, IRemoteRoom } from "mx-puppet-bridge";
import { EventEmitter } from "events";
import * as skypeHttp from "@sorunome/skype-http";
import { Contact as SkypeContact } from "@sorunome/skype-http/dist/lib/types/contact";
import { NewMediaMessage as SkypeNewMediaMessage } from "@sorunome/skype-http/dist/lib/interfaces/api/api";
import { Context as SkypeContext } from "@sorunome/skype-http/dist/lib/interfaces/api/context";
import ExpireSet from "expire-set";
import * as toughCookie from "tough-cookie";
import { client, xml } from "@xmpp/client";
import { Client as XmppClient } from "@xmpp/client-core";
const log = new Log("SkypePuppet:client");
const log = new Log("XmppPuppet:client");
// tslint:disable no-magic-numbers
const ID_TIMEOUT = 60000;
const CONTACTS_DELTA_INTERVAL = 5 * 60 * 1000;
// tslint:enable no-magic-numbers
type Contact = {
personId: string,
workloads: any,
mri: string,
blocked: boolean,
authorized: boolean,
creationTime: Date,
displayName: string,
displayNameSource: any, // tslint:disable-line no-any
profile: {
roomId: string,
avatarUrl: string | undefined,
name: {
first: string | undefined,
surname: string | undefined,
nickname: string | undefined,
},
},
}
export class Client extends EventEmitter {
public contacts: Map<string, SkypeContact> = new Map();
public conversations: Map<string, skypeHttp.Conversation> = new Map();
private api: skypeHttp.Api;
private handledIds: ExpireSet<string>;
private contactsInterval: NodeJS.Timeout | null = null;
public contacts: Map<string, Contact> = new Map();
public conversations: Map<string, any> = new Map();
private api: XmppClient;
constructor(
private loginUsername: string,
private password: string,
private state?: SkypeContext.Json,
) {
super();
this.handledIds = new ExpireSet(ID_TIMEOUT);
}
) { super(); }
public get username(): string {
return "8:" + this.api.context.username;
return this.loginUsername.split("@")[0].trim();
}
public get getState(): SkypeContext.Json {
return this.api.getState();
public get host(): string {
return this.loginUsername.split("@")[1].trim();
}
public get getState() {
return {};
}
public async connect() {
let connectedWithAuth = false;
if (this.state) {
try {
this.api = await skypeHttp.connect({ state: this.state, verbose: true });
connectedWithAuth = true;
} catch (err) {
this.api = await skypeHttp.connect({
credentials: {
username: this.loginUsername,
password: this.password,
},
verbose: true,
});
connectedWithAuth = false;
}
} else {
this.api = await skypeHttp.connect({
credentials: {
username: this.loginUsername,
password: this.password,
},
verbose: true,
});
connectedWithAuth = false;
}
log.info("Connecting to ", this.host);
log.info(this.username);
try {
await this.startupApi();
} catch (err) {
if (!connectedWithAuth) {
throw err;
}
this.api = await skypeHttp.connect({
credentials: {
username: this.loginUsername,
password: this.password,
},
verbose: true,
});
connectedWithAuth = false;
await this.startupApi();
}
this.api = client({
service: "ws://"+this.host+":5280/xmpp-websocket",
domain: this.host,
resource: "mx_bridge",
username: this.username,
password: this.password,
});
const registerErrorHandler = () => {
this.api.on("error", (err: Error) => {
log.error("An error occured", err);
this.emit("error", err);
});
};
await this.startupApi();
if (connectedWithAuth) {
let resolved = false;
return new Promise(async (resolve, reject) => {
const TIMEOUT_SUCCESS = 5000;
setTimeout(() => {
if (resolved) {
return;
}
resolved = true;
registerErrorHandler();
resolve();
}, TIMEOUT_SUCCESS);
this.api.once("error", async () => {
if (resolved) {
return;
}
resolved = true;
// alright, re-try as normal user
try {
await this.api.stopListening();
this.api = await skypeHttp.connect({
credentials: {
username: this.loginUsername,
password: this.password,
},
verbose: true,
});
await this.startupApi();
registerErrorHandler();
resolve();
} catch (err) {
reject(err);
}
});
await this.api.listen();
}).then(async () => {
await this.api.setStatus("Online");
});
} else {
registerErrorHandler();
await this.api.listen();
await this.api.setStatus("Online");
}
this.api.on("error", (err: Error) => {
log.error("An error occured", err);
this.emit("error", err);
});
this.api.start();
}
public async disconnect() {
if (this.api) {
await this.api.stopListening();
}
if (this.contactsInterval) {
clearInterval(this.contactsInterval);
this.contactsInterval = null;
await this.api.stop();
}
}
public async getContact(id: string): Promise<SkypeContact | null> {
log.debug(`Fetching contact ${id}`);
const hasStart = Boolean(id.match(/^\d+:/));
const fullId = hasStart ? id : `8:${id}`;
if (this.contacts.has(fullId)) {
log.debug("Returning cached result");
const ret = this.contacts.get(fullId) || null;
log.silly(ret);
public async getContact(username: string): Promise<any> {
log.debug(`Fetching contact from: ` + username);
if (this.contacts.has(username)) {
const ret = this.contacts.get(username);
return ret;
}
if (hasStart) {
id = id.substr(id.indexOf(":") + 1);
}
try {
const rawContact = await this.api.getContact(id);
const contact: SkypeContact = {
personId: rawContact.id.raw,
const contact = {
personId: username,
workloads: null,
mri: rawContact.id.raw,
mri: username,
blocked: false,
authorized: true,
creationTime: new Date(),
displayName: (rawContact.name && rawContact.name.displayName) || rawContact.id.id,
displayName: username,
displayNameSource: "profile" as any, // tslint:disable-line no-any
profile: {
avatarUrl: rawContact.avatarUrl || undefined,
roomId: username,
avatarUrl: undefined,
name: {
first: (rawContact.name && rawContact.name).first || undefined,
surname: (rawContact.name && rawContact.name).surname || undefined,
nickname: (rawContact.name && rawContact.name).nickname || undefined,
first: undefined,
surname: undefined,
nickname: username,
},
},
};
this.contacts.set(contact.mri, contact || null);
this.contacts.set(contact.mri, contact);
log.debug("Returning new result");
log.silly(contact);
return contact || null;
@ -201,181 +124,93 @@ export class Client extends EventEmitter {
}
}
public async getConversation(room: IRemoteRoom): Promise<skypeHttp.Conversation | null> {
log.debug(`Fetching conversation puppetId=${room.puppetId} roomId=${room.roomId}`);
public async getConversation(room: IRemoteRoom): Promise<any> {
log.info(`Fetching conversation`, room);
log.info(`Fetching conversation puppetId=${room.puppetId} roomId=${room.roomId}`);
let id = room.roomId;
const match = id.match(/^dm-\d+-/);
if (match) {
const [_, puppetId] = id.split("-");
if (Number(puppetId) !== room.puppetId) {
return null;
}
id = id.substr(match[0].length);
}
if (this.conversations.has(id)) {
log.debug("Returning cached result");
log.info("Returning cached result");
const ret = this.conversations.get(id) || null;
log.silly(ret);
return ret;
}
try {
const conversation = await this.api.getConversation(id);
this.conversations.set(conversation.id, conversation || null);
log.debug("Returning new result");
log.silly(conversation);
const conversation = {id: room.roomId, members: []};
this.conversations.set(room.roomId, conversation || null);
log.info("Returning new result");
log.info(conversation);
return conversation || null;
} catch (err) {
// conversation not found
log.debug("No such conversation found");
log.debug(err.body || err);
log.error("No such conversation found");
log.error(err.body || err);
return null;
}
}
public async downloadFile(url: string, type: string = "imgpsh_fullsize_anim"): Promise<Buffer> {
if (url.startsWith("https://api.asm.skype.com/") && !url.includes("/views/")) {
url = `${url}/views/${type}`;
}
const cookieJar = new toughCookie.CookieJar(this.api.context.cookies);
return await Util.DownloadFile(url, {
responseType: "buffer",
headers: {
Authorization: "skypetoken=" + this.api.context.skypeToken.value,
RegistrationToken: this.api.context.registrationToken.raw,
},
cookieJar: {
setCookie: async (rawCookie: string, cookieUrl: string) =>
new Promise((resolve, reject) =>
cookieJar.setCookie(rawCookie, cookieUrl, (err, value) =>
err ? reject(err) : resolve(value),
),
),
getCookieString: async (cookieUrl: string) =>
new Promise((resolve, reject) =>
cookieJar.getCookieString(cookieUrl, (err, value) => {
if (err) {
reject(err);
return;
}
if (url.startsWith("https://api.asm.skype.com/")) {
if (value) {
value += "; ";
}
value += "skypetoken_asm=" + encodeURIComponent(this.api.context.skypeToken.value);
}
resolve(value);
}),
),
},
});
public async downloadFile(url: string, type: string = "imgpsh_fullsize_anim") {
// TODO
}
public async sendMessage(conversationId: string, msg: string): Promise<skypeHttp.Api.SendMessageResult> {
return await this.api.sendMessage({
textContent: msg,
}, conversationId);
public async sendMessage(conversationId: string, msg: string) {
return await this.api.send(xml(
"message",
{ type: "chat", to: conversationId },
xml("body", {}, msg),
));
}
public async sendEdit(conversationId: string, messageId: string, msg: string) {
return await this.api.sendEdit({
textContent: msg,
}, conversationId, messageId);
// TODO
// return await this.api.sendEdit({
// textContent: msg,
// }, conversationId, messageId);
}
public async sendDelete(conversationId: string, messageId: string) {
return await this.api.sendDelete(conversationId, messageId);
// TODO
// return await this.api.sendDelete(conversationId, messageId);
}
public async sendAudio(
conversationId: string,
opts: SkypeNewMediaMessage,
): Promise<skypeHttp.Api.SendMessageResult> {
return await this.api.sendAudio(opts, conversationId);
opts: any,
) {
// TODO
// return await this.api.sendAudio(opts, conversationId);
}
public async sendDocument(
conversationId: string,
opts: SkypeNewMediaMessage,
): Promise<skypeHttp.Api.SendMessageResult> {
return await this.api.sendDocument(opts, conversationId);
opts: any,
) {
// TODO
// return await this.api.sendDocument(opts, conversationId);
}
public async sendImage(
conversationId: string,
opts: SkypeNewMediaMessage,
): Promise<skypeHttp.Api.SendMessageResult> {
return await this.api.sendImage(opts, conversationId);
opts: any,
) {
// TODO
// return await this.api.sendImage(opts, conversationId);
}
private async startupApi() {
this.api.on("event", (evt: skypeHttp.events.EventMessage) => {
if (!evt || !evt.resource) {
return;
}
const resource = evt.resource;
log.debug(`Got new event of type ${resource.type}`);
log.silly(evt);
const [type, subtype] = resource.type.split("/");
switch (type) {
case "RichText":
if (evt.resourceType === "NewMessage") {
if (resource.native.skypeeditedid || this.handledIds.has(resource.id)) {
break;
}
this.handledIds.add(resource.id);
if (subtype === "Location") {
this.emit("location", resource);
} else if (subtype) {
this.emit("file", resource);
} else {
this.emit("text", resource);
}
} else if (evt.resourceType === "MessageUpdate") {
this.emit("edit", resource);
}
break;
case "Control":
if (subtype === "Typing" || subtype === "ClearTyping") {
this.emit("typing", resource, subtype === "Typing");
}
break;
case "ThreadActivity":
if (subtype === "MemberConsumptionHorizonUpdate") {
this.emit("presence", resource);
}
break;
this.api.on("stanza", async (stanza) => {
if (stanza.is("message")) {
this.emit("text", stanza);
}
});
const contacts = await this.api.getContacts();
for (const contact of contacts) {
this.contacts.set(contact.mri, contact);
}
const conversations = await this.api.getConversations();
for (const conversation of conversations) {
this.conversations.set(conversation.id, conversation);
}
this.api.on("online", async (address) => {
await this.api.send(xml("presence"));
});
if (this.contactsInterval) {
clearInterval(this.contactsInterval);
this.contactsInterval = null;
}
this.contactsInterval = setInterval(this.updateContacts.bind(this), CONTACTS_DELTA_INTERVAL);
}
private async updateContacts() {
log.verbose("Getting contacts diff....");
try {
const contacts = await this.api.getContacts(true);
const MANY_CONTACTS = 5;
for (const contact of contacts) {
const oldContact = this.contacts.get(contact.mri) || null;
this.contacts.set(contact.mri, contact);
this.emit("updateContact", oldContact, contact);
}
} catch (err) {
log.error("Failed to get contacts diff", err);
this.emit("error", err);
}
// const contacts = await this.api.getContacts();
// for (const contact of contacts) {
// this.contacts.set(contact.mri, contact);
// }
}
}

View File

@ -1,5 +1,5 @@
/*
Copyright 2020 mx-puppet-skype
Copyright 2020 mx-puppet-xmpp
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
@ -23,10 +23,10 @@ import * as commandLineArgs from "command-line-args";
import * as commandLineUsage from "command-line-usage";
import * as fs from "fs";
import * as yaml from "js-yaml";
import { Skype } from "./skype";
import { Xmpp } from "./xmpp";
import { Client } from "./client";
const log = new Log("SkypePuppet:index");
const log = new Log("XmppPuppet:index");
const commandOptions = [
{ name: "register", alias: "r", type: Boolean },
@ -36,7 +36,7 @@ const commandOptions = [
];
const options = Object.assign({
"register": false,
"registration-file": "skype-registration.yaml",
"registration-file": "xmpp-registration.yaml",
"config": "config.yaml",
"help": false,
}, commandLineArgs(commandOptions));
@ -45,8 +45,8 @@ if (options.help) {
// tslint:disable-next-line:no-console
console.log(commandLineUsage([
{
header: "Matrix Skype Puppet Bridge",
content: "A matrix puppet bridge for Skype",
header: "Matrix Xmpp Puppet Bridge",
content: "A matrix puppet bridge for Xmpp",
},
{
header: "Options",
@ -58,16 +58,16 @@ if (options.help) {
const protocol: IProtocolInformation = {
features: {
image: true,
audio: true,
file: true,
edit: true,
reply: true,
image: false,
audio: false,
file: false,
edit: false,
reply: false,
globalNamespace: true,
},
id: "skype",
displayname: "Skype",
externalUrl: "https://skype.com/",
id: "xmpp",
displayname: "Xmpp",
externalUrl: "https://xmpp.com/",
};
const puppet = new PuppetBridge(options["registration-file"], options.config, protocol);
@ -77,8 +77,8 @@ if (options.register) {
puppet.readConfig(false);
try {
puppet.generateRegistration({
prefix: "_skypepuppet_",
id: "skype-puppet",
prefix: "_xmpppuppet_",
id: "xmpp-puppet",
url: `http://${puppet.Config.bridge.bindAddress}:${puppet.Config.bridge.port}`,
});
} catch (err) {
@ -90,24 +90,24 @@ if (options.register) {
async function run() {
await puppet.init();
const skype = new Skype(puppet);
puppet.on("puppetNew", skype.newPuppet.bind(skype));
puppet.on("puppetDelete", skype.deletePuppet.bind(skype));
puppet.on("message", skype.handleMatrixMessage.bind(skype));
puppet.on("edit", skype.handleMatrixEdit.bind(skype));
puppet.on("reply", skype.handleMatrixReply.bind(skype));
puppet.on("redact", skype.handleMatrixRedact.bind(skype));
puppet.on("image", skype.handleMatrixImage.bind(skype));
puppet.on("audio", skype.handleMatrixAudio.bind(skype));
puppet.on("file", skype.handleMatrixFile.bind(skype));
puppet.setCreateUserHook(skype.createUser.bind(skype));
puppet.setCreateRoomHook(skype.createRoom.bind(skype));
puppet.setGetDmRoomIdHook(skype.getDmRoom.bind(skype));
puppet.setListUsersHook(skype.listUsers.bind(skype));
puppet.setListRoomsHook(skype.listRooms.bind(skype));
puppet.setGetUserIdsInRoomHook(skype.getUserIdsInRoom.bind(skype));
const xmpp = new Xmpp(puppet);
puppet.on("puppetNew", xmpp.newPuppet.bind(xmpp));
puppet.on("puppetDelete", xmpp.deletePuppet.bind(xmpp));
puppet.on("message", xmpp.handleMatrixMessage.bind(xmpp));
puppet.on("edit", xmpp.handleMatrixEdit.bind(xmpp));
puppet.on("reply", xmpp.handleMatrixReply.bind(xmpp));
puppet.on("redact", xmpp.handleMatrixRedact.bind(xmpp));
puppet.on("image", xmpp.handleMatrixImage.bind(xmpp));
puppet.on("audio", xmpp.handleMatrixAudio.bind(xmpp));
puppet.on("file", xmpp.handleMatrixFile.bind(xmpp));
puppet.setCreateUserHook(xmpp.createUser.bind(xmpp));
puppet.setCreateRoomHook(xmpp.createRoom.bind(xmpp));
puppet.setGetDmRoomIdHook(xmpp.getDmRoom.bind(xmpp));
puppet.setListUsersHook(xmpp.listUsers.bind(xmpp));
puppet.setListRoomsHook(xmpp.listRooms.bind(xmpp));
puppet.setGetUserIdsInRoomHook(xmpp.getUserIdsInRoom.bind(xmpp));
puppet.setGetDescHook(async (puppetId: number, data: any): Promise<string> => {
let s = "Skype";
let s = "Xmpp";
if (data.username) {
s += ` as \`${data.username}\``;
}
@ -139,7 +139,7 @@ async function run() {
return retData;
});
puppet.setBotHeaderMsgHook((): string => {
return "Skype Puppet Bridge";
return "Xmpp Puppet Bridge";
});
await puppet.start();
}

View File

@ -1,5 +1,5 @@
/*
Copyright 2020 mx-puppet-skype
Copyright 2020 mx-puppet-xmpp
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

View File

@ -1,5 +1,5 @@
/*
Copyright 2020 mx-puppet-skype
Copyright 2020 mx-puppet-xmpp
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
@ -16,46 +16,46 @@ import {
IRetList, IReplyEvent,
} from "mx-puppet-bridge";
import { Client } from "./client";
import * as skypeHttp from "@sorunome/skype-http";
import { Contact as SkypeContact } from "@sorunome/skype-http/dist/lib/types/contact";
import { NewMediaMessage as SkypeNewMediaMessage } from "@sorunome/skype-http/dist/lib/interfaces/api/api";
import { UnexpectedHttpStatusError } from "@sorunome/skype-http/dist/lib/errors";
import * as decodeHtml from "decode-html";
import * as escapeHtml from "escape-html";
import { MatrixMessageParser } from "./matrixmessageparser";
import { SkypeMessageParser } from "./skypemessageparser";
import { XmppMessageParser } from "./xmppmessageparser";
import * as cheerio from "cheerio";
import ExpireSet from "expire-set";
const log = new Log("SkypePuppet:skype");
const log = new Log("XmppPuppet:xmpp");
const ROOM_TYPE_DM = 8;
interface ISkypePuppet {
interface IXmppPuppet {
client: Client;
data: any;
deletedMessages: ExpireSet<string>;
restarting: boolean;
}
interface ISkypePuppets {
[puppetId: number]: ISkypePuppet;
interface IXmppPuppets {
[puppetId: number]: IXmppPuppet;
}
export class Skype {
private puppets: ISkypePuppets = {};
interface IStanza {
attrs: {to: string, from:string, id: string};
getChild(path: string): {text: () => string}
}
export class Xmpp {
private puppets: IXmppPuppets = {};
private messageDeduplicator: MessageDeduplicator;
private matrixMessageParser: MatrixMessageParser;
private skypeMessageParser: SkypeMessageParser;
private xmppMessageParser: XmppMessageParser;
constructor(
private puppet: PuppetBridge,
) {
this.messageDeduplicator = new MessageDeduplicator();
this.matrixMessageParser = new MatrixMessageParser();
this.skypeMessageParser = new SkypeMessageParser();
this.xmppMessageParser = new XmppMessageParser();
}
public getUserParams(puppetId: number, contact: SkypeContact): IRemoteUser {
public getUserParams(puppetId: number, contact: any): IRemoteUser {
return {
puppetId,
userId: contact.mri,
@ -64,55 +64,38 @@ export class Skype {
};
}
public getRoomParams(puppetId: number, conversation: skypeHttp.Conversation): IRemoteRoom {
const roomType = Number(conversation.id.split(":")[0]);
const isDirect = roomType === ROOM_TYPE_DM;
if (isDirect) {
return {
puppetId,
roomId: `dm-${puppetId}-${conversation.id}`,
isDirect: true,
};
}
public getRoomParams(puppetId: number, conversation: any): IRemoteRoom {
let avatarUrl: string | null = null;
let name: string | null = null;
if (conversation.threadProperties) {
name = conversation.threadProperties.topic || null;
if (name) {
name = decodeHtml(name);
}
const picture = conversation.threadProperties.picture;
if (picture && picture.startsWith("URL@")) {
avatarUrl = picture.slice("URL@".length);
}
}
const p = this.puppets[puppetId];
return {
puppetId,
roomId: conversation.id,
name,
avatarUrl,
downloadFile: async (url: string): Promise<Buffer> => {
downloadFile: async (url: string): Promise<any> => {
return await p.client.downloadFile(url, "swx_avatar");
},
};
}
public async getSendParams(puppetId: number, resource: skypeHttp.resources.Resource): Promise<IReceiveParams | null> {
const roomType = Number(resource.conversation.split(":")[0]);
public async getSendParams(puppetId: number, stanza: IStanza): Promise<IReceiveParams | null> {
const p = this.puppets[puppetId];
const contact = await p.client.getContact(resource.from.raw);
const contact = await p.client.getContact(stanza.attrs.from);
log.info("stanza.attrs", stanza.attrs);
const conversation = await p.client.getConversation({
puppetId,
roomId: resource.conversation,
puppetId: puppetId,
roomId: stanza.attrs.from.split("/")[0],
});
log.info("Received contact", contact);
log.info("Received conversation", conversation);
if (!contact || !conversation) {
return null;
}
return {
user: this.getUserParams(puppetId, contact),
room: this.getRoomParams(puppetId, conversation),
eventId: resource.id, // tslint:disable-line no-any
eventId: stanza.attrs.id, // tslint:disable-line no-any
};
}
@ -130,51 +113,58 @@ export class Skype {
return;
}
await this.stopClient(puppetId);
p.client = new Client(p.data.username, p.data.password, p.data.state);
p.client = new Client(p.data.username, p.data.password);
const client = p.client;
client.on("text", async (resource: skypeHttp.resources.TextResource) => {
client.on("text", async (stanza: any) => {
try {
await this.handleSkypeText(puppetId, resource);
await this.handleXmppText(puppetId, stanza);
} catch (err) {
log.error("Error while handling text event", err);
}
});
client.on("edit", async (resource: skypeHttp.resources.RichTextResource) => {
client.on("edit", async (stanza: any) => {
try {
await this.handleSkypeEdit(puppetId, resource);
await this.handleXmppEdit(puppetId, stanza);
} catch (err) {
log.error("Error while handling edit event", err);
}
});
client.on("location", async (resource: skypeHttp.resources.RichTextLocationResource) => {
client.on("location", async (stanza: any) => {
try {
} catch (err) {
log.error("Error while handling location event", err);
}
});
client.on("file", async (resource: skypeHttp.resources.FileResource) => {
client.on("file", async (stanza: any) => {
try {
await this.handleSkypeFile(puppetId, resource);
await this.handleXmppFile(puppetId, stanza);
} catch (err) {
log.error("Error while handling file event", err);
}
});
client.on("typing", async (resource: skypeHttp.resources.Resource, typing: boolean) => {
client.on("typing", async (stanza: any, typing: boolean) => {
try {
await this.handleSkypeTyping(puppetId, resource, typing);
await this.handleXmppTyping(puppetId, stanza, typing);
} catch (err) {
log.error("Error while handling typing event", err);
}
});
client.on("presence", async (resource: skypeHttp.resources.Resource) => {
client.on("presence", async (stanza: any) => {
try {
await this.handleSkypePresence(puppetId, resource);
await this.handleXmppPresence(puppetId, stanza);
} catch (err) {
log.error("Error while handling presence event", err);
}
});
client.on("updateContact", async (oldContact: SkypeContact | null, newContact: SkypeContact) => {
client.on("receipt", async (stanza: any) => {
try {
await this.handleXmppPresence(puppetId, stanza);
} catch (err) {
log.error("Error while handling receipt event", err);
}
});
client.on("updateContact", async (oldContact: any | null, newContact: any) => {
try {
let update = oldContact === null;
const newUser = this.getUserParams(puppetId, newContact);
@ -197,16 +187,7 @@ export class Skype {
}
p.restarting = true;
const causeName = (err as any).cause ? (err as any).cause.name : "";
log.error("Error when polling");
log.error("name: ", err.name);
const errr = err as any;
if (errr.cause) {
log.error("cause name: ", errr.cause.name);
}
log.error("code: ", errr.code);
log.error("body: ", errr.body);
log.error("cause: ", errr.cause);
log.error("data: ", errr.data);
log.error("Error when polling", err.message);
log.error(err);
if (causeName === "UnexpectedHttpStatus") {
await this.puppet.sendStatusMessage(puppetId, "Error: " + err);
@ -346,6 +327,7 @@ export class Skype {
if (!p) {
return null;
}
log.info("getUserIdsInRoom", room);
const conversation = await p.client.getConversation(room);
if (!conversation) {
return null;
@ -356,10 +338,12 @@ export class Skype {
users.add(member);
}
}
log.info("getUserIdsInRoom users", users);
return users;
}
public async handleMatrixMessage(room: IRemoteRoom, data: IMessageEvent) {
log.info("handleMatrixMessage");
const p = this.puppets[room.puppetId];
if (!p) {
return;
@ -414,6 +398,7 @@ export class Skype {
}
public async handleMatrixReply(room: IRemoteRoom, eventId: string, data: IReplyEvent) {
log.info("handleMatrixReply");
const p = this.puppets[room.puppetId];
if (!p) {
return;
@ -481,230 +466,209 @@ export class Skype {
}
public async handleMatrixImage(room: IRemoteRoom, data: IFileEvent) {
await this.handleMatrixFile(room, data, "sendImage");
// TODO
// await this.handleMatrixFile(room, data, "sendImage");
}
public async handleMatrixAudio(room: IRemoteRoom, data: IFileEvent) {
await this.handleMatrixFile(room, data, "sendAudio");
// TODO
// await this.handleMatrixFile(room, data, "sendAudio");
}
public async handleMatrixFile(room: IRemoteRoom, data: IFileEvent, method?: string) {
if (!method) {
method = "sendDocument";
}
const p = this.puppets[room.puppetId];
if (!p) {
return;
}
log.info("Received file from matrix");
const conversation = await p.client.getConversation(room);
if (!conversation) {
log.warn(`Room ${room.roomId} not found!`);
return;
}
const buffer = await Util.DownloadFile(data.url);
const opts: SkypeNewMediaMessage = {
file: buffer,
name: data.filename,
};
if (data.info) {
if (data.info.w) {
opts.width = data.info.w;
}
if (data.info.h) {
opts.height = data.info.h;
}
}
const dedupeKey = `${room.puppetId};${room.roomId}`;
this.messageDeduplicator.lock(dedupeKey, p.client.username, `file:${data.filename}`);
const ret = await p.client[method](conversation.id, opts);
const eventId = ret && ret.MessageId;
this.messageDeduplicator.unlock(dedupeKey, p.client.username, eventId);
if (eventId) {
await this.puppet.eventSync.insert(room, data.eventId!, eventId);
}
// TODO
// if (!method) {
// method = "sendDocument";
// }
// const p = this.puppets[room.puppetId];
// if (!p) {
// return;
// }
// log.info("Received file from matrix");
// const conversation = await p.client.getConversation(room);
// if (!conversation) {
// log.warn(`Room ${room.roomId} not found!`);
// return;
// }
// const buffer = await Util.DownloadFile(data.url);
// const opts: XmppNewMediaMessage = {
// file: buffer,
// name: data.filename,
// };
// if (data.info) {
// if (data.info.w) {
// opts.width = data.info.w;
// }
// if (data.info.h) {
// opts.height = data.info.h;
// }
// }
// const dedupeKey = `${room.puppetId};${room.roomId}`;
// this.messageDeduplicator.lock(dedupeKey, p.client.username, `file:${data.filename}`);
// const ret = await p.client[method](conversation.id, opts);
// const eventId = ret && ret.MessageId;
// this.messageDeduplicator.unlock(dedupeKey, p.client.username, eventId);
// if (eventId) {
// await this.puppet.eventSync.insert(room, data.eventId!, eventId);
// }
}
private async handleSkypeText(
private async handleXmppText(
puppetId: number,
resource: skypeHttp.resources.TextResource | skypeHttp.resources.RichTextResource,
stanza: IStanza,
) {
const p = this.puppets[puppetId];
if (!p) {
return;
}
const rich = resource.native.messagetype.startsWith("RichText");
log.info("Got new skype message");
log.silly(resource);
const params = await this.getSendParams(puppetId, resource);
log.info("Got new xmpp message");
log.silly(stanza);
const params = await this.getSendParams(puppetId, stanza);
if (!params) {
log.warn("Couldn't generate params");
return;
}
let msg = resource.content;
let msg = stanza.getChild("body").text();
let emote = false;
if (resource.native.skypeemoteoffset) {
emote = true;
msg = msg.substr(Number(resource.native.skypeemoteoffset));
}
const dedupeKey = `${puppetId};${params.room.roomId}`;
if (rich && msg.trim().startsWith("<URIObject") && msg.trim().endsWith("</URIObject>")) {
// okay, we might have a sticker or something...
const $ = cheerio.load(msg);
const obj = $("URIObject");
let uri = obj.attr("uri");
const filename = $(obj.find("OriginalName")).attr("v");
if (uri) {
if (await this.messageDeduplicator.dedupe(dedupeKey, params.user.userId, params.eventId, `file:${filename}`)) {
log.silly("file message dedupe");
return;
}
uri += "/views/thumblarge";
uri = uri.replace("static.asm.skype.com", "static-asm.secure.skypeassets.com");
const buffer = await p.client.downloadFile(uri);
await this.puppet.sendFileDetect(params, buffer, filename);
return;
}
}
if (await this.messageDeduplicator.dedupe(dedupeKey, params.user.userId, params.eventId, msg)) {
log.silly("normal message dedupe");
return;
}
if (rich && msg.trim().startsWith("<quote")) {
if (msg.trim().startsWith("<quote")) {
// TODO
// okay, we might have a reply...
const $ = cheerio.load(msg);
const quote = $("quote");
const messageid = quote.attr("messageid");
if (messageid) {
const sendQuoteMsg = this.skypeMessageParser.parse(msg, { noQuotes: true });
await this.puppet.sendReply(params, messageid, sendQuoteMsg);
return;
}
// const $ = cheerio.load(msg);
// const quote = $("quote");
// const messageid = quote.attr("messageid");
// if (messageid) {
// const sendQuoteMsg = this.xmppMessageParser.parse(msg, { noQuotes: true });
// await this.puppet.sendReply(params, messageid, sendQuoteMsg);
// return;
// }
}
let sendMsg: IMessageEvent;
if (rich) {
sendMsg = this.skypeMessageParser.parse(msg);
} else {
sendMsg = {
body: msg,
};
}
if (emote) {
sendMsg.emote = true;
}
sendMsg = {
body: msg,
};
await this.puppet.sendMessage(params, sendMsg);
}
private async handleSkypeEdit(
private async handleXmppEdit(
puppetId: number,
resource: skypeHttp.resources.TextResource | skypeHttp.resources.RichTextResource,
stanza: IStanza,
) {
const p = this.puppets[puppetId];
if (!p) {
return;
}
const rich = resource.native.messagetype.startsWith("RichText");
log.info("Got new skype edit");
log.silly(resource);
const params = await this.getSendParams(puppetId, resource);
if (!params) {
log.warn("Couldn't generate params");
return;
}
let msg = resource.content;
let emote = false;
if (resource.native.skypeemoteoffset) {
emote = true;
msg = msg.substr(Number(resource.native.skypeemoteoffset));
}
const dedupeKey = `${puppetId};${params.room.roomId}`;
if (await this.messageDeduplicator.dedupe(dedupeKey, params.user.userId, params.eventId, msg)) {
log.silly("normal message dedupe");
return;
}
let sendMsg: IMessageEvent;
if (rich) {
sendMsg = this.skypeMessageParser.parse(msg, { noQuotes: msg.trim().startsWith("<quote") });
} else {
sendMsg = {
body: msg,
};
}
if (emote) {
sendMsg.emote = true;
}
if (resource.content) {
await this.puppet.sendEdit(params, resource.id, sendMsg);
} else if (p.deletedMessages.has(resource.id)) {
log.silly("normal message redact dedupe");
return;
} else {
await this.puppet.sendRedact(params, resource.id);
}
// TODO
// const p = this.puppets[puppetId];
// if (!p) {
// return;
// }
// const rich = resource.native.messagetype.startsWith("RichText");
// log.info("Got new xmpp edit");
// log.silly(resource);
// const params = await this.getSendParams(puppetId, resource);
// if (!params) {
// log.warn("Couldn't generate params");
// return;
// }
// let msg = resource.content;
// let emote = false;
// if (resource.native.xmppemoteoffset) {
// emote = true;
// msg = msg.substr(Number(resource.native.xmppemoteoffset));
// }
// const dedupeKey = `${puppetId};${params.room.roomId}`;
// if (await this.messageDeduplicator.dedupe(dedupeKey, params.user.userId, params.eventId, msg)) {
// log.silly("normal message dedupe");
// return;
// }
// let sendMsg: IMessageEvent;
// if (rich) {
// sendMsg = this.xmppMessageParser.parse(msg, { noQuotes: msg.trim().startsWith("<quote") });
// } else {
// sendMsg = {
// body: msg,
// };
// }
// if (emote) {
// sendMsg.emote = true;
// }
// if (resource.content) {
// await this.puppet.sendEdit(params, resource.id, sendMsg);
// } else if (p.deletedMessages.has(resource.id)) {
// log.silly("normal message redact dedupe");
// return;
// } else {
// await this.puppet.sendRedact(params, resource.id);
// }
}
private async handleSkypeFile(puppetId: number, resource: skypeHttp.resources.FileResource) {
const p = this.puppets[puppetId];
if (!p) {
return;
}
log.info("Got new skype file");
log.silly(resource);
const params = await this.getSendParams(puppetId, resource);
if (!params) {
log.warn("Couldn't generate params");
return;
}
const filename = resource.original_file_name;
const dedupeKey = `${puppetId};${params.room.roomId}`;
if (await this.messageDeduplicator.dedupe(dedupeKey, params.user.userId, params.eventId, `file:${filename}`)) {
log.silly("file message dedupe");
return;
}
const buffer = await p.client.downloadFile(resource.uri);
await this.puppet.sendFileDetect(params, buffer, filename);
private async handleXmppFile(puppetId: number, stanza: IStanza) {
// TODO
// const p = this.puppets[puppetId];
// if (!p) {
// return;
// }
// log.info("Got new xmpp file");
// log.silly(resource);
// const params = await this.getSendParams(puppetId, resource);
// if (!params) {
// log.warn("Couldn't generate params");
// return;
// }
// const filename = resource.original_file_name;
// const dedupeKey = `${puppetId};${params.room.roomId}`;
// if (await this.messageDeduplicator.dedupe(dedupeKey, params.user.userId, params.eventId, `file:${filename}`)) {
// log.silly("file message dedupe");
// return;
// }
// const buffer = await p.client.downloadFile(resource.uri);
// await this.puppet.sendFileDetect(params, buffer, filename);
}
private async handleSkypeTyping(puppetId: number, resource: skypeHttp.resources.Resource, typing: boolean) {
const p = this.puppets[puppetId];
if (!p) {
return;
}
log.info("Got new skype typing event");
log.silly(resource);
const params = await this.getSendParams(puppetId, resource);
if (!params) {
log.warn("Couldn't generate params");
return;
}
await this.puppet.setUserTyping(params, typing);
private async handleXmppTyping(puppetId: number, stanza: IStanza, typing: boolean) {
// TODO
// const p = this.puppets[puppetId];
// if (!p) {
// return;
// }
// log.info("Got new xmpp typing event");
// log.silly(resource);
// const params = await this.getSendParams(puppetId, resource);
// if (!params) {
// log.warn("Couldn't generate params");
// return;
// }
// await this.puppet.setUserTyping(params, typing);
}
private async handleSkypePresence(puppetId: number, resource: skypeHttp.resources.Resource) {
const p = this.puppets[puppetId];
if (!p) {
return;
}
log.info("Got new skype presence event");
log.silly(resource);
const content = JSON.parse(resource.native.content);
const contact = await p.client.getContact(content.user);
const conversation = await p.client.getConversation({
puppetId,
roomId: resource.conversation,
});
if (!contact || !conversation) {
log.warn("Couldn't generate params");
return;
}
const params: IReceiveParams = {
user: this.getUserParams(puppetId, contact),
room: this.getRoomParams(puppetId, conversation),
};
const [id, _, clientId] = content.consumptionhorizon.split(";");
params.eventId = id;
await this.puppet.sendReadReceipt(params);
params.eventId = clientId;
await this.puppet.sendReadReceipt(params);
private async handleXmppPresence(puppetId: number, stanza: IStanza) {
// TODO
// const p = this.puppets[puppetId];
// if (!p) {
// return;
// }
// log.info("Got new xmpp presence event");
// log.silly(resource);
// const content = JSON.parse(resource.native.content);
// const contact = await p.client.getContact(content.user);
// const conversation = await p.client.getConversation({
// puppetId,
// roomId: resource.conversation,
// });
// if (!contact || !conversation) {
// log.warn("Couldn't generate params");
// return;
// }
// const params: IReceiveParams = {
// user: this.getUserParams(puppetId, contact),
// room: this.getRoomParams(puppetId, conversation),
// };
// const [id, _, clientId] = content.consumptionhorizon.split(";");
// params.eventId = id;
// await this.puppet.sendReadReceipt(params);
// params.eventId = clientId;
// await this.puppet.sendReadReceipt(params);
}
}

View File

@ -1,5 +1,5 @@
/*
Copyright 2020 mx-puppet-skype
Copyright 2020 mx-puppet-xmpp
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
@ -17,12 +17,12 @@ import * as escapeHtml from "escape-html";
import { IMessageEvent } from "mx-puppet-bridge";
import * as emoji from "node-emoji";
interface ISkypeMessageParserOpts {
interface IXmppMessageParserOpts {
noQuotes?: boolean;
}
export class SkypeMessageParser {
public parse(msg: string, opts: ISkypeMessageParserOpts = {}): IMessageEvent {
export class XmppMessageParser {
public parse(msg: string, opts: IXmppMessageParserOpts = {}): IMessageEvent {
opts = Object.assign({
noQuotes: false,
}, opts);
@ -33,7 +33,7 @@ export class SkypeMessageParser {
return this.walkNode(nodes, opts);
}
private walkChildNodes(node: Parser.Node, opts: ISkypeMessageParserOpts): IMessageEvent {
private walkChildNodes(node: Parser.Node, opts: IXmppMessageParserOpts): IMessageEvent {
if (!node.childNodes.length) {
return {
body: "",
@ -55,7 +55,7 @@ export class SkypeMessageParser {
};
}
private walkNode(node: Parser.Node, opts: ISkypeMessageParserOpts): IMessageEvent {
private walkNode(node: Parser.Node, opts: IXmppMessageParserOpts): IMessageEvent {
if (node.nodeType === Parser.NodeType.TEXT_NODE) {
return this.escape((node as Parser.TextNode).text);
} else if (node.nodeType === Parser.NodeType.ELEMENT_NODE) {
@ -111,7 +111,7 @@ export class SkypeMessageParser {
};
}
case "ss": {
// skype emoji
// xmpp emoji
const type = nodeHtml.attributes.type;
let emojiType = {
smile: "slightly_smiling_face",