get basic message sending working

This commit is contained in:
Sorunome 2020-03-24 17:27:42 +01:00
commit 1cab823bbe
No known key found for this signature in database
GPG Key ID: B19471D07FC9BE9C
8 changed files with 5001 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
config.yaml
build
skype-registration.yaml
*.db

4330
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "mx-puppet-skype",
"version": "0.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "tsc",
"lint": "tslint --project ./tsconfig.json -t stylish",
"start": "npm run-script build && node ./build/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Sorunome",
"dependencies": {
"command-line-args": "^5.1.1",
"command-line-usage": "^5.0.5",
"decode-html": "^2.0.0",
"escape-html": "^1.0.3",
"events": "^3.0.0",
"js-yaml": "^3.13.1",
"mx-puppet-bridge": "0.0.35-1",
"skype-http": "git://github.com/metasonic/skype-http#ea08e617d4f2bd3b16a2de79fe4efb9dbbe489fc",
"tslint": "^5.17.0",
"typescript": "^3.7.4"
},
"devDependencies": {
"@types/mocha": "^7.0.2",
"@types/node": "^12.0.8"
}
}

176
src/client.ts Normal file
View File

@ -0,0 +1,176 @@
/*
Copyright 2020 mx-puppet-skype
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
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Log, ExpireSet, IRemoteRoom } from "mx-puppet-bridge";
import { EventEmitter } from "events";
import * as skypeHttp from "skype-http";
import { Contact as SkypeContact } from "skype-http/dist/lib/types/contact";
const log = new Log("SkypePuppet:client");
const ID_TIMEOUT = 60 * 1000;
export class Client extends EventEmitter {
private api: skypeHttp.Api;
private handledIds: ExpireSet<string>;
private contacts: Map<String, SkypeContact | null> = new Map();
private conversations: Map<String, skypeHttp.Conversation | null> = new Map();
constructor(
private loginUsername: string,
private password: string,
) {
super();
this.handledIds = new ExpireSet(ID_TIMEOUT);
}
public get username(): string {
return "8:" + this.api.context.username;
}
public async connect() {
this.api = await skypeHttp.connect({
credentials: {
username: this.loginUsername,
password: this.password,
},
verbose: true,
});
this.api.on("event", (evt: skypeHttp.events.EventMessage) => {
if (!evt || !evt.resource) {
return;
}
const resource = evt.resource;
if (this.handledIds.has(resource.id)) {
return;
}
this.handledIds.add(resource.id);
log.debug(`Got new event of type ${resource.type}`);
const [type, subtype] = resource.type.split("/");
switch (type) {
case "Text":
this.emit("text", resource);
break;
case "RichText":
if (subtype === "Location") {
this.emit("location", resource);
} else if (subtype) {
this.emit("file", resource);
} else {
this.emit("richText", resource);
}
break;
case "Control":
if (subtype === "Typing" || subtype === "ClearTyping") {
this.emit("typing", resource, subtype === "Typing");
}
break;
case "ThreadActivity":
// log.silly(resource);
break;
}
});
this.api.on("error", (err: Error) => {
log.error("An error occured", err);
});
await this.api.listen();
await this.api.setStatus("Online");
try {
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);
}
} catch (err) {
log.error(err);
}
}
public async disconnect() {
await this.api.stopListening();
}
public async getContact(id: string): Promise<SkypeContact | null> {
const hasStart = Boolean(id.match(/^\d+:/));
const fullId = hasStart ? id : `8:${id}`;
if (this.contacts.has(fullId)) {
return this.contacts.get(fullId) || null;
}
if (hasStart) {
id = id.substr(id.indexOf(":")+1);
}
try {
const rawContact = await this.api.getContact(id);
const contact: SkypeContact = {
personId: rawContact.id.raw,
workloads: null,
mri: rawContact.id.raw,
blocked: false,
authorized: true,
creationTime: new Date(),
displayName: (rawContact.name && rawContact.name.displayName) || rawContact.id.id,
displayNameSource: "profile" as any, // tslint:disable-line no-any
profile: {
avatarUrl: rawContact.avatarUrl || undefined,
name: {
first: (rawContact.name && rawContact.name).first || undefined,
surname: (rawContact.name && rawContact.name).surname || undefined,
nickname: (rawContact.name && rawContact.name).nickname || undefined,
},
},
};
this.contacts.set(contact.mri, contact || null);
return contact || null;
} catch (err) {
// contact not found
this.contacts.set(fullId, null);
return null;
}
}
public async getConversation(room: IRemoteRoom): Promise<skypeHttp.Conversation | null> {
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)) {
return this.conversations.get(id) || null;
}
try {
const conversation = await this.api.getConversation(id);
this.conversations.set(conversation.id, conversation || null);
return conversation || null;
} catch (err) {
// conversation not found
this.conversations.set(id, null);
return null;
}
}
public async sendMessage(conversationId: string, msg: string): Promise<skypeHttp.Api.SendMessageResult> {
return await this.api.sendMessage({
textContent: msg,
}, conversationId);
}
}

125
src/index.ts Normal file
View File

@ -0,0 +1,125 @@
/*
Copyright 2020 mx-puppet-skype
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
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
PuppetBridge,
IPuppetBridgeRegOpts,
Log,
IRetData,
Util,
IProtocolInformation,
} from "mx-puppet-bridge";
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 { Client } from "./client";
const log = new Log("SkypePuppet:index");
const commandOptions = [
{ name: "register", alias: "r", type: Boolean },
{ name: "registration-file", alias: "f", type: String },
{ name: "config", alias: "c", type: String },
{ name: "help", alias: "h", type: Boolean },
];
const options = Object.assign({
"register": false,
"registration-file": "skype-registration.yaml",
"config": "config.yaml",
"help": false,
}, commandLineArgs(commandOptions));
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: "Options",
optionList: commandOptions,
},
]));
process.exit(0);
}
const protocol: IProtocolInformation = {
features: {
// file: true, // no need for the others as we auto-detect types anyways
// presence: true, // we want to be able to send presence
globalNamespace: true,
},
id: "skype",
displayname: "Skype",
externalUrl: "https://skype.com/",
};
const puppet = new PuppetBridge(options["registration-file"], options.config, protocol);
if (options.register) {
// okay, all we have to do is generate a registration file
puppet.readConfig(false);
try {
puppet.generateRegistration({
prefix: "_skypepuppet_",
id: "skype-puppet",
url: `http://${puppet.Config.bridge.bindAddress}:${puppet.Config.bridge.port}`,
});
} catch (err) {
// tslint:disable-next-line:no-console
console.log("Couldn't generate registration file:", err);
}
process.exit(0);
}
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.setCreateUserHook(skype.createUser.bind(skype));
puppet.setCreateRoomHook(skype.createRoom.bind(skype));
puppet.setGetUserIdsInRoomHook(skype.getUserIdsInRoom.bind(skype));
puppet.setGetDataFromStrHook(async (str: string): Promise<IRetData> => {
const retData = {
success: false,
} as IRetData;
const [username, password] = str.split(" ");
try {
const client = new Client(username, password);
await client.connect();
await client.disconnect();
} catch (err) {
retData.error = "Username or password wrong";
return retData;
}
retData.success = true;
const data: any = {
username,
password,
};
retData.data = data;
return retData;
});
puppet.setBotHeaderMsgHook((): string => {
return "Skype Puppet Bridge";
});
await puppet.start();
}
// tslint:disable-next-line:no-floating-promises
run();

277
src/skype.ts Normal file
View File

@ -0,0 +1,277 @@
/*
Copyright 2020 mx-puppet-skype
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
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
PuppetBridge, IRemoteUser, IRemoteRoom, IReceiveParams, IMessageEvent, IFileEvent, Log, MessageDeduplicator,
} from "mx-puppet-bridge";
import { Client } from "./client";
import * as skypeHttp from "skype-http";
import { Contact as SkypeContact } from "skype-http/dist/lib/types/contact";
import * as decodeHtml from "decode-html";
import * as escapeHtml from "escape-html";
const log = new Log("SkypePuppet:skype");
interface ISkypePuppet {
client: Client;
data: any;
}
interface ISkypePuppets {
[puppetId: number]: ISkypePuppet;
}
export class Skype {
private puppets: ISkypePuppets = {};
private messageDeduplicator: MessageDeduplicator;
constructor(
private puppet: PuppetBridge,
) {
this.messageDeduplicator = new MessageDeduplicator();
}
public getUserParams(puppetId: number, contact: SkypeContact): IRemoteUser {
return {
puppetId,
userId: contact.mri,
name: contact.displayName,
avatarUrl: contact.profile ? contact.profile.avatarUrl : null,
};
}
public getRoomParams(puppetId: number, conversation: skypeHttp.Conversation): IRemoteRoom {
const roomType = Number(conversation.id.split(":")[0]);
let roomId = conversation.id;
const isDirect = roomType === 8;
if (isDirect) {
return {
puppetId,
roomId: `dm-${puppetId}-${roomId}`,
isDirect: true,
};
}
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);
}
}
return {
puppetId,
roomId,
name,
avatarUrl,
};
}
public async getSendParams(puppetId: number, resource: skypeHttp.resources.Resource): Promise<IReceiveParams | null> {
const roomType = Number(resource.conversation.split(":")[0]);
let roomId = resource.conversation;
const p = this.puppets[puppetId];
const contact = await p.client.getContact(resource.from.raw);
const conversation = await p.client.getConversation({
puppetId,
roomId: resource.conversation,
});
if (!contact || !conversation) {
return null;
}
return {
user: await this.getUserParams(puppetId, contact),
room: await this.getRoomParams(puppetId, conversation),
eventId: (resource as any).clientId, // tslint:disable-line no-any
};
}
public async startClient(puppetId: number) {
const p = this.puppets[puppetId];
if (!p) {
return;
}
const client = p.client;
client.on("text", async (resource: skypeHttp.resources.TextResource) => {
try {
await this.handleSkypeText(puppetId, resource, false);
} catch (err) {
log.error("Error while handling text event", err);
}
});
client.on("richText", async (resource: skypeHttp.resources.RichTextResource) => {
try {
await this.handleSkypeText(puppetId, resource, true);
} catch (err) {
log.error("Error while handling richText event", err);
}
});
client.on("location", async (resource: skypeHttp.resources.RichTextLocationResource) => {
try {
} catch (err) {
log.error("Error while handling location event", err);
}
});
client.on("file", async (resource: skypeHttp.resources.FileResource) => {
try {
} catch (err) {
log.error("Error while handling file event", err);
}
});
client.on("typing", async (resource: skypeHttp.resources.Resource, typing: boolean) => {
try {
} catch (err) {
log.error("Error while handling typing event", err);
}
});
await client.connect();
await this.puppet.setUserId(puppetId, client.username);
}
public async newPuppet(puppetId: number, data: any) {
if (this.puppets[puppetId]) {
await this.deletePuppet(puppetId);
}
const client = new Client(data.username, data.password);
this.puppets[puppetId] = {
client,
data,
};
await this.startClient(puppetId);
}
public async deletePuppet(puppetId: number) {
const p = this.puppets[puppetId];
if (!p) {
return;
}
await p.client.disconnect();
delete this.puppets[puppetId];
}
public async createUser(remoteUser: IRemoteUser): Promise<IRemoteUser | null> {
const p = this.puppets[remoteUser.puppetId];
if (!p) {
return null;
}
log.info(`Received create request for user update puppetId=${remoteUser.puppetId} userId=${remoteUser.userId}`);
const contact = await p.client.getContact(remoteUser.userId);
if (!contact) {
return null;
}
return this.getUserParams(remoteUser.puppetId, contact);
}
public async createRoom(room: IRemoteRoom): Promise<IRemoteRoom | null> {
const p = this.puppets[room.puppetId];
if (!p) {
return null;
}
log.info(`Received create request for channel update puppetId=${room.puppetId} roomId=${room.roomId}`);
const conversation = await p.client.getConversation(room);
if (!conversation) {
return null;
}
return this.getRoomParams(room.puppetId, conversation);
}
public async getUserIdsInRoom(room: IRemoteRoom): Promise<Set<string> | null> {
const p = this.puppets[room.puppetId];
if (!p) {
return null;
}
const conversation = await p.client.getConversation(room);
if (!conversation) {
return null;
}
const users = new Set<string>();
if (conversation.members) {
for (const member of conversation.members) {
users.add(member);
}
}
return users;
}
public async handleMatrixMessage(room: IRemoteRoom, data: IMessageEvent) {
const p = this.puppets[room.puppetId];
if (!p) {
return;
}
const conversation = await p.client.getConversation(room);
if (!conversation) {
log.warn(`Room ${room.roomId} not found!`);
return;
}
let msg: string;
if (data.formattedBody) {
msg = data.formattedBody;
} else {
msg = escapeHtml(data.body);
}
const dedupeKey = `${room.puppetId};${room.roomId}`;
this.messageDeduplicator.lock(dedupeKey, p.client.username, msg);
const ret = await p.client.sendMessage(conversation.id, msg);
const eventId = ret && ret.clientMessageId;
this.messageDeduplicator.unlock(dedupeKey, p.client.username, eventId);
if (eventId) {
await this.puppet.eventStore.insert(room.puppetId, data.eventId!, eventId);
}
}
private async handleSkypeText(
puppetId: number,
resource: skypeHttp.resources.TextResource | skypeHttp.resources.RichTextResource,
rich: boolean,
) {
const p = this.puppets[puppetId];
if (!p) {
return;
}
log.info("Got new skype message");
log.silly(resource);
const params = await this.getSendParams(puppetId, resource);
if (!params) {
log.warn("Couldn't generate params");
return;
}
const dedupeKey = `${puppetId};${params.room.roomId}`;
if (await this.messageDeduplicator.dedupe(dedupeKey, params.user.userId, params.eventId, resource.content)) {
return;
}
if (!rich) {
await this.puppet.sendMessage(params, {
body: resource.content,
});
} else if (resource.native && resource.native.skypeeditedid) {
if (resource.content) {
await this.puppet.sendEdit(params, resource.native.skypeeditedid, {
body: resource.content,
formattedBody: resource.content,
});
} else {
await this.puppet.sendRedact(params, resource.native.skypeeditedid);
}
} else {
await this.puppet.sendMessage(params, {
body: resource.content,
formattedBody: resource.content,
});
}
}
}

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"target": "es2016",
"noImplicitAny": false,
"inlineSourceMap": true,
"outDir": "./build",
"types": ["node"],
"strictNullChecks": true,
"allowSyntheticDefaultImports": true
},
"compileOnSave": true,
"include": [
"src/**/*",
]
}

42
tslint.json Normal file
View File

@ -0,0 +1,42 @@
{
"extends": "tslint:recommended",
"rules": {
"ordered-imports": false,
"no-trailing-whitespace": "error",
"max-classes-per-file": {
"severity": "warning"
},
"object-literal-sort-keys": "off",
"no-any":{
"severity": "warning"
},
"arrow-return-shorthand": true,
"no-magic-numbers": [true, -1, 0, 1, 1000],
"prefer-for-of": true,
"typedef": {
"severity": "warning"
},
"await-promise": [true],
"curly": true,
"no-empty": false,
"no-invalid-this": true,
"no-string-throw": {
"severity": "warning"
},
"no-unused-expression": true,
"prefer-const": true,
"object-literal-sort-keys": false,
"indent": [true, "tabs", 1],
"max-file-line-count": {
"severity": "warning",
"options": [500]
},
"no-duplicate-imports": true,
"array-type": [true, "array"],
"promise-function-async": true,
"no-bitwise": true,
"no-debugger": true,
"no-floating-promises": true,
"prefer-template": [true, "allow-single-concat"]
}
}