diff --git a/package-lock.json b/package-lock.json index 035ef20..1c23204 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2511,6 +2511,11 @@ "resolved": "https://registry.npmjs.org/lodash.padend/-/lodash.padend-4.6.1.tgz", "integrity": "sha1-U8y6BH0G4VjTEfRdpiX05J5vFm4=" }, + "lodash.toarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.toarray/-/lodash.toarray-4.4.0.tgz", + "integrity": "sha1-JMS/zWsvuji/0FlNsRedjptlZWE=" + }, "logform": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/logform/-/logform-2.1.2.tgz", @@ -2863,6 +2868,14 @@ "semver": "^5.4.1" } }, + "node-emoji": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.10.0.tgz", + "integrity": "sha512-Yt3384If5H6BYGVHiHwTL+99OzJKHhgp82S8/dktEK73T26BazdgZ4JZh92xSVtGNJvz9UbXdNAc5hcrXV42vw==", + "requires": { + "lodash.toarray": "^4.4.0" + } + }, "node-html-parser": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.2.13.tgz", @@ -3606,8 +3619,8 @@ } }, "skype-http": { - "version": "git://github.com/Sorunome/skype-http.git#10555125f46307bbff93a8c4779889f4100669d2", - "from": "git://github.com/Sorunome/skype-http.git#10555125f46307bbff93a8c4779889f4100669d2", + "version": "git://github.com/Sorunome/skype-http.git#e8a92f8bc4929443bbba9755b6dd1ce90026bf4e", + "from": "git://github.com/Sorunome/skype-http.git#e8a92f8bc4929443bbba9755b6dd1ce90026bf4e", "requires": { "@types/cheerio": "^0.22.12", "@types/escape-html": "0.0.20", diff --git a/package.json b/package.json index 11041bb..bf957bf 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "author": "Sorunome", "dependencies": { + "cheerio": "^1.0.0-rc.3", "command-line-args": "^5.1.1", "command-line-usage": "^5.0.5", "decode-html": "^2.0.0", @@ -18,8 +19,9 @@ "events": "^3.0.0", "js-yaml": "^3.13.1", "mx-puppet-bridge": "0.0.35-1", + "node-emoji": "^1.10.0", "node-html-parser": "^1.2.13", - "skype-http": "git://github.com/Sorunome/skype-http#10555125f46307bbff93a8c4779889f4100669d2", + "skype-http": "git://github.com/Sorunome/skype-http#e8a92f8bc4929443bbba9755b6dd1ce90026bf4e", "tslint": "^5.17.0", "typescript": "^3.7.4" }, diff --git a/src/client.ts b/src/client.ts index b11c28b..483cf8c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -20,13 +20,18 @@ import { Context as SkypeContext } from "skype-http/dist/lib/interfaces/api/cont const log = new Log("SkypePuppet:client"); +// tslint:disable no-magic-numbers const ID_TIMEOUT = 60000; +const CONTACTS_DELTA_INTERVAL = 5 * 60 * 1000; +// tslint:enable no-magic-numbers export class Client extends EventEmitter { public contacts: Map = new Map(); public conversations: Map = new Map(); private api: skypeHttp.Api; private handledIds: ExpireSet; + private lastContactsDate: Date = new Date(); + private contactsInterval: NodeJS.Timeout | null = null; constructor( private loginUsername: string, private password: string, @@ -46,7 +51,7 @@ export class Client extends EventEmitter { public async connect() { let connectedWithAuth = false; - if (this.state && false) { + if (this.state) { try { this.api = await skypeHttp.connect({ state: this.state, verbose: true }); connectedWithAuth = true; @@ -71,63 +76,52 @@ export class Client extends EventEmitter { connectedWithAuth = false; } - 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": - if (subtype === "MemberConsumptionHorizonUpdate") { - this.emit("presence", resource); - } - break; - } - }); - - this.api.on("error", (err: Error) => { - log.error("An error occured", err); - this.emit("error", err); - }); - - 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); - } + await this.startupApi(); await this.api.listen(); await this.api.setStatus("Online"); + if (connectedWithAuth) { + let resolved = false; + return new Promise((resolve, reject) => { + const TIMEOUT_SUCCESS = 5000; + setTimeout(() => { + if (resolved) { + return; + } + resolved = true; + 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(); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + } } public async disconnect() { await this.api.stopListening(); + if (this.contactsInterval) { + clearInterval(this.contactsInterval); + this.contactsInterval = null; + } } public async getContact(id: string): Promise { @@ -193,7 +187,7 @@ export class Client extends EventEmitter { } public async downloadFile(url: string): Promise { - if (!url.includes("/views/imgpsh_fullsize_anim")) { + if (!url.includes("/views/")) { url = url + "/views/imgpsh_fullsize_anim"; } return await Util.DownloadFile(url, { @@ -238,4 +232,75 @@ export class Client extends EventEmitter { ): Promise { 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("error", (err: Error) => { + log.error("An error occured", err); + this.emit("error", err); + }); + + const contacts = await this.api.getContacts(); + for (const contact of contacts) { + this.contacts.set(contact.mri, contact); + } + this.lastContactsDate = new Date(); + const conversations = await this.api.getConversations(); + for (const conversation of conversations) { + this.conversations.set(conversation.id, conversation); + } + + if (this.contactsInterval) { + clearInterval(this.contactsInterval); + this.contactsInterval = null; + } + this.contactsInterval = setInterval(this.updateContacts.bind(this), CONTACTS_DELTA_INTERVAL); + } + + private async updateContacts() { + const contacts = await this.api.getContacts(true); + for (const contact of contacts) { + this.contacts.set(contact.mri, contact); + this.emit("updateContact", contact); + } + this.lastContactsDate = new Date(); + } } diff --git a/src/matrixmessageparser.ts b/src/matrixmessageparser.ts index d76c7ad..5a1be43 100644 --- a/src/matrixmessageparser.ts +++ b/src/matrixmessageparser.ts @@ -24,7 +24,7 @@ export class MatrixMessageParser { } private walkChildNodes(node: Parser.Node): string { - return node.childNodes.map((node) => this.walkNode(node)).join(""); + return node.childNodes.map((n) => this.walkNode(n)).join(""); } private escape(s: string): string { diff --git a/src/skype.ts b/src/skype.ts index 74f73ca..ab8dc53 100644 --- a/src/skype.ts +++ b/src/skype.ts @@ -23,6 +23,7 @@ import * as decodeHtml from "decode-html"; import * as escapeHtml from "escape-html"; import { MatrixMessageParser } from "./matrixmessageparser"; import { SkypeMessageParser } from "./skypemessageparser"; +import * as cheerio from "cheerio"; const log = new Log("SkypePuppet:skype"); @@ -104,7 +105,7 @@ export class Skype { return { user: this.getUserParams(puppetId, contact), room: this.getRoomParams(puppetId, conversation), - eventId: (resource as any).clientId || resource.native.clientmessageid || resource.id, // tslint:disable-line no-any + eventId: resource.id, // tslint:disable-line no-any }; } @@ -125,16 +126,16 @@ export class Skype { const client = p.client; client.on("text", async (resource: skypeHttp.resources.TextResource) => { try { - await this.handleSkypeText(puppetId, resource, false); + await this.handleSkypeText(puppetId, resource); } catch (err) { log.error("Error while handling text event", err); } }); - client.on("richText", async (resource: skypeHttp.resources.RichTextResource) => { + client.on("edit", async (resource: skypeHttp.resources.RichTextResource) => { try { - await this.handleSkypeText(puppetId, resource, true); + await this.handleSkypeEdit(puppetId, resource); } catch (err) { - log.error("Error while handling richText event", err); + log.error("Error while handling edit event", err); } }); client.on("location", async (resource: skypeHttp.resources.RichTextLocationResource) => { @@ -165,6 +166,14 @@ export class Skype { log.error("Error while handling presence event", err); } }); + client.on("updateContact", async (contact: SkypeContact) => { + try { + const remoteUser = this.getUserParams(puppetId, contact); + await this.puppet.updateUser(remoteUser); + } catch (err) { + log.error("Error while handling updateContact event", err); + } + }); const MINUTE = 60000; client.on("error", async (err: Error) => { await this.puppet.sendStatusMessage(puppetId, "Error: " + err); @@ -324,9 +333,8 @@ export class Skype { const dedupeKey = `${room.puppetId};${room.roomId}`; this.messageDeduplicator.lock(dedupeKey, p.client.username, msg); const ret = await p.client.sendMessage(conversation.id, msg); - const dedupeId = ret && ret.clientMessageId; const eventId = ret && ret.MessageId; - this.messageDeduplicator.unlock(dedupeKey, p.client.username, dedupeId); + this.messageDeduplicator.unlock(dedupeKey, p.client.username, eventId); if (eventId) { await this.puppet.eventSync.insert(room.puppetId, data.eventId!, eventId); } @@ -412,9 +420,8 @@ export class Skype { 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 dedupeId = ret && ret.clientMessageId; const eventId = ret && ret.MessageId; - this.messageDeduplicator.unlock(dedupeKey, p.client.username, dedupeId); + this.messageDeduplicator.unlock(dedupeKey, p.client.username, eventId); if (eventId) { await this.puppet.eventSync.insert(room.puppetId, data.eventId!, eventId); } @@ -423,12 +430,12 @@ export class Skype { private async handleSkypeText( puppetId: number, resource: skypeHttp.resources.TextResource | skypeHttp.resources.RichTextResource, - rich: boolean, ) { 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); @@ -438,7 +445,66 @@ export class Skype { } let msg = resource.content; let emote = false; - if (resource.native && resource.native.skypeemoteoffset) { + if (resource.native.skypeemoteoffset) { + emote = true; + msg = msg.substr(Number(resource.native.skypeemoteoffset)); + } + const dedupeKey = `${puppetId};${params.room.roomId}`; + if (rich && msg.trim().startsWith("")) { + // 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; + } + let sendMsg: IMessageEvent; + if (rich) { + sendMsg = this.skypeMessageParser.parse(msg); + } else { + sendMsg = { + body: msg, + }; + } + if (emote) { + sendMsg.emote = true; + } + await this.puppet.sendMessage(params, sendMsg); + } + + private async handleSkypeEdit( + puppetId: number, + resource: skypeHttp.resources.TextResource | skypeHttp.resources.RichTextResource, + ) { + 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)); } @@ -458,17 +524,13 @@ export class Skype { if (emote) { sendMsg.emote = true; } - if (resource.native && resource.native.skypeeditedid) { - if (resource.content) { - await this.puppet.sendEdit(params, resource.native.skypeeditedid, sendMsg); - } else if (p.deletedMessages.has(resource.native.skypeeditedid)) { - log.silly("normal message redact dedupe"); - return; - } else { - await this.puppet.sendRedact(params, resource.native.skypeeditedid); - } + 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.sendMessage(params, sendMsg); + await this.puppet.sendRedact(params, resource.id); } } @@ -490,14 +552,6 @@ export class Skype { log.silly("file message dedupe"); return; } - if (resource.native && resource.native.skypeeditedid && !resource.uri) { - if (p.deletedMessages.has(resource.native.skypeeditedid)) { - log.silly("file message redact dedupe"); - return; - } - await this.puppet.sendRedact(params, resource.native.skypeeditedid); - return; - } const buffer = await p.client.downloadFile(resource.uri); await this.puppet.sendFileDetect(params, buffer, filename); } @@ -519,7 +573,7 @@ export class Skype { private async handleSkypePresence(puppetId: number, resource: skypeHttp.resources.Resource) { const p = this.puppets[puppetId]; - if (!p || !resource.native) { + if (!p) { return; } log.info("Got new skype presence event"); diff --git a/src/skypemessageparser.ts b/src/skypemessageparser.ts index 71fd7a7..113bb47 100644 --- a/src/skypemessageparser.ts +++ b/src/skypemessageparser.ts @@ -15,6 +15,7 @@ import * as Parser from "node-html-parser"; import * as decodeHtml from "decode-html"; import * as escapeHtml from "escape-html"; import { IMessageEvent } from "mx-puppet-bridge"; +import * as emoji from "node-emoji"; export class SkypeMessageParser { public parse(msg: string): IMessageEvent { @@ -26,7 +27,13 @@ export class SkypeMessageParser { } private walkChildNodes(node: Parser.Node): IMessageEvent { - return node.childNodes.map((node) => this.walkNode(node)).reduce((acc, curr) => { + if (!node.childNodes.length) { + return { + body: "", + formattedBody: "", + }; + } + return node.childNodes.map((n) => this.walkNode(n)).reduce((acc, curr) => { return { body: acc.body + curr.body, formattedBody: acc.formattedBody! + curr.formattedBody!, @@ -83,7 +90,82 @@ export class SkypeMessageParser { formattedBody: `${child.formattedBody}`, }; } + case "ss": { + // skype emoji + const type = nodeHtml.attributes.type; + let emojiType = { + smile: "slightly_smiling_face", + sad: "slightly_frowning_face", + laugh: "grin", + cool: "sunglasses", + hearteyes: "heart_eyes", + stareyes: "star-struck", + like: "thumbsup", + cwl: "rolling_on_the_floor_laughing", + xd: "laughing", + happyface: "smiley", + happyeyes: "smile", + // hysterical: "", TODO: find + sweatgrinning: "sweat_smile", + // smileeyes: "", TODO: find + blankface: "no_mouth", + surprised: "astonished", + upsidedownface: "upside_down_face", + loudlycrying: "sob", + shivering: "🥶", + speechless: "😐️", + tongueout: "stuck_out_tongue", + winktongueout: "stuck_out_tongue_winking_eye", + inlove: "🥰", + // wonder: "", TODO: find + // dull: "", TODO: find + yawn: "🥱", + puke: "face_vomiting", + // doh: "", TODO: find + angryface: "angry", + angry: "rage", + // wasntme: "", TODO: find + // worry: "", TODO: find + screamingfear: "scream", + // veryconfused: "", TODO: find + // mmm: "", TODO: find + nerdy: "nerd_face", + loveearth: "🌍️", + // rainbowsmile: "", TODO: find + // lipssealed: "", TODO: find + devil: "smiling_imp", + // envy: "", TODO: find + // makeup: "", TODO: find + think: "thinking_face", + rofl: "rolling_on_the_floor_laughing", + }[type]; + const haveEmojiType = Boolean(emojiType); + if (!emojiType) { + emojiType = type; + } + let e = emoji.get(emojiType); + if (e === `:${emojiType}:`) { + e = emoji.get(emojiType + "_face"); + } + if (!e.startsWith(":")) { + return { + body: e, + formattedBody: e, + }; + } + if (haveEmojiType) { + return { + body: emojiType, + formattedBody: emojiType, + }; + } + return { + body: `(${type})`, + formattedBody: `(${escapeHtml(type)})`, + }; + } case "e_m": + // empty edit tag return { body: "", formattedBody: "",