diff --git a/config/config.sample.yaml b/config/config.sample.yaml index 925df2bbadf7195dd2049b2c01c901c61620e0fa..146668def098cd7a639b253fb4cf49030c6ea3b3 100644 --- a/config/config.sample.yaml +++ b/config/config.sample.yaml @@ -19,20 +19,11 @@ bridge: disablePresence: false # Disable sending typing notifications when somebody on Discord types. disableTypingNotifications: false - # Disable parsing discord usernames out of matrix messages so - # that it highlights discord users. - # WARNING: Not always 100% accurate, but close enough usually. - disableDiscordMentions: false # Disable deleting messages on Discord if a message is redacted on Matrix. disableDeletionForwarding: false # Enable users to bridge rooms using !discord commands. See # https://t2bot.io/discord for instructions. enableSelfServiceBridging: false - # For both below, a space is inserted after @ to stop the mentions working. - # Disable relaying @everyone to Discord. Non-puppeted users can abuse this. - disableEveryoneMention: false - # Disable relaying @here to Discord. Non-puppeted users can abuse this. - disableHereMention: false # Authentication configuration for the discord bot. auth: clientID: "12345" diff --git a/config/config.schema.yaml b/config/config.schema.yaml index 4f832fa6ce069b1a2560c318a806e09c9127b00a..17b816ca6060f16f7bd0cd4e6872ce2c9d102a08 100644 --- a/config/config.schema.yaml +++ b/config/config.schema.yaml @@ -16,16 +16,10 @@ properties: type: "boolean" disableTypingNotifications: type: "boolean" - disableDiscordMentions: - type: "boolean" disableDeletionForwarding: type: "boolean" enableSelfServiceBridging: type: "boolean" - disableEveryoneMention: - type: "boolean" - disableHereMention: - type: "boolean" auth: type: "object" required: ["botToken", "clientID"] diff --git a/package-lock.json b/package-lock.json index bbd3538fe25e7f5a4d5c0e7e2806b16136b105f5..eb94d0b206ddd9af8befd9779390042aed5e3557 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1375,8 +1375,7 @@ "he": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", - "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", - "dev": true + "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=" }, "highlight.js": { "version": "9.13.1", @@ -2064,6 +2063,14 @@ "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", "dev": true }, + "node-html-parser": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.1.11.tgz", + "integrity": "sha512-KOjvmbk0yWuy/cN8uqk6bVYS0Lue+jVWcLO/zmnCtz8FPXhj00apBN376FoM6QmFMMbJwXQdKf5ko6G1S6bnrw==", + "requires": { + "he": "1.1.1" + } + }, "nopt": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", diff --git a/package.json b/package.json index 70430f21574548a41aca058155c92071f4928818..9a3b0a85cf7687f54f382c9d1332872d872db860 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "matrix-appservice-bridge": "^1.7.0", "mime": "^1.6.0", "moment": "^2.22.2", + "node-html-parser": "^1.1.11", "pg-promise": "^8.5.1", "tslint": "^5.11.0", "typescript": "^3.1.3", diff --git a/src/bot.ts b/src/bot.ts index 5c651649edcf803922acb10b10bb26d23da3448d..c76df58a4f8d65a0eec5d452aee06d11717304c7 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -5,7 +5,11 @@ import { DbEmoji } from "./db/dbdataemoji"; import { DbEvent } from "./db/dbdataevent"; import { MatrixUser, RemoteUser, Bridge, Entry, Intent } from "matrix-appservice-bridge"; import { Util } from "./util"; -import { MessageProcessor, MessageProcessorOpts, MessageProcessorMatrixResult } from "./messageprocessor"; +import { + DiscordMessageProcessor, + DiscordMessageProcessorOpts, + DiscordMessageProcessorResult, +} from "./discordmessageprocessor"; import { MatrixEventProcessor, MatrixEventProcessorOpts } from "./matrixeventprocessor"; import { PresenceHandler } from "./presencehandler"; import { Provisioner } from "./provisioner"; @@ -49,7 +53,7 @@ export class DiscordBot { private bridge: Bridge; private presenceInterval: number; private sentMessages: string[]; - private msgProcessor: MessageProcessor; + private discordMsgProcessor: DiscordMessageProcessor; private mxEventProcessor: MatrixEventProcessor; private presenceHandler: PresenceHandler; private userSync: UserSyncroniser; @@ -64,8 +68,8 @@ export class DiscordBot { this.store = store; this.sentMessages = []; this.clientFactory = new DiscordClientFactory(store, config.auth); - this.msgProcessor = new MessageProcessor( - new MessageProcessorOpts(this.config.bridge.domain, this), + this.discordMsgProcessor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts(this.config.bridge.domain, this), ); this.presenceHandler = new PresenceHandler(this); this.discordMessageQueue = {}; @@ -325,15 +329,8 @@ export class DiscordBot { const result = await this.LookupRoom(guildId, channelId, event.sender); const chan = result.channel; const botUser = result.botUser; - let profile = null; - if (result.botUser) { - // We are doing this through webhooks so fetch the user profile. - profile = await mxClient.getStateEvent(event.room_id, "m.room.member", event.sender); - if (profile === null) { - log.warn(`User ${event.sender} has no member state. That's odd.`); - } - } - const embedSet = await this.mxEventProcessor.EventToEmbed(event, profile, chan); + + const embedSet = await this.mxEventProcessor.EventToEmbed(event, chan); const embed = embedSet.messageEmbed; const opts: Discord.MessageOptions = {}; const file = await this.mxEventProcessor.HandleAttachment(event, mxClient); @@ -516,7 +513,15 @@ export class DiscordBot { } } - private async SendMatrixMessage(matrixMsg: MessageProcessorMatrixResult, chan: Discord.Channel, + public async GetEmojiByMxc(mxc: string): Promise<DbEmoji> { + const dbEmoji = await this.store.Get(DbEmoji, {mxc_url: mxc}); + if (!dbEmoji || !dbEmoji.Result) { + throw new Error("Couldn't fetch from store"); + } + return dbEmoji; + } + + private async SendMatrixMessage(matrixMsg: DiscordMessageProcessorResult, chan: Discord.Channel, guild: Discord.Guild, author: Discord.User, msgID: string): Promise<boolean> { const rooms = await this.channelSync.GetRoomIdsFromChannel(chan); @@ -654,7 +659,7 @@ export class DiscordBot { if (msg.content === null) { return; } - const result = await this.msgProcessor.FormatDiscordMessage(msg); + const result = await this.discordMsgProcessor.FormatMessage(msg); if (!result.body) { return; } @@ -699,7 +704,7 @@ export class DiscordBot { } // Create a new edit message using the old and new message contents - const editedMsg = await this.msgProcessor.FormatEdit(oldMsg, newMsg); + const editedMsg = await this.discordMsgProcessor.FormatEdit(oldMsg, newMsg); // Send the message to all bridged matrix rooms if (!await this.SendMatrixMessage(editedMsg, newMsg.channel, newMsg.guild, newMsg.author, newMsg.id)) { diff --git a/src/db/dbdataemoji.ts b/src/db/dbdataemoji.ts index f816c01f1661534fcd31784846fe28faa223f6b6..0813ccc00fb5d752e27a7f09c174220a6476c4d8 100644 --- a/src/db/dbdataemoji.ts +++ b/src/db/dbdataemoji.ts @@ -12,11 +12,19 @@ export class DbEmoji implements IDbData { public Result: boolean; public async RunQuery(store: DiscordStore, params: ISqlCommandParameters): Promise<void> { - const row = await store.db.Get(` + let query = ` SELECT * FROM emoji - WHERE emoji_id = $id`, { + WHERE emoji_id = $id`; + if (params.mxc_url) { + query = ` + SELECT * + FROM emoji + WHERE mxc_url = $mxc`; + } + const row = await store.db.Get(query, { id: params.emoji_id, + mxc: params.mxc_url, }); this.Result = row !== undefined; if (this.Result) { diff --git a/src/messageprocessor.ts b/src/discordmessageprocessor.ts similarity index 92% rename from src/messageprocessor.ts rename to src/discordmessageprocessor.ts index 20ab5828a6748f2b2b95eab7aeba6dc4b9af821b..bddd2cfe748ffcaa9e287a546f64c71dd4315014 100644 --- a/src/messageprocessor.ts +++ b/src/discordmessageprocessor.ts @@ -5,7 +5,7 @@ import * as escapeHtml from "escape-html"; import { Util } from "./util"; import { Log } from "./log"; -const log = new Log("MessageProcessor"); +const log = new Log("DiscordMessageProcessor"); const MATRIX_TO_LINK = "https://matrix.to/#/"; const MXC_INSERT_REGEX = /\x01(\w+)\x01([01])\x01([0-9]*)\x01/g; @@ -14,13 +14,13 @@ const ANIMATED_MXC_INSERT_REGEX_GROUP = 2; const ID_MXC_INSERT_REGEX_GROUP = 3; const EMOJI_SIZE = 32; -export class MessageProcessorOpts { +export class DiscordMessageProcessorOpts { constructor(readonly domain: string, readonly bot?: DiscordBot) { } } -export class MessageProcessorMatrixResult { +export class DiscordMessageProcessorResult { public formattedBody: string; public body: string; public msgtype: string; @@ -35,19 +35,19 @@ interface IEmojiNode extends IDiscordNode { name: string; } -export class MessageProcessor { - private readonly opts: MessageProcessorOpts; - constructor(opts: MessageProcessorOpts, bot: DiscordBot | null = null) { +export class DiscordMessageProcessor { + private readonly opts: DiscordMessageProcessorOpts; + constructor(opts: DiscordMessageProcessorOpts, bot: DiscordBot | null = null) { // Backwards compat if (bot !== null) { - this.opts = new MessageProcessorOpts(opts.domain, bot); + this.opts = new DiscordMessageProcessorOpts(opts.domain, bot); } else { this.opts = opts; } } - public async FormatDiscordMessage(msg: Discord.Message): Promise<MessageProcessorMatrixResult> { - const result = new MessageProcessorMatrixResult(); + public async FormatMessage(msg: Discord.Message): Promise<DiscordMessageProcessorResult> { + const result = new DiscordMessageProcessorResult(); let content = msg.content; @@ -76,10 +76,13 @@ export class MessageProcessor { return result; } - public async FormatEdit(oldMsg: Discord.Message, newMsg: Discord.Message): Promise<MessageProcessorMatrixResult> { + public async FormatEdit( + oldMsg: Discord.Message, + newMsg: Discord.Message, + ): Promise<DiscordMessageProcessorResult> { // TODO: Produce a nice, colored diff between the old and new message content oldMsg.content = `*edit:* ~~${oldMsg.content}~~ -> ${newMsg.content}`; - return this.FormatDiscordMessage(oldMsg); + return this.FormatMessage(oldMsg); } public InsertEmbeds(content: string, msg: Discord.Message): string { diff --git a/src/matrixeventprocessor.ts b/src/matrixeventprocessor.ts index 8005d0041894d53c93bd1e5c758cd3b67cd57730..2ad0107368de6ebf72a54f592d7917a9a968eca2 100644 --- a/src/matrixeventprocessor.ts +++ b/src/matrixeventprocessor.ts @@ -1,5 +1,4 @@ import * as Discord from "discord.js"; -import { MessageProcessorOpts, MessageProcessor } from "./messageprocessor"; import { DiscordBot } from "./bot"; import { DiscordBridgeConfig } from "./config"; import * as escapeStringRegexp from "escape-string-regexp"; @@ -8,7 +7,8 @@ import * as path from "path"; import * as mime from "mime"; import { MatrixUser, Bridge } from "matrix-appservice-bridge"; import { Client as MatrixClient } from "matrix-js-sdk"; -import { IMatrixEvent, IMatrixEventContent } from "./matrixtypes"; +import { IMatrixEvent, IMatrixEventContent, IMatrixMessage } from "./matrixtypes"; +import { MatrixMessageProcessor, IMatrixMessageProcessorParams } from "./matrixmessageprocessor"; import { Log } from "./log"; const log = new Log("MatrixEventProcessor"); @@ -16,7 +16,6 @@ const log = new Log("MatrixEventProcessor"); const MaxFileSize = 8000000; const MIN_NAME_LENGTH = 2; const MAX_NAME_LENGTH = 32; -const DISCORD_EMOJI_REGEX = /:(\w+):/g; const DISCORD_AVATAR_WIDTH = 128; const DISCORD_AVATAR_HEIGHT = 128; @@ -39,11 +38,13 @@ export class MatrixEventProcessor { private config: DiscordBridgeConfig; private bridge: Bridge; private discord: DiscordBot; + private matrixMsgProcessor: MatrixMessageProcessor; constructor(opts: MatrixEventProcessorOpts) { this.config = opts.config; this.bridge = opts.bridge; this.discord = opts.discord; + this.matrixMsgProcessor = new MatrixMessageProcessor(this.discord); } public StateEventToMessage(event: IMatrixEvent, channel: Discord.TextChannel): string | undefined { @@ -85,93 +86,46 @@ export class MatrixEventProcessor { } public async EventToEmbed( - event: IMatrixEvent, profile: IMatrixEvent|null, channel: Discord.TextChannel, + event: IMatrixEvent, channel: Discord.TextChannel, getReply: boolean = true, ): Promise<IMatrixEventProcessorResult> { - let body: string = this.config.bridge.disableDiscordMentions ? event.content!.body as string : - this.FindMentionsInPlainBody( - event.content!.body as string, - channel.members.array(), - ); - - if (event.type === "m.sticker") { - body = ""; + const mxClient = this.bridge.getClientFactory().getClientAs(); + let profile: IMatrixEvent | null = null; + try { + profile = await mxClient.getStateEvent(event.room_id, "m.room.member", event.sender); + if (!profile) { + profile = await mxClient.getProfileInfo(event.sender); + } + if (!profile) { + log.warn(`User ${event.sender} has no member state and no profile. That's odd.`); + } + } catch (err) { + log.warn(`Trying to fetch member state or profile for ${event.sender} failed`, err); } - // Replace @everyone - if (this.config.bridge.disableEveryoneMention) { - body = body.replace(new RegExp(`@everyone`, "g"), "@ everyone"); + const params = { + mxClient, + roomId: event.room_id, + userId: event.sender, + } as IMatrixMessageProcessorParams; + if (profile) { + params.displayname = profile.displayname; } - // Replace @here - if (this.config.bridge.disableHereMention) { - body = body.replace(new RegExp(`@here`, "g"), "@ here"); + let body: string = ""; + if (event.type !== "m.sticker") { + body = await this.matrixMsgProcessor.FormatMessage(event.content as IMatrixMessage, channel.guild, params); } - /* See issue #82 - const isMarkdown = (event.content.format === "org.matrix.custom.html"); - if (!isMarkdown) { - body = "\\" + body; - }*/ - - // Replace /me with * username ... - if (event.content!.msgtype === "m.emote") { - if (profile && - profile.displayname && - profile.displayname.length >= MIN_NAME_LENGTH && - profile.displayname.length <= MAX_NAME_LENGTH) { - body = `*${profile.displayname} ${body}*`; - } else { - body = `*${body}*`; - } - } - - // replace <del>blah</del> with ~~blah~~ - body = body.replace(/<del>([^<]*)<\/del>/g, "~~$1~~"); - - // Handle discord custom emoji - body = this.ReplaceDiscordEmoji(body, channel.guild); - const messageEmbed = new Discord.RichEmbed(); - const replyEmbedAndBody = await this.GetEmbedForReply(event); - messageEmbed.setDescription(replyEmbedAndBody ? replyEmbedAndBody[1] : body); + messageEmbed.setDescription(body); await this.SetEmbedAuthor(messageEmbed, event.sender, profile); + const replyEmbed = getReply ? (await this.GetEmbedForReply(event, channel)) : undefined; return { messageEmbed, - replyEmbed: replyEmbedAndBody ? replyEmbedAndBody[0] : undefined, + replyEmbed, }; } - public FindMentionsInPlainBody(body: string, members: Discord.GuildMember[]): string { - const WORD_BOUNDARY = "(^|\:|\#|```|\\s|$|,)"; - for (const member of members) { - const matcher = `${escapeStringRegexp(`${member.user.username}#${member.user.discriminator}`)}|` + - `${escapeStringRegexp(member.displayName)}`; - const regex = new RegExp( - `(${WORD_BOUNDARY})(@?(${matcher}))(?=${WORD_BOUNDARY})` - , "igmu"); - - body = body.replace(regex, `$1<@!${member.id}>`); - } - return body; - } - - public ReplaceDiscordEmoji(content: string, guild: Discord.Guild): string { - let results = DISCORD_EMOJI_REGEX.exec(content); - while (results !== null) { - const emojiName = results[1]; - const emojiNameWithColons = results[0]; - - // Check if this emoji exists in the guild - const emoji = guild.emojis.find((e) => e.name === emojiName); - if (emoji) { - // Replace :a: with <:a:123ID123> - content = content.replace(emojiNameWithColons, `<${emojiNameWithColons}${emoji.id}>`); - } - results = DISCORD_EMOJI_REGEX.exec(content); - } - return content; - } - public async HandleAttachment(event: IMatrixEvent, mxClient: MatrixClient): Promise<string|Discord.FileOptions> { if (!event.content) { event.content = {}; @@ -217,46 +171,36 @@ export class MatrixEventProcessor { return `[${name}](${url})`; } - public async GetEmbedForReply(event: IMatrixEvent): Promise<[Discord.RichEmbed, string]|undefined> { + public async GetEmbedForReply( + event: IMatrixEvent, + channel: Discord.TextChannel, + ): Promise<Discord.RichEmbed|undefined> { if (!event.content) { event.content = {}; } const relatesTo = event.content["m.relates_to"]; - let eventId = null; + let eventId = ""; if (relatesTo && relatesTo["m.in_reply_to"]) { eventId = relatesTo["m.in_reply_to"].event_id; } else { return; } - let reponseText = Util.GetReplyFromReplyBody(event.content.body || ""); - if (reponseText === "") { - reponseText = "Reply with unknown content"; - } const intent = this.bridge.getIntent(); - const embed = new Discord.RichEmbed(); // Try to get the event. try { const sourceEvent = await intent.getEvent(event.room_id, eventId); - let replyText = sourceEvent.content.body || "Reply with unknown content"; - // Check if this is also a reply. - if (sourceEvent.content && sourceEvent.content["m.relates_to"] && - sourceEvent.content["m.relates_to"]["m.in_reply_to"]) { - replyText = Util.GetReplyFromReplyBody(sourceEvent.content.body); - } - embed.setDescription(replyText); - await this.SetEmbedAuthor( - embed, - sourceEvent.sender, - ); + sourceEvent.content.body = sourceEvent.content.body || "Reply with unknown content"; + return (await this.EventToEmbed(sourceEvent, channel, false)).messageEmbed; } catch (ex) { log.warn("Failed to handle reply, showing a unknown embed:", ex); - // For some reason we failed to get the event, so using fallback. - embed.setDescription("Reply with unknown content"); - embed.setAuthor("Unknown"); } - return [embed, reponseText]; + // For some reason we failed to get the event, so using fallback. + const embed = new Discord.RichEmbed(); + embed.setDescription("Reply with unknown content"); + embed.setAuthor("Unknown"); + return embed; } private async SetEmbedAuthor(embed: Discord.RichEmbed, sender: string, profile?: IMatrixEvent | null) { diff --git a/src/matrixmessageprocessor.ts b/src/matrixmessageprocessor.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0a4c550d4d051d936568afb81603e2986385ec3 --- /dev/null +++ b/src/matrixmessageprocessor.ts @@ -0,0 +1,313 @@ +import * as Discord from "discord.js"; +import { IMatrixMessage, IMatrixEvent } from "./matrixtypes"; +import * as Parser from "node-html-parser"; +import { Util } from "./util"; +import { DiscordBot } from "./bot"; +import { Client as MatrixClient } from "matrix-js-sdk"; + +const MIN_NAME_LENGTH = 2; +const MAX_NAME_LENGTH = 32; +const MATRIX_TO_LINK = "https://matrix.to/#/"; + +export interface IMatrixMessageProcessorParams { + displayname?: string; + mxClient?: MatrixClient; + roomId?: string; + userId?: string; +} + +export class MatrixMessageProcessor { + private guild: Discord.Guild; + private listDepth: number = 0; + private listBulletPoints: string[] = ["●", "○", "■", "‣"]; + private params?: IMatrixMessageProcessorParams; + constructor(public bot: DiscordBot) { } + public async FormatMessage( + msg: IMatrixMessage, + guild: Discord.Guild, + params?: IMatrixMessageProcessorParams, + ): Promise<string> { + this.guild = guild; + this.listDepth = 0; + this.params = params; + let reply = ""; + if (msg.formatted_body) { + // parser needs everything wrapped in html elements + // so we wrap everything in <div> just to be sure stuff is wrapped + // as <div> will be un-touched anyways + const parsed = Parser.parse(`<div>${msg.formatted_body}</div>`, { + lowerCaseTagName: true, + pre: true, + // tslint:disable-next-line no-any + } as any); + reply = await this.walkNode(parsed); + reply = reply.replace(/\s*$/, ""); // trim off whitespace at end + } else { + reply = await this.escapeDiscord(msg.body); + } + + if (msg.msgtype === "m.emote") { + if (params && + params.displayname && + params.displayname.length >= MIN_NAME_LENGTH && + params.displayname.length <= MAX_NAME_LENGTH) { + reply = `_${params.displayname} ${reply}_`; + } else { + reply = `_${reply}_`; + } + } + return reply; + } + + private async escapeDiscord(msg: string): Promise<string> { + // \u200B is the zero-width space --> they still look the same but don't mention + msg = msg.replace(/@everyone/g, "@\u200Beveryone"); + msg = msg.replace(/@here/g, "@\u200Bhere"); + + if (msg.includes("@room") && this.params && this.params.mxClient && this.params.roomId && this.params.userId) { + // let's check for more complex logic if @room should be replaced + const res: IMatrixEvent = await this.params.mxClient.getStateEvent( + this.params.roomId, "m.room.power_levels"); + if ( + res && res.users + && res.users[this.params.userId] !== undefined + && res.notifications + && res.notifications.room !== undefined + && res.users[this.params.userId] >= res.notifications.room + ) { + msg = msg.replace(/@room/g, "@here"); + } + } + const escapeChars = ["\\", "*", "_", "~", "`"]; + msg = msg.split(" ").map((s) => { + if (s.match(/^https?:\/\//)) { + return s; + } + escapeChars.forEach((char) => { + s = s.replace(new RegExp("\\" + char, "g"), "\\" + char); + }); + return s; + }).join(" "); + return msg; + } + + private parsePreContent(node: Parser.HTMLElement): string { + let text = node.text; + const match = text.match(/^<code([^>]*)>/i); + if (!match) { + if (text[0] !== "\n") { + text = "\n" + text; + } + return text; + } + // remove <code> opening-tag + text = text.substr(match[0].length); + // remove </code> closing tag + text = text.replace(/<\/code>$/i, ""); + if (text[0] !== "\n") { + text = "\n" + text; + } + const language = match[1].match(/language-(\w*)/i); + if (language) { + text = language[1] + text; + } + return text; + } + + private parseUser(id: string): string { + const USER_REGEX = /^@_discord_([0-9]*)/; + const match = id.match(USER_REGEX); + if (!match || !this.guild.members.get(match[1])) { + return ""; + } + return `<@${match[1]}>`; + } + + private parseChannel(id: string): string { + const CHANNEL_REGEX = /^#_discord_[0-9]*_([0-9]*)/; + const match = id.match(CHANNEL_REGEX); + if (!match || !this.guild.channels.get(match[1])) { + return MATRIX_TO_LINK + id; + } + return `<#${match[1]}>`; + } + + private async parseLinkContent(node: Parser.HTMLElement): Promise<string> { + const attrs = node.attributes; + const content = await this.walkChildNodes(node); + if (!attrs.href || content === attrs.href) { + return content; + } + return `[${content}](${attrs.href})`; + } + + private async parsePillContent(node: Parser.HTMLElement): Promise<string> { + const attrs = node.attributes; + if (!attrs.href || !attrs.href.startsWith(MATRIX_TO_LINK)) { + return await this.parseLinkContent(node); + } + const id = attrs.href.replace(MATRIX_TO_LINK, ""); + let reply = ""; + switch (id[0]) { + case "@": + // user pill + reply = this.parseUser(id); + break; + case "#": + reply = this.parseChannel(id); + break; + } + if (!reply) { + return await this.parseLinkContent(node); + } + return reply; + } + + private async parseImageContent(node: Parser.HTMLElement): Promise<string> { + const EMOTE_NAME_REGEX = /^:?(\w+):?/; + const attrs = node.attributes; + const name = attrs.alt || attrs.title || ""; + let emoji: Discord.Emoji | null = null; + // first check for matching mxc url + if (attrs.src) { + let id = ""; + try { + const emojiDb = await this.bot.GetEmojiByMxc(attrs.src); + id = emojiDb.EmojiId; + emoji = this.guild.emojis.find((e) => e.id === id); + } catch (e) { + emoji = null; + } + } + // nexc check for matching alt text / title + if (!emoji) { + const match = name.match(EMOTE_NAME_REGEX); + let emojiName = ""; + if (match) { + emojiName = match[1]; + emoji = this.guild.emojis.find((e) => e.name === emojiName); + } + } + + if (!emoji) { + return await this.escapeDiscord(name); + } + return `<${emoji.animated ? "a" : ""}:${emoji.name}:${emoji.id}>`; + } + + private async parseBlockquoteContent(node: Parser.HTMLElement): Promise<string> { + let msg = await this.walkChildNodes(node); + + msg = msg.split("\n").map((s) => { + return "> " + s; + }).join("\n"); + msg = msg + "\n\n"; + return msg; + } + + private async parseUlContent(node: Parser.HTMLElement): Promise<string> { + this.listDepth++; + const entries = await this.arrayChildNodes(node, ["li"]); + this.listDepth--; + const bulletPoint = this.listBulletPoints[this.listDepth % this.listBulletPoints.length]; + + let msg = entries.map((s) => { + return `${" ".repeat(this.listDepth)}${bulletPoint} ${s}`; + }).join("\n"); + + if (this.listDepth === 0) { + msg = `\n${msg}\n\n`; + } + return msg; + } + + private async parseOlContent(node: Parser.HTMLElement): Promise<string> { + this.listDepth++; + const entries = await this.arrayChildNodes(node, ["li"]); + this.listDepth--; + let entry = 0; + const attrs = node.attributes; + if (attrs.start && attrs.start.match(/^[0-9]+$/)) { + entry = parseInt(attrs.start, 10) - 1; + } + + let msg = entries.map((s) => { + entry++; + return `${" ".repeat(this.listDepth)}${entry}. ${s}`; + }).join("\n"); + + if (this.listDepth === 0) { + msg = `\n${msg}\n\n`; + } + return msg; + } + + private async arrayChildNodes(node: Parser.Node, types: string[] = []): Promise<string[]> { + const replies: string[] = []; + await Util.AsyncForEach(node.childNodes, async (child) => { + if (types.length && ( + child.nodeType === Parser.NodeType.TEXT_NODE + || !types.includes((child as Parser.HTMLElement).tagName) + )) { + return; + } + replies.push(await this.walkNode(child)); + }); + return replies; + } + + private async walkChildNodes(node: Parser.Node): Promise<string> { + let reply = ""; + await Util.AsyncForEach(node.childNodes, async (child) => { + reply += await this.walkNode(child); + }); + return reply; + } + + private async walkNode(node: Parser.Node): Promise<string> { + if (node.nodeType === Parser.NodeType.TEXT_NODE) { + // ignore \n between single nodes + if ((node as Parser.TextNode).text === "\n") { + return ""; + } + return await this.escapeDiscord((node as Parser.TextNode).text); + } else if (node.nodeType === Parser.NodeType.ELEMENT_NODE) { + const nodeHtml = node as Parser.HTMLElement; + switch (nodeHtml.tagName) { + case "em": + case "i": + return `*${await this.walkChildNodes(nodeHtml)}*`; + case "strong": + case "b": + return `**${await this.walkChildNodes(nodeHtml)}**`; + case "u": + return `__${await this.walkChildNodes(nodeHtml)}__`; + case "del": + return `~~${await this.walkChildNodes(nodeHtml)}~~`; + case "code": + return `\`${nodeHtml.text}\``; + case "pre": + return `\`\`\`${this.parsePreContent(nodeHtml)}\`\`\``; + case "a": + return await this.parsePillContent(nodeHtml); + case "img": + return await this.parseImageContent(nodeHtml); + case "br": + return "\n"; + case "blockquote": + return await this.parseBlockquoteContent(nodeHtml); + case "ul": + return await this.parseUlContent(nodeHtml); + case "ol": + return await this.parseOlContent(nodeHtml); + case "mx-reply": + return ""; + case "hr": + return "\n----------\n"; + default: + return await this.walkChildNodes(nodeHtml); + } + } + return ""; + } +} diff --git a/src/matrixtypes.ts b/src/matrixtypes.ts index 4efbf8395084b61ef852d59886b6eb5e9cb5058e..9e72460f93ae884d79ca4f4e37fd196813939661 100644 --- a/src/matrixtypes.ts +++ b/src/matrixtypes.ts @@ -23,4 +23,13 @@ export interface IMatrixEvent { content?: IMatrixEventContent; unsigned?: any; // tslint:disable-line no-any origin_server_ts?: number; + users?: any; // tslint:disable-line no-any + notifications?: any; // tslint:disable-line no-any +} + +export interface IMatrixMessage { + body: string; + msgtype: string; + formatted_body?: string; + format?: string; } diff --git a/src/util.ts b/src/util.ts index 8661ba785ba9f14f60a923e97649ba00b0a638dc..a83be839c2a1c25a529a1c90c0360be4067b2083 100644 --- a/src/util.ts +++ b/src/util.ts @@ -238,17 +238,6 @@ export class Util { return {command, args}; } - public static GetReplyFromReplyBody(body: string) { - const lines = body.split("\n"); - while (lines[0].startsWith("> ") || lines[0].trim().length === 0) { - lines.splice(0, 1); - if (lines.length === 0) { - return ""; - } - } - return lines.join("\n").trim(); - } - public static async AsyncForEach(arr, callback) { for (let i = 0; i < arr.length; i++) { await callback(arr[i], i, arr); diff --git a/test/mocks/emoji.ts b/test/mocks/emoji.ts index c499ca4f03b19da04d787401691dc50e9db5e05d..b3809625d44f6cda58fbdbeecc97b99541c87b18 100644 --- a/test/mocks/emoji.ts +++ b/test/mocks/emoji.ts @@ -3,5 +3,5 @@ /* tslint:disable:no-unused-expression max-file-line-count no-any */ export class MockEmoji { - constructor(public id: string = "", public name = "") { } + constructor(public id: string = "", public name = "", public animated = false) { } } diff --git a/test/test_channelsyncroniser.ts b/test/test_channelsyncroniser.ts index 36bbc94812c80dbedf0b9864187bbb9147e33cdd..91e626365efae3dae121291be55bac7b45a93fea 100644 --- a/test/test_channelsyncroniser.ts +++ b/test/test_channelsyncroniser.ts @@ -8,7 +8,6 @@ import { MockGuild } from "./mocks/guild"; import { MockMember } from "./mocks/member"; import { MatrixEventProcessor, MatrixEventProcessorOpts } from "../src/matrixeventprocessor"; import { DiscordBridgeConfig } from "../src/config"; -import { MessageProcessor, MessageProcessorOpts } from "../src/messageprocessor"; import { MockChannel } from "./mocks/channel"; import { Bridge, MatrixRoom, RemoteRoom } from "matrix-appservice-bridge"; diff --git a/test/test_discordbot.ts b/test/test_discordbot.ts index bf387306a26df4ba7dda278e7c85ecc920b31e8d..2e7bb00dadfcd3742e5cc05f31351dcf243da14f 100644 --- a/test/test_discordbot.ts +++ b/test/test_discordbot.ts @@ -3,7 +3,6 @@ import * as Proxyquire from "proxyquire"; import * as Discord from "discord.js"; import { Log } from "../src/log"; -import { MessageProcessorMatrixResult } from "../src/messageprocessor"; import { MockGuild } from "./mocks/guild"; import { MockMember } from "./mocks/member"; import { DiscordBot } from "../src/bot"; diff --git a/test/test_messageprocessor.ts b/test/test_discordmessageprocessor.ts similarity index 80% rename from test/test_messageprocessor.ts rename to test/test_discordmessageprocessor.ts index 0ec88eb300a2e36a0cf35e4575ff1de02c594075..95e098986053bdc6ea1b413e80ebc322a456e9a9 100644 --- a/test/test_messageprocessor.ts +++ b/test/test_discordmessageprocessor.ts @@ -1,6 +1,6 @@ import * as Chai from "chai"; import * as Discord from "discord.js"; -import { MessageProcessor, MessageProcessorOpts } from "../src/messageprocessor"; +import { DiscordMessageProcessor, DiscordMessageProcessorOpts } from "../src/discordmessageprocessor"; import { DiscordBot } from "../src/bot"; import { MockGuild } from "./mocks/guild"; import { MockMember } from "./mocks/member"; @@ -20,53 +20,57 @@ const bot = { }, }; -describe("MessageProcessor", () => { +describe("DiscordMessageProcessor", () => { describe("init", () => { it("constructor", () => { - const mp = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const mp = new DiscordMessageProcessor(new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); }); }); - describe("FormatDiscordMessage", () => { + describe("FormatMessage", () => { it("processes plain text messages correctly", async () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const msg = new MockMessage() as any; msg.embeds = []; msg.content = "Hello World!"; - const result = await processor.FormatDiscordMessage(msg); + const result = await processor.FormatMessage(msg); Chai.assert(result.body, "Hello World!"); Chai.assert(result.formattedBody, "Hello World!"); }); it("processes markdown messages correctly.", async () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const msg = new MockMessage() as any; msg.embeds = []; msg.content = "Hello *World*!"; - const result = await processor.FormatDiscordMessage(msg); + const result = await processor.FormatMessage(msg); Chai.assert.equal(result.body, "Hello *World*!"); Chai.assert.equal(result.formattedBody, "Hello <em>World</em>!"); }); it("processes non-discord markdown correctly.", async () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const msg = new MockMessage() as any; msg.embeds = []; msg.content = "> inb4 tests"; - let result = await processor.FormatDiscordMessage(msg); + let result = await processor.FormatMessage(msg); Chai.assert.equal(result.body, "> inb4 tests"); Chai.assert.equal(result.formattedBody, "> inb4 tests"); msg.embeds = []; msg.content = "[test](http://example.com)"; - result = await processor.FormatDiscordMessage(msg); + result = await processor.FormatMessage(msg); Chai.assert.equal(result.body, "[test](http://example.com)"); Chai.assert.equal(result.formattedBody, "[test](<a href=\"http://example.com\">http://example.com</a>)"); }); it("processes discord-specific markdown correctly.", async () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const msg = new MockMessage() as any; msg.embeds = []; msg.content = "_ italic _"; - const result = await processor.FormatDiscordMessage(msg); + const result = await processor.FormatMessage(msg); Chai.assert.equal(result.body, "_ italic _"); Chai.assert.equal(result.formattedBody, "<em> italic </em>"); }); @@ -101,7 +105,8 @@ describe("MessageProcessor", () => { }); describe("FormatEmbeds", () => { it("should format embeds correctly", async () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const msg = new MockMessage() as any; msg.embeds = [ { @@ -125,13 +130,14 @@ describe("MessageProcessor", () => { }, ]; msg.content = "message"; - const result = await processor.FormatDiscordMessage(msg); + const result = await processor.FormatMessage(msg); Chai.assert.equal(result.body, "message\n\n----\n##### [Title](http://example.com)\nDescription"); Chai.assert.equal(result.formattedBody, "message<hr><h5><a href=\"http://example.com\">Title</a>" + "</h5>Description"); }); it("should ignore same-url embeds", async () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const msg = new MockMessage() as any; msg.embeds = [ { @@ -155,7 +161,7 @@ describe("MessageProcessor", () => { }, ]; msg.content = "message http://example.com"; - const result = await processor.FormatDiscordMessage(msg); + const result = await processor.FormatMessage(msg); Chai.assert.equal(result.body, "message http://example.com"); Chai.assert.equal(result.formattedBody, "message <a href=\"http://example.com\">" + "http://example.com</a>"); @@ -163,7 +169,8 @@ describe("MessageProcessor", () => { }); describe("FormatEdit", () => { it("should format basic edits appropriately", async () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const oldMsg = new MockMessage() as any; const newMsg = new MockMessage() as any; oldMsg.embeds = []; @@ -179,7 +186,8 @@ describe("MessageProcessor", () => { }); it("should format markdown heavy edits apropriately", async () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const oldMsg = new MockMessage() as any; const newMsg = new MockMessage() as any; oldMsg.embeds = []; @@ -199,7 +207,8 @@ describe("MessageProcessor", () => { describe("InsertUser / HTML", () => { it("processes members missing from the guild correctly", () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const guild: any = new MockGuild("123", []); const channel = new Discord.TextChannel(guild, {}); const msg = new MockMessage(channel) as any; @@ -212,7 +221,8 @@ describe("MessageProcessor", () => { "<a href=\"https://matrix.to/#/@_discord_12345:localhost\">@_discord_12345:localhost</a>"); }); it("processes members with usernames correctly", () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const guild: any = new MockGuild("123", []); guild._mockAddMember(new MockMember("12345", "TestUsername")); const channel = new Discord.TextChannel(guild, {}); @@ -226,7 +236,8 @@ describe("MessageProcessor", () => { "<a href=\"https://matrix.to/#/@_discord_12345:localhost\">TestUsername</a>"); }); it("processes members with nickname correctly", () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const guild: any = new MockGuild("123", []); guild._mockAddMember(new MockMember("12345", "TestUsername", null, "TestNickname")); const channel = new Discord.TextChannel(guild, {}); @@ -242,7 +253,8 @@ describe("MessageProcessor", () => { }); describe("InsertChannel / HTML", () => { it("processes unknown channel correctly", () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const guild: any = new MockGuild("123", []); const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"}); const msg = new MockMessage(channel) as any; @@ -255,7 +267,8 @@ describe("MessageProcessor", () => { "<a href=\"https://matrix.to/#/#_discord_123_123456789:localhost\">#123456789</a>"); }); it("processes channels correctly", () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const guild: any = new MockGuild("123", []); const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"}); guild.channels.set("456", channel); @@ -271,7 +284,8 @@ describe("MessageProcessor", () => { }); describe("InsertRole / HTML", () => { it("ignores unknown roles", () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const guild: any = new MockGuild("123", []); const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"}); guild.channels.set("456", channel); @@ -286,7 +300,8 @@ describe("MessageProcessor", () => { Chai.assert.equal(reply, "<@&1234>"); }); it("parses known roles", () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const guild: any = new MockGuild("123", []); const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"}); guild.channels.set("456", channel); @@ -304,7 +319,8 @@ describe("MessageProcessor", () => { }); describe("InsertEmoji", () => { it("inserts static emojis to their post-parse flag", () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const content = { animated: false, id: "1234", @@ -314,7 +330,8 @@ describe("MessageProcessor", () => { Chai.assert.equal(reply, "\x01blah\x010\x011234\x01"); }); it("inserts animated emojis to their post-parse flag", () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const content = { animated: true, id: "1234", @@ -326,7 +343,8 @@ describe("MessageProcessor", () => { }); describe("InsertMxcImages / HTML", () => { it("processes unknown emoji correctly", async () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const guild: any = new MockGuild("123", []); const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"}); const msg = new MockMessage(channel) as any; @@ -338,7 +356,8 @@ describe("MessageProcessor", () => { Chai.assert.equal(reply, "Hello <:hello:123456789>"); }); it("processes emoji correctly", async () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const guild: any = new MockGuild("123", []); const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"}); guild.channels.set("456", channel); @@ -353,7 +372,8 @@ describe("MessageProcessor", () => { }); describe("InsertEmbeds", () => { it("processes titleless embeds properly", () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const msg = new MockMessage() as any; msg.embeds = [ new Discord.MessageEmbed(msg, { @@ -365,7 +385,8 @@ describe("MessageProcessor", () => { Chai.assert.equal(content, "\n\n----\nTestDescription"); }); it("processes urlless embeds properly", () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const msg = new MockMessage() as any; msg.embeds = [ new Discord.MessageEmbed(msg, { @@ -378,7 +399,8 @@ describe("MessageProcessor", () => { Chai.assert.equal(content, "\n\n----\n##### TestTitle\nTestDescription"); }); it("processes linked embeds properly", () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const msg = new MockMessage() as any; msg.embeds = [ new Discord.MessageEmbed(msg, { @@ -392,7 +414,8 @@ describe("MessageProcessor", () => { Chai.assert.equal(content, "\n\n----\n##### [TestTitle](testurl)\nTestDescription"); }); it("rejects titleless and descriptionless embeds", () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const msg = new MockMessage() as any; msg.embeds = [ new Discord.MessageEmbed(msg, { @@ -404,7 +427,8 @@ describe("MessageProcessor", () => { Chai.assert.equal(content, "Some content..."); }); it("processes multiple embeds properly", () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const msg = new MockMessage() as any; msg.embeds = [ new Discord.MessageEmbed(msg, { @@ -426,7 +450,8 @@ describe("MessageProcessor", () => { ); }); it("inserts embeds properly", () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const msg = new MockMessage() as any; msg.embeds = [ new Discord.MessageEmbed(msg, { @@ -449,21 +474,23 @@ TestDescription`, }); describe("Message Type", () => { it("sets non-bot messages as m.text", async () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const msg = new MockMessage() as any; msg.embeds = []; msg.content = "no bot"; msg.author.bot = false; - const result = await processor.FormatDiscordMessage(msg); + const result = await processor.FormatMessage(msg); Chai.assert.equal(result.msgtype, "m.text"); }); it("sets bot messages as m.notice", async () => { - const processor = new MessageProcessor(new MessageProcessorOpts("localhost"), bot as DiscordBot); + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); const msg = new MockMessage() as any; msg.embeds = []; msg.content = "a bot"; msg.author.bot = true; - const result = await processor.FormatDiscordMessage(msg); + const result = await processor.FormatMessage(msg); Chai.assert.equal(result.msgtype, "m.notice"); }); }); diff --git a/test/test_matrixeventprocessor.ts b/test/test_matrixeventprocessor.ts index 9073fc8b028f0730f2936d0a7d10564b8c41a47e..3d61f0ee22444532ac03cb2aaa214b07d313a770 100644 --- a/test/test_matrixeventprocessor.ts +++ b/test/test_matrixeventprocessor.ts @@ -10,7 +10,6 @@ import { MockMember } from "./mocks/member"; import { MockEmoji } from "./mocks/emoji"; import { MatrixEventProcessor, MatrixEventProcessorOpts } from "../src/matrixeventprocessor"; import { DiscordBridgeConfig } from "../src/config"; -import { MessageProcessor, MessageProcessorOpts } from "../src/messageprocessor"; import { MockChannel } from "./mocks/channel"; import { IMatrixEvent } from "../src/matrixtypes"; @@ -32,16 +31,35 @@ const bot = { }; const mxClient = { + getStateEvent: async (roomId, stateType, stateKey) => { + if (stateType === "m.room.member") { + switch (stateKey) { + case "@test:localhost": + return { + avatar_url: "mxc://localhost/avatarurl", + displayname: "Test User", + }; + case "@test_short:localhost": + return { + avatar_url: "mxc://localhost/avatarurl", + displayname: "t", + }; + case "@test_long:localhost": + return { + avatar_url: "mxc://localhost/avatarurl", + displayname: "this is a very very long displayname that should be capped", + }; + } + return null; + } + return { }; + }, mxcUrlToHttp: (url) => { return url.replace("mxc://", "https://"); }, }; -function createMatrixEventProcessor( - disableMentions: boolean = false, - disableEveryone = false, - disableHere = false, -): MatrixEventProcessor { +function createMatrixEventProcessor(): MatrixEventProcessor { const bridge = { getBot: () => { return { @@ -78,6 +96,9 @@ function createMatrixEventProcessor( "body": `> <@doggo:localhost> This is the original body This is the first reply`, + "formatted_body": ` +<mx-reply><blockquote><a>In Reply to</a> <a>@doggo:localhost</a> +<br>This is the original body</blockquote></mx-reply>This is the first reply`, "m.relates_to": { "m.in_reply_to": { event_id: "$goodEvent:localhost", @@ -109,9 +130,6 @@ function createMatrixEventProcessor( }, }; const config = new DiscordBridgeConfig(); - config.bridge.disableDiscordMentions = disableMentions; - config.bridge.disableEveryoneMention = disableEveryone; - config.bridge.disableHereMention = disableHere; const Util = Object.assign(require("../src/util").Util, { DownloadFile: (name: string) => { @@ -265,10 +283,6 @@ describe("MatrixEventProcessor", () => { body: "testcontent", }, sender: "@test:localhost", - } as IMatrixEvent, - { - avatar_url: "mxc://localhost/avatarurl", - displayname: "Test User", } as IMatrixEvent, mockChannel as any); const author = embeds.messageEmbed.author; Chai.assert.equal(author!.name, "Test User"); @@ -283,12 +297,10 @@ describe("MatrixEventProcessor", () => { body: "testcontent", }, sender: "@test:localhost", - } as IMatrixEvent, { - displayname: "Test User", } as IMatrixEvent, mockChannel as any); const author = embeds.messageEmbed.author; Chai.assert.equal(author!.name, "Test User"); - Chai.assert.isUndefined(author!.icon_url); + Chai.assert.equal(author!.icon_url, "https://localhost/avatarurl"); Chai.assert.equal(author!.url, "https://matrix.to/#/@test:localhost"); }); @@ -298,12 +310,12 @@ describe("MatrixEventProcessor", () => { content: { body: "testcontent", }, - sender: "@test:localhost", - } as IMatrixEvent, null, mockChannel as any); + sender: "@test_nonexistant:localhost", + } as IMatrixEvent, mockChannel as any); const author = embeds.messageEmbed.author; - Chai.assert.equal(author!.name, "@test:localhost"); + Chai.assert.equal(author!.name, "@test_nonexistant:localhost"); Chai.assert.isUndefined(author!.icon_url); - Chai.assert.equal(author!.url, "https://matrix.to/#/@test:localhost"); + Chai.assert.equal(author!.url, "https://matrix.to/#/@test_nonexistant:localhost"); }); it("Should use the userid when the displayname is too short", async () => { @@ -312,12 +324,10 @@ describe("MatrixEventProcessor", () => { content: { body: "testcontent", }, - sender: "@test:localhost", - } as IMatrixEvent, { - displayname: "t", + sender: "@test_short:localhost", } as IMatrixEvent, mockChannel as any); const author = embeds.messageEmbed.author; - Chai.assert.equal(author!.name, "@test:localhost"); + Chai.assert.equal(author!.name, "@test_short:localhost"); }); it("Should use the userid when displayname is too long", async () => { @@ -326,12 +336,10 @@ describe("MatrixEventProcessor", () => { content: { body: "testcontent", }, - sender: "@test:localhost", - } as IMatrixEvent, { - displayname: "this is a very very long displayname that should be capped", + sender: "@test_long:localhost", } as IMatrixEvent, mockChannel as any); const author = embeds.messageEmbed.author; - Chai.assert.equal(author!.name, "@test:localhost"); + Chai.assert.equal(author!.name, "@test_long:localhost"); }); it("Should cap the sender name if it is too long", async () => { @@ -341,7 +349,7 @@ describe("MatrixEventProcessor", () => { body: "testcontent", }, sender: "@testwithalottosayaboutitselfthatwillgoonandonandonandon:localhost", - } as IMatrixEvent, null, mockChannel as any); + } as IMatrixEvent, mockChannel as any); const author = embeds.messageEmbed.author; Chai.assert.equal(author!.name, "@testwithalottosayaboutitselftha"); }); @@ -353,112 +361,35 @@ describe("MatrixEventProcessor", () => { body: "testcontent", }, sender: "@test:localhost", - } as IMatrixEvent, { - avatar_url: "mxc://localhost/test", } as IMatrixEvent, mockChannel as any); const author = embeds.messageEmbed.author; - Chai.assert.equal(author!.name, "@test:localhost"); - Chai.assert.equal(author!.icon_url, "https://localhost/test"); + Chai.assert.equal(author!.name, "Test User"); + Chai.assert.equal(author!.icon_url, "https://localhost/avatarurl"); Chai.assert.equal(author!.url, "https://matrix.to/#/@test:localhost"); }); - it("Should enable mentions if configured.", async () => { + it("Should remove everyone mentions.", async () => { const processor = createMatrixEventProcessor(); - const embeds = await processor.EventToEmbed({ - content: { - body: "@testuser2 Hello!", - }, - sender: "@test:localhost", - } as IMatrixEvent, { - avatar_url: "test", - } as IMatrixEvent, mockChannel as any); - Chai.assert.equal(embeds.messageEmbed.description, "<@!12345> Hello!"); - }); - - it("Should disable mentions if configured.", async () => { - const processor = createMatrixEventProcessor(true); - const embeds = await processor.EventToEmbed({ - content: { - body: "@testuser2 Hello!", - }, - sender: "@test:localhost", - } as IMatrixEvent, { - avatar_url: "test", - } as IMatrixEvent, mockChannel as any); - Chai.assert.equal(embeds.messageEmbed.description, "@testuser2 Hello!"); - }); - - it("Should remove everyone mentions if configured.", async () => { - const processor = createMatrixEventProcessor(false, true); const embeds = await processor.EventToEmbed({ content: { body: "@everyone Hello!", }, sender: "@test:localhost", - } as IMatrixEvent, { - avatar_url: "test", } as IMatrixEvent, mockChannel as any); - Chai.assert.equal(embeds.messageEmbed.description, "@ everyone Hello!"); + Chai.assert.equal(embeds.messageEmbed.description, "@\u200Beveryone Hello!"); }); - it("Should remove here mentions if configured.", async () => { - const processor = createMatrixEventProcessor(false, false, true); + it("Should remove here mentions.", async () => { + const processor = createMatrixEventProcessor(); const embeds = await processor.EventToEmbed({ content: { body: "@here Hello!", }, sender: "@test:localhost", - } as IMatrixEvent, { - avatar_url: "test", } as IMatrixEvent, mockChannel as any); - Chai.assert.equal(embeds.messageEmbed.description, "@ here Hello!"); + Chai.assert.equal(embeds.messageEmbed.description, "@\u200Bhere Hello!"); }); - it("Should process custom discord emojis.", async () => { - const processor = createMatrixEventProcessor(false, false, true); - const mockEmoji = new MockEmoji("123", "supercake"); - const mockCollectionEmojis = new MockCollection<string, MockEmoji>(); - mockCollectionEmojis.set("123", mockEmoji); - - const mockChannelEmojis = new MockChannel("test", { - emojis: mockCollectionEmojis, - }); - const embeds = await processor.EventToEmbed({ - content: { - body: "I like :supercake:", - }, - sender: "@test:localhost", - } as IMatrixEvent, { - avatar_url: "test", - } as IMatrixEvent, mockChannelEmojis as any); - Chai.assert.equal( - embeds.messageEmbed.description, - "I like <:supercake:123>", - ); - }); - - it("Should not process invalid custom discord emojis.", async () => { - const processor = createMatrixEventProcessor(false, false, true); - const mockEmoji = new MockEmoji("123", "supercake"); - const mockCollectionEmojis = new MockCollection<string, MockEmoji>(); - mockCollectionEmojis.set("123", mockEmoji); - - const mockChannelEmojis = new MockChannel("test", { - emojis: mockCollectionEmojis, - }); - const embeds = await processor.EventToEmbed({ - content: { - body: "I like :lamecake:", - }, - sender: "@test:localhost", - } as IMatrixEvent, { - avatar_url: "test", - } as IMatrixEvent, mockChannelEmojis as any); - Chai.assert.equal( - embeds.messageEmbed.description, - "I like :lamecake:", - ); - }); it("Should replace /me with * displayname, and italicize message", async () => { const processor = createMatrixEventProcessor(); const embeds = await processor.EventToEmbed({ @@ -467,12 +398,10 @@ describe("MatrixEventProcessor", () => { msgtype: "m.emote", }, sender: "@test:localhost", - } as IMatrixEvent, { - displayname: "displayname", } as IMatrixEvent, mockChannel as any); Chai.assert.equal( embeds.messageEmbed.description, - "*displayname likes puppies*", + "_Test User likes puppies_", ); }); it("Should handle stickers.", async () => { @@ -484,118 +413,10 @@ describe("MatrixEventProcessor", () => { }, sender: "@test:localhost", type: "m.sticker", - } as IMatrixEvent, { - avatar_url: "test", } as IMatrixEvent, mockChannel as any); Chai.assert.equal(embeds.messageEmbed.description, ""); }); }); - describe("FindMentionsInPlainBody", () => { - it("processes mentioned username correctly", async () => { - const processor = createMatrixEventProcessor(); - const guild: any = new MockGuild("123", []); - const members: Discord.GuildMember[] = [new Discord.GuildMember(guild, { - user: { - discriminator: "54321", - id: "12345", - username: "TestUsername", - }, - })]; - Chai.assert.equal( - processor.FindMentionsInPlainBody("Hello TestUsername", members), - "Hello <@!12345>", - ); - Chai.assert.equal( - processor.FindMentionsInPlainBody("Hello TestUsername#54321", members), - "Hello <@!12345>", - ); - Chai.assert.equal( - processor.FindMentionsInPlainBody("I really love going to https://TestUsername.com", members), - "I really love going to https://TestUsername.com", - ); - Chai.assert.equal( - processor.FindMentionsInPlainBody("I really love going to www.TestUsername.com", members), - "I really love going to www.TestUsername.com", - ); - }); - it("processes mentioned nickname correctly", async () => { - const processor = createMatrixEventProcessor(); - const guild: any = new MockGuild("123", []); - const members: Discord.GuildMember[] = [new Discord.GuildMember(guild, { - nick: "Test", - user: { - id: "54321", - username: "Test", - }, - }), new Discord.GuildMember(guild, { - nick: "TestNickname", - user: { - id: "12345", - username: "TestUsername", - }, - }), new Discord.GuildMember(guild, { - nick: "𝖘𝖔𝖒𝖊𝖋𝖆𝖓𝖈𝖞𝖓𝖎𝖈𝖐𝖓𝖆𝖒𝖊", - user: { - id: "66666", - username: "SomeFancyNickname", - }, - })]; - Chai.assert.equal(processor.FindMentionsInPlainBody("Hello TestNickname", members), "Hello <@!12345>"); - Chai.assert.equal(processor.FindMentionsInPlainBody("TestNickname: Hello", members), "<@!12345>: Hello"); - Chai.assert.equal(processor.FindMentionsInPlainBody("TestNickname, Hello", members), "<@!12345>, Hello"); - Chai.assert.equal(processor.FindMentionsInPlainBody("TestNickname Hello", members), "<@!12345> Hello"); - Chai.assert.equal(processor.FindMentionsInPlainBody("testNicKName Hello", members), "<@!12345> Hello"); - Chai.assert.equal( - processor.FindMentionsInPlainBody("𝖘𝖔𝖒𝖊𝖋𝖆𝖓𝖈𝖞𝖓𝖎𝖈𝖐𝖓𝖆𝖒𝖊 Hello", members), - "<@!66666> Hello", - ); - Chai.assert.equal( - processor.FindMentionsInPlainBody("I wish TestNickname was here", members), - "I wish <@!12345> was here", - ); - Chai.assert.equal( - processor.FindMentionsInPlainBody("I wish TestNickname was here, TestNickname is cool", members), - "I wish <@!12345> was here, <@!12345> is cool", - ); - Chai.assert.equal( - processor.FindMentionsInPlainBody("TestNickname was here with Test", members), - "<@!12345> was here with <@!54321>", - ); - Chai.assert.equal( - processor.FindMentionsInPlainBody("Fixing this issue provided by @Test", members), - "Fixing this issue provided by <@!54321>", - ); - Chai.assert.equal( - processor.FindMentionsInPlainBody("I really love going to https://Test.com", members), - "I really love going to https://Test.com", - ); - Chai.assert.equal( - processor.FindMentionsInPlainBody("I really love going to www.Test.com", members), - "I really love going to www.Test.com", - ); - }); - it("processes non-mentions correctly", async () => { - const processor = createMatrixEventProcessor(); - const guild: any = new MockGuild("123", []); - const members: Discord.GuildMember[] = [new Discord.GuildMember(guild, { - nick: "that", - user: { - id: "12345", - username: "TestUsername", - }, - }), - new Discord.GuildMember(guild, { - nick: "testingstring", - user: { - id: "12345", - username: "that", - }, - })]; - const msg = "Welcome thatman"; - const content = processor.FindMentionsInPlainBody(msg, members); - Chai.assert.equal(content, "Welcome thatman"); - }); - }); describe("HandleAttachment", () => { const SMALL_FILE = 200; it("message without an attachment", async () => { @@ -700,7 +521,7 @@ describe("MatrixEventProcessor", () => { }, sender: "@test:localhost", type: "m.room.message", - } as IMatrixEvent); + } as IMatrixEvent, mockChannel as any); expect(result).to.be.undefined; }); it("should handle replies without a fallback", async () => { @@ -716,12 +537,11 @@ describe("MatrixEventProcessor", () => { }, sender: "@test:localhost", type: "m.room.message", - } as IMatrixEvent); - expect(result![0].description).to.be.equal("Hello!"); - expect(result![0].author!.name).to.be.equal("Doggo!"); - expect(result![0].author!.icon_url).to.be.equal("https://fakeurl.com"); - expect(result![0].author!.url).to.be.equal("https://matrix.to/#/@doggo:localhost"); - expect(result![1]).to.be.equal("Test"); + } as IMatrixEvent, mockChannel as any); + expect(result!.description).to.be.equal("Hello!"); + expect(result!.author!.name).to.be.equal("Doggo!"); + expect(result!.author!.icon_url).to.be.equal("https://fakeurl.com"); + expect(result!.author!.url).to.be.equal("https://matrix.to/#/@doggo:localhost"); }); it("should handle replies with a missing event", async () => { const processor = createMatrixEventProcessor(); @@ -738,12 +558,11 @@ This is where the reply goes`, }, sender: "@test:localhost", type: "m.room.message", - } as IMatrixEvent); - expect(result![0].description).to.be.equal("Reply with unknown content"); - expect(result![0].author!.name).to.be.equal("Unknown"); - expect(result![0].author!.icon_url).to.be.undefined; - expect(result![0].author!.url).to.be.undefined; - expect(result![1]).to.be.equal("This is where the reply goes"); + } as IMatrixEvent, mockChannel as any); + expect(result!.description).to.be.equal("Reply with unknown content"); + expect(result!.author!.name).to.be.equal("Unknown"); + expect(result!.author!.icon_url).to.be.undefined; + expect(result!.author!.url).to.be.undefined; }); it("should handle replies with a valid reply event", async () => { const processor = createMatrixEventProcessor(); @@ -760,12 +579,11 @@ This is where the reply goes`, }, sender: "@test:localhost", type: "m.room.message", - } as IMatrixEvent); - expect(result![0].description).to.be.equal("Hello!"); - expect(result![0].author!.name).to.be.equal("Doggo!"); - expect(result![0].author!.icon_url).to.be.equal("https://fakeurl.com"); - expect(result![0].author!.url).to.be.equal("https://matrix.to/#/@doggo:localhost"); - expect(result![1]).to.be.equal("This is where the reply goes"); + } as IMatrixEvent, mockChannel as any); + expect(result!.description).to.be.equal("Hello!"); + expect(result!.author!.name).to.be.equal("Doggo!"); + expect(result!.author!.icon_url).to.be.equal("https://fakeurl.com"); + expect(result!.author!.url).to.be.equal("https://matrix.to/#/@doggo:localhost"); }); it("should handle replies on top of replies", async () => { const processor = createMatrixEventProcessor(); @@ -782,12 +600,11 @@ This is the second reply`, }, sender: "@test:localhost", type: "m.room.message", - } as IMatrixEvent); - expect(result![0].description).to.be.equal("This is the first reply"); - expect(result![0].author!.name).to.be.equal("Doggo!"); - expect(result![0].author!.icon_url).to.be.equal("https://fakeurl.com"); - expect(result![0].author!.url).to.be.equal("https://matrix.to/#/@doggo:localhost"); - expect(result![1]).to.be.equal("This is the second reply"); + } as IMatrixEvent, mockChannel as any); + expect(result!.description).to.be.equal("This is the first reply"); + expect(result!.author!.name).to.be.equal("Doggo!"); + expect(result!.author!.icon_url).to.be.equal("https://fakeurl.com"); + expect(result!.author!.url).to.be.equal("https://matrix.to/#/@doggo:localhost"); }); it("should handle replies with non text events", async () => { const processor = createMatrixEventProcessor(); @@ -804,12 +621,11 @@ This is the reply`, }, sender: "@test:localhost", type: "m.room.message", - } as IMatrixEvent); - expect(result![0].description).to.be.equal("Reply with unknown content"); - expect(result![0].author!.name).to.be.equal("Doggo!"); - expect(result![0].author!.icon_url).to.be.equal("https://fakeurl.com"); - expect(result![0].author!.url).to.be.equal("https://matrix.to/#/@doggo:localhost"); - expect(result![1]).to.be.equal("This is the reply"); + } as IMatrixEvent, mockChannel as any); + expect(result!.description).to.be.equal("Reply with unknown content"); + expect(result!.author!.name).to.be.equal("Doggo!"); + expect(result!.author!.icon_url).to.be.equal("https://fakeurl.com"); + expect(result!.author!.url).to.be.equal("https://matrix.to/#/@doggo:localhost"); }); }); }); diff --git a/test/test_matrixmessageprocessor.ts b/test/test_matrixmessageprocessor.ts new file mode 100644 index 0000000000000000000000000000000000000000..611496fd6a9fe833e0aefb17354fff207b4be1df --- /dev/null +++ b/test/test_matrixmessageprocessor.ts @@ -0,0 +1,570 @@ +import * as Chai from "chai"; +import * as Discord from "discord.js"; +import { MockGuild } from "./mocks/guild"; +import { MockMember } from "./mocks/member"; +import { MockChannel } from "./mocks/channel"; +import { MockEmoji } from "./mocks/emoji"; +import { DiscordBot } from "../src/bot"; +import { DbEmoji } from "../src/db/dbdataemoji"; +import { MatrixMessageProcessor } from "../src/matrixmessageprocessor"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +const expect = Chai.expect; + +const mxClient = { + getStateEvent: async (roomId, stateType, _) => { + if (stateType === "m.room.power_levels") { + return { + notifications: { + room: 50, + }, + users: { + "@nopower:localhost": 0, + "@power:localhost": 100, + }, + }; + } + return null; + }, +}; + +const bot = { + GetEmojiByMxc: async (mxc: string): Promise<DbEmoji> => { + if (mxc === "mxc://real_emote:localhost") { + const emoji = new DbEmoji(); + emoji.Name = "real_emote"; + emoji.EmojiId = "123456"; + emoji.Animated = false; + emoji.MxcUrl = mxc; + return emoji; + } + throw new Error("Couldn't fetch from store"); + }, +} as DiscordBot; + +function getPlainMessage(msg: string, msgtype: string = "m.text") { + return { + body: msg, + msgtype, + }; +} + +function getHtmlMessage(msg: string, msgtype: string = "m.text") { + return { + body: msg, + formatted_body: msg, + msgtype, + }; +} + +describe("MatrixMessageProcessor", () => { + describe("FormatMessage / body / simple", () => { + it("leaves blank stuff untouched", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("hello world!"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("hello world!"); + }); + it("escapes simple stuff", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("hello *world* how __are__ you?"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("hello \\*world\\* how \\_\\_are\\_\\_ you?"); + }); + it("escapes more complex stuff", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("wow \\*this\\* is cool"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("wow \\\\\\*this\\\\\\* is cool"); + }); + }); + describe("FormatMessage / formatted_body / simple", () => { + it("leaves blank stuff untouched", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("hello world!"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("hello world!"); + }); + it("un-escapes simple stuff", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("foxes & foxes"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("foxes & foxes"); + }); + it("converts italic formatting", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("this text is <em>italic</em> and so is <i>this one</i>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("this text is *italic* and so is *this one*"); + }); + it("converts bold formatting", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("wow some <b>bold</b> and <strong>more</strong> boldness!"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("wow some **bold** and **more** boldness!"); + }); + it("converts underline formatting", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("to be <u>underlined</u> or not to be?"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("to be __underlined__ or not to be?"); + }); + it("converts strike formatting", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("does <del>this text</del> exist?"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("does ~~this text~~ exist?"); + }); + it("converts code", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("WOW this is <code>some awesome</code> code"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("WOW this is `some awesome` code"); + }); + it("converts multiline-code", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<p>here</p><pre><code>is\ncode\n</code></pre><p>yay</p>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("here```\nis\ncode\n```yay"); + }); + it("converts multiline language code", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<p>here</p> +<pre><code class="language-js">is +code +</code></pre> +<p>yay</p>`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("here```js\nis\ncode\n```yay"); + }); + it("handles linebreaks", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("line<br>break"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("line\nbreak"); + }); + it("handles <hr>", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("test<hr>foxes"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("test\n----------\nfoxes"); + }); + }); + describe("FormatMessage / formatted_body / complex", () => { + it("html unescapes stuff inside of code", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<code>is <em>italic</em>?</code>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("`is <em>italic</em>?`"); + }); + it("html unescapes inside of pre", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<pre><code>wow &</code></pre>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("```\nwow &```"); + }); + it("doesn't parse inside of code", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<code>*yay*</code>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("`*yay*`"); + }); + it("doesn't parse inside of pre", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<pre><code>*yay*</code></pre>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("```\n*yay*```"); + }); + it("parses new lines", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<em>test</em><br><strong>ing</strong>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("*test*\n**ing**"); + }); + it("drops mx-reply", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<mx-reply><blockquote>message</blockquote></mx-reply>test reply"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("test reply"); + }); + it("parses links", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<a href=\"http://example.com\">link</a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("[link](http://example.com)"); + }); + it("parses links with same content", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<a href=\"http://example.com\">http://example.com</a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("http://example.com"); + }); + it("doesn't discord-escape links", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<a href=\"http://example.com/_blah_/\">link</a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("[link](http://example.com/_blah_/)"); + }); + it("doesn't discord-escape links with same content", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<a href=\"http://example.com/_blah_/\">http://example.com/_blah_/</a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("http://example.com/_blah_/"); + }); + }); + describe("FormatMessage / formatted_body / discord", () => { + it("Parses user pills", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const member = new MockMember("12345", "TestUsername", guild); + guild.members.set("12345", member); + const msg = getHtmlMessage("<a href=\"https://matrix.to/#/@_discord_12345:localhost\">TestUsername</a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("<@12345>"); + }); + it("Ignores invalid user pills", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const member = new MockMember("12345", "TestUsername", guild); + guild.members.set("12345", member); + const msg = getHtmlMessage("<a href=\"https://matrix.to/#/@_discord_789:localhost\">TestUsername</a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("[TestUsername](https://matrix.to/#/@_discord_789:localhost)"); + }); + it("Parses channel pills", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const channel = new MockChannel("12345", guild, "text", "SomeChannel"); + guild.channels.set("12345", channel as any); + const msg = getHtmlMessage("<a href=\"https://matrix.to/#/#_discord_1234_12345:" + + "localhost\">#SomeChannel</a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("<#12345>"); + }); + it("Handles invalid channel pills", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const channel = new MockChannel("12345", guild, "text", "SomeChannel"); + guild.channels.set("12345", channel as any); + const msg = getHtmlMessage("<a href=\"https://matrix.to/#/#_discord_1234_789:localhost\">#SomeChannel</a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("https://matrix.to/#/#_discord_1234_789:localhost"); + }); + it("Handles external channel pills", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<a href=\"https://matrix.to/#/#matrix:matrix.org\">#SomeChannel</a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("https://matrix.to/#/#matrix:matrix.org"); + }); + it("Ignores links without href", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<a><em>yay?</em></a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("*yay?*"); + }); + it("Ignores links with non-matrix href", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<a href=\"http://example.com\"><em>yay?</em></a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("[*yay?*](http://example.com)"); + }); + }); + describe("FormatMessage / formatted_body / emoji", () => { + it("Inserts emoji by name", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const emoji = new MockEmoji("123456", "test_emoji"); + guild.emojis.set("123456", emoji); + const msg = getHtmlMessage("<img alt=\"test_emoji\">"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("<:test_emoji:123456>"); + }); + it("Inserts emojis by mxc url", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const emoji = new MockEmoji("123456", "test_emoji"); + guild.emojis.set("123456", emoji); + const msg = getHtmlMessage("<img src=\"mxc://real_emote:localhost\">"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("<:test_emoji:123456>"); + }); + it("ignores unknown mxc urls", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const emoji = new MockEmoji("123456", "test_emoji"); + guild.emojis.set("123456", emoji); + const msg = getHtmlMessage("<img alt=\"yay\" src=\"mxc://unreal_emote:localhost\">"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("yay"); + }); + it("ignores with no alt / title, too", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const emoji = new MockEmoji("123456", "test_emoji"); + guild.emojis.set("123456", emoji); + const msg = getHtmlMessage("<img>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal(""); + }); + }); + describe("FormatMessage / formatted_body / matrix", () => { + it("escapes @everyone", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("hey @everyone"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("hey @\u200Beveryone"); + }); + it("escapes @here", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("hey @here"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("hey @\u200Bhere"); + }); + it("converts @room to @here, if sufficient power", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("hey @room"); + const params = { + mxClient, + roomId: "!123456:localhost", + userId: "@power:localhost", + }; + const result = await mp.FormatMessage(msg, guild as any, params as any); + expect(result).is.equal("hey @here"); + }); + it("ignores @room to @here conversion, if insufficient power", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("hey @room"); + const params = { + mxClient, + roomId: "!123456:localhost", + userId: "@nopower:localhost", + }; + const result = await mp.FormatMessage(msg, guild as any, params as any); + expect(result).is.equal("hey @room"); + }); + it("handles /me for normal names", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("floofs", "m.emote"); + const params = { + displayname: "fox", + }; + const result = await mp.FormatMessage(msg, guild as any, params as any); + expect(result).is.equal("_fox floofs_"); + }); + it("handles /me for short names", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("floofs", "m.emote"); + const params = { + displayname: "f", + }; + const result = await mp.FormatMessage(msg, guild as any, params as any); + expect(result).is.equal("_floofs_"); + }); + it("handles /me for long names", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("floofs", "m.emote"); + const params = { + displayname: "foxfoxfoxfoxfoxfoxfoxfoxfoxfoxfoxfox", + }; + const result = await mp.FormatMessage(msg, guild as any, params as any); + expect(result).is.equal("_floofs_"); + }); + }); + describe("FormatMessage / formatted_body / blockquotes", () => { + it("parses single blockquotes", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<blockquote>hey</blockquote>there"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("> hey\n\nthere"); + }); + it("parses double blockquotes", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<blockquote><blockquote>hey</blockquote>you</blockquote>there"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("> > hey\n> \n> you\n\nthere"); + }); + it("parses blockquotes with <p>", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<blockquote>\n<p>spoky</p>\n</blockquote>\n<p>test</p>\n"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("> spoky\n\ntest"); + }); + it("parses double blockquotes with <p>", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<blockquote> +<blockquote> +<p>spoky</p> +</blockquote> +<p>testing</p> +</blockquote> +<p>test</p> +`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("> > spoky\n> \n> testing\n\ntest"); + }); + }); + describe("FormatMessage / formatted_body / lists", () => { + it("parses simple unordered lists", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<p>soru</p> +<ul> +<li>test</li> +<li>ing</li> +</ul> +<p>more</p> +`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("soru\n● test\n● ing\n\nmore"); + }); + it("parses nested unordered lists", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<p>foxes</p> +<ul> +<li>awesome</li> +<li>floofy +<ul> +<li>fur</li> +<li>tail</li> +</ul> +</li> +</ul> +<p>yay!</p> +`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("foxes\n● awesome\n● floofy\n ○ fur\n ○ tail\n\nyay!"); + }); + it("parses more nested unordered lists", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<p>foxes</p> +<ul> +<li>awesome</li> +<li>floofy +<ul> +<li>fur</li> +<li>tail</li> +</ul> +</li> +<li>cute</li> +</ul> +<p>yay!</p> +`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("foxes\n● awesome\n● floofy\n ○ fur\n ○ tail\n● cute\n\nyay!"); + }); + }); + it("parses simple ordered lists", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<p>oookay</p> +<ol> +<li>test</li> +<li>test more</li> +</ol> +<p>ok?</p> +`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("oookay\n1. test\n2. test more\n\nok?"); + }); + it("parses nested ordered lists", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<p>and now</p> +<ol> +<li>test</li> +<li>test more +<ol> +<li>and more</li> +<li>more?</li> +</ol> +</li> +<li>done!</li> +</ol> +<p>ok?</p> +`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("and now\n1. test\n2. test more\n 1. and more\n 2. more?\n3. done!\n\nok?"); + }); + it("parses ordered lists with different start", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<ol start="5"> +<li>test</li> +<li>test more</li> +</ol>`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("\n5. test\n6. test more"); + }); + it("parses ul in ol", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<ol> +<li>test</li> +<li>test more +<ul> +<li>asdf</li> +<li>jklö</li> +</ul> +</li> +</ol>`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("\n1. test\n2. test more\n ○ asdf\n ○ jklö"); + }); + it("parses ol in ul", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<ul> +<li>test</li> +<li>test more +<ol> +<li>asdf</li> +<li>jklö</li> +</ol> +</li> +</ul>`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("\n● test\n● test more\n 1. asdf\n 2. jklö"); + }); +}); diff --git a/test/test_util.ts b/test/test_util.ts index 9284942d14f4dcd3438be6df2cd26371353bf202..5003a4fd7d09c66dc5d261becfeca75f2931a782 100644 --- a/test/test_util.ts +++ b/test/test_util.ts @@ -125,30 +125,6 @@ describe("Util", () => { } }); }); - describe("GetReplyFromReplyBody", () => { - it("Should get a reply from the body", () => { - const reply = Util.GetReplyFromReplyBody(`> <@alice:example.org> This is the original body - -This is where the reply goes`); - expect(reply).to.equal("This is where the reply goes"); - }); - it("Should get a multi-line reply from the body", () => { - const reply = Util.GetReplyFromReplyBody(`> <@alice:example.org> This is the original body - -This is where the reply goes and -there are even more lines here.`); - expect(reply).to.equal("This is where the reply goes and\nthere are even more lines here."); - }); - it("Should get empty string from an empty reply", () => { - const reply = Util.GetReplyFromReplyBody(`> <@alice:example.org> This is the original body -`); - expect(reply).to.equal(""); - }); - it("Should return body if no reply found", () => { - const reply = Util.GetReplyFromReplyBody("Test\nwith\nhalfy"); - expect(reply).to.equal("Test\nwith\nhalfy"); - }); - }); describe("NumberToHTMLColor", () => { it("Should handle valid colors", () => { const COLOR = 0xdeadaf;