diff --git a/src/bot.ts b/src/bot.ts index ec0dc412c35eb6498b67a3761170e33df64c89a8..afa7b438944a2f36486547f090e80bab2f4df408 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -235,7 +235,8 @@ export class DiscordBot { log.warn(`User ${event.sender} has no member state. That's odd.`); } } - const embed = this.mxEventProcessor.EventToEmbed(event, profile, chan); + const embedSet = await this.mxEventProcessor.EventToEmbed(event, profile, chan); + const embed = embedSet.messageEmbed; const opts: Discord.MessageOptions = {}; const file = await this.mxEventProcessor.HandleAttachment(event, mxClient); if (typeof(file) === "string") { @@ -260,14 +261,20 @@ export class DiscordBot { } try { if (!botUser) { + opts.embed = embedSet.replyEmbed; msg = await chan.send(embed.description, opts); } else if (hook) { msg = await hook.send(embed.description, { username: embed.author.name, avatarURL: embed.author.icon_url, file: opts.file, - }); + embed: embedSet.replyEmbed, + } as Discord.WebhookMessageOptions); } else { + if (embedSet.replyEmbed) { + embed.addField("Replying to", embedSet.replyEmbed.author.name); + embed.addField("Reply text", embedSet.replyEmbed.description); + } opts.embed = embed; msg = await chan.send("", opts); } diff --git a/src/matrixeventprocessor.ts b/src/matrixeventprocessor.ts index 1ec23a3074f74e723b7400b3179eb859e9dc8e72..f9ed11839c97be24cc475ee6bf6328a171cdd467 100644 --- a/src/matrixeventprocessor.ts +++ b/src/matrixeventprocessor.ts @@ -14,6 +14,7 @@ const MaxFileSize = 8000000; const MIN_NAME_LENGTH = 2; const MAX_NAME_LENGTH = 32; const DISCORD_EMOJI_REGEX = /:(\w+):/g; +const REPLY_REGEX = /> <(@.*:.*)> (.*)\n\n(.*)/; export class MatrixEventProcessorOpts { constructor( @@ -24,6 +25,11 @@ export class MatrixEventProcessorOpts { } } +export interface MatrixEventProcessorResult { + messageEmbed: Discord.RichEmbed; + replyEmbed?: Discord.RichEmbed; +} + export class MatrixEventProcessor { private config: DiscordBridgeConfig; private bridge: any; @@ -71,7 +77,7 @@ export class MatrixEventProcessor { return msg; } - public EventToEmbed(event: any, profile: any|null, channel: Discord.TextChannel): Discord.RichEmbed { + public async EventToEmbed(event: any, profile: any|null, channel: Discord.TextChannel): Promise<MatrixEventProcessorResult> { let body = this.config.bridge.disableDiscordMentions ? event.content.body : this.FindMentionsInPlainBody( event.content.body, @@ -116,28 +122,14 @@ export class MatrixEventProcessor { // Handle discord custom emoji body = this.ReplaceDiscordEmoji(body, channel.guild); - let displayName = event.sender; - let avatarUrl = undefined; - if (profile) { - if (profile.displayname && - profile.displayname.length >= MIN_NAME_LENGTH && - profile.displayname.length <= MAX_NAME_LENGTH) { - displayName = profile.displayname; - } - - if (profile.avatar_url) { - const mxClient = this.bridge.getClientFactory().getClientAs(); - avatarUrl = mxClient.mxcUrlToHttp(profile.avatar_url); - } - } - return new Discord.RichEmbed({ - author: { - name: displayName.substr(0, MAX_NAME_LENGTH), - icon_url: avatarUrl, - url: `https://matrix.to/#/${event.sender}`, - }, - description: body, - }); + const messageEmbed = new Discord.RichEmbed(); + const replyEmbedAndBody = await this.GetEmbedForReply(event); + messageEmbed.setDescription(replyEmbedAndBody ? replyEmbedAndBody[1] : body); + this.SetEmbedAuthor(messageEmbed, event.sender, profile); + return { + messageEmbed, + replyEmbed: replyEmbedAndBody ? replyEmbedAndBody[0] : undefined, + }; } public FindMentionsInPlainBody(body: string, members: Discord.GuildMember[]): string { @@ -212,6 +204,74 @@ export class MatrixEventProcessor { return `[${name}](${url})`; } + public async GetEmbedForReply(event: any): Promise<[Discord.RichEmbed,string]|undefined> { + const relatesTo = event.content["m.relates_to"]; + let eventId = null; + if (relatesTo && relatesTo["m.in_reply_to"]) { + eventId = relatesTo["m.in_reply_to"].event_id; + } else { + return; + } + const matches = REPLY_REGEX.exec(event.content.body); + if (!matches || matches.length !== 4) { + return; + } + const intent = this.bridge.getIntent(); + const embed = new Discord.RichEmbed(); + // Try to get the event. + try { + const sourceEvent = await intent.getEvent(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"]) { + const sourceMatch = REPLY_REGEX.exec(sourceEvent.content.body); + if (sourceMatch && sourceMatch.length === 4) { + replyText = sourceMatch[3]; + } + } + embed.setDescription(replyText); + this.SetEmbedAuthor( + embed, + sourceEvent.sender, + await intent.getProfileInfo(sourceEvent.sender) + ); + } catch (ex) { + // For some reason we failed to get the event, so using fallback. + embed.setDescription(matches[2]); + this.SetEmbedAuthor( + embed, + matches[1], + await intent.getProfileInfo(matches[1]) + ); + } + return [embed, matches[3]]; + } + + private SetEmbedAuthor(embed: Discord.RichEmbed, sender: string, profile: any) { + const intent = this.bridge.getIntent(); + let displayName = sender; + let avatarUrl = undefined; + + if (profile) { + if (profile.displayname && + profile.displayname.length >= MIN_NAME_LENGTH && + profile.displayname.length <= MAX_NAME_LENGTH) { + displayName = profile.displayname; + } + + if (profile.avatar_url) { + const mxClient = this.bridge.getClientFactory().getClientAs(); + avatarUrl = mxClient.mxcUrlToHttp(profile.avatar_url); + } + } + embed.setAuthor( + displayName.substr(0, MAX_NAME_LENGTH), + avatarUrl, + `https://matrix.to/#/${sender}`, + ); + } + private GetFilenameForMediaEvent(content: any): string { if (content.body) { if (path.extname(content.body) !== "") { diff --git a/test/test_matrixeventprocessor.ts b/test/test_matrixeventprocessor.ts index 60b803a6352fe8ad47c7a77299ffcb38feea9c21..037d0b994c3e002e39c1ec79f2243fe9a28f8ad9 100644 --- a/test/test_matrixeventprocessor.ts +++ b/test/test_matrixeventprocessor.ts @@ -54,6 +54,47 @@ function createMatrixEventProcessor }, }; }, + getEvent: async (eventId: string) => { + if (eventId === "$goodEvent:localhost") { + return { + sender: "@doggo:localhost", + content: { + body: "Hello!" + } + }; + } else if (eventId === "$reply:localhost") { + return { + sender: "@doggo:localhost", + content: { + body: `> <@doggo:localhost> This is the original body + +This is the first reply`, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$goodEvent:localhost", + }, + }, + }, + }; + } else if (eventId === "$nontext:localhost") { + return { + sender: "@doggo:localhost", + content: { + something: "not texty", + }, + }; + } + return null; + }, + getProfileInfo: async (userId: string) => { + if (userId !== "@doggo:localhost") { + return null; + } + return { + displayname: "Doggo!", + avatar_url: "mxc://fakeurl.com", + }; + }, }; }, }; @@ -203,9 +244,9 @@ describe("MatrixEventProcessor", () => { }); }); describe("EventToEmbed", () => { - it("Should contain a profile.", () => { + it("Should contain a profile.", async () => { const processor = createMatrixEventProcessor(); - const evt = processor.EventToEmbed({ + const embeds = await processor.EventToEmbed({ sender: "@test:localhost", content: { body: "testcontent", @@ -214,53 +255,57 @@ describe("MatrixEventProcessor", () => { displayname: "Test User", avatar_url: "mxc://localhost/avatarurl", }, mockChannel as any); - Chai.assert.equal(evt.author.name, "Test User"); - Chai.assert.equal(evt.author.icon_url, "https://localhost/avatarurl"); - Chai.assert.equal(evt.author.url, "https://matrix.to/#/@test:localhost"); + const author = embeds.messageEmbed.author; + 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 contain the users displayname if it exists.", () => { + it("Should contain the users displayname if it exists.", async () => { const processor = createMatrixEventProcessor(); - const evt = processor.EventToEmbed({ + const embeds = await processor.EventToEmbed({ sender: "@test:localhost", content: { body: "testcontent", }, }, { displayname: "Test User"}, mockChannel as any); - Chai.assert.equal(evt.author.name, "Test User"); - Chai.assert.isUndefined(evt.author.icon_url); - Chai.assert.equal(evt.author.url, "https://matrix.to/#/@test:localhost"); + const author = embeds.messageEmbed.author; + Chai.assert.equal(author.name, "Test User"); + Chai.assert.isUndefined(author.icon_url); + Chai.assert.equal(author.url, "https://matrix.to/#/@test:localhost"); }); - it("Should contain the users userid if the displayname is not set", () => { + it("Should contain the users userid if the displayname is not set", async () => { const processor = createMatrixEventProcessor(); - const evt = processor.EventToEmbed({ + const embeds = await processor.EventToEmbed({ sender: "@test:localhost", content: { body: "testcontent", }, }, null, mockChannel as any); - Chai.assert.equal(evt.author.name, "@test:localhost"); - Chai.assert.isUndefined(evt.author.icon_url); - Chai.assert.equal(evt.author.url, "https://matrix.to/#/@test:localhost"); + const author = embeds.messageEmbed.author; + Chai.assert.equal(author.name, "@test:localhost"); + Chai.assert.isUndefined(author.icon_url); + Chai.assert.equal(author.url, "https://matrix.to/#/@test:localhost"); }); - it("Should use the userid when the displayname is too short", () => { + it("Should use the userid when the displayname is too short", async () => { const processor = createMatrixEventProcessor(); - const evt = processor.EventToEmbed({ + const embeds = await processor.EventToEmbed({ sender: "@test:localhost", content: { body: "testcontent", }, }, { displayname: "t"}, mockChannel as any); - Chai.assert.equal(evt.author.name, "@test:localhost"); + const author = embeds.messageEmbed.author; + Chai.assert.equal(author.name, "@test:localhost"); }); - it("Should use the userid when displayname is too long", () => { + it("Should use the userid when displayname is too long", async () => { const processor = createMatrixEventProcessor(); - const evt = processor.EventToEmbed({ + const embeds = await processor.EventToEmbed({ sender: "@test:localhost", content: { body: "testcontent", @@ -268,78 +313,81 @@ describe("MatrixEventProcessor", () => { }, { displayname: "this is a very very long displayname that should be capped", }, mockChannel as any); - Chai.assert.equal(evt.author.name, "@test:localhost"); + const author = embeds.messageEmbed.author; + Chai.assert.equal(author.name, "@test:localhost"); }); - it("Should cap the sender name if it is too long", () => { + it("Should cap the sender name if it is too long", async () => { const processor = createMatrixEventProcessor(); - const evt = processor.EventToEmbed({ + const embeds = await processor.EventToEmbed({ sender: "@testwithalottosayaboutitselfthatwillgoonandonandonandon:localhost", content: { body: "testcontent", }, }, null, mockChannel as any); - Chai.assert.equal(evt.author.name, "@testwithalottosayaboutitselftha"); + const author = embeds.messageEmbed.author; + Chai.assert.equal(author.name, "@testwithalottosayaboutitselftha"); }); - it("Should contain the users avatar if it exists.", () => { + it("Should contain the users avatar if it exists.", async () => { const processor = createMatrixEventProcessor(); - const evt = processor.EventToEmbed({ + const embeds = await processor.EventToEmbed({ sender: "@test:localhost", content: { body: "testcontent", }, }, {avatar_url: "mxc://localhost/test"}, mockChannel as any); - Chai.assert.equal(evt.author.name, "@test:localhost"); - Chai.assert.equal(evt.author.icon_url, "https://localhost/test"); - Chai.assert.equal(evt.author.url, "https://matrix.to/#/@test:localhost"); + 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.url, "https://matrix.to/#/@test:localhost"); }); - it("Should enable mentions if configured.", () => { - const processor = createMatrixEventProcessor(); - const evt = processor.EventToEmbed({ + it("Should enable mentions if configured.", async () => { + const processor = await createMatrixEventProcessor(); + const embeds = await processor.EventToEmbed({ sender: "@test:localhost", content: { body: "@testuser2 Hello!", }, }, {avatar_url: "test"}, mockChannel as any); - Chai.assert.equal(evt.description, "<@!12345> Hello!"); + Chai.assert.equal(embeds.messageEmbed.description, "<@!12345> Hello!"); }); - it("Should disable mentions if configured.", () => { + it("Should disable mentions if configured.", async () => { const processor = createMatrixEventProcessor(true); - const evt = processor.EventToEmbed({ + const embeds = await processor.EventToEmbed({ sender: "@test:localhost", content: { body: "@testuser2 Hello!", }, }, {avatar_url: "test"}, mockChannel as any); - Chai.assert.equal(evt.description, "@testuser2 Hello!"); + Chai.assert.equal(embeds.messageEmbed.description, "@testuser2 Hello!"); }); - it("Should remove everyone mentions if configured.", () => { + it("Should remove everyone mentions if configured.", async () => { const processor = createMatrixEventProcessor(false, true); - const evt = processor.EventToEmbed({ + const embeds = await processor.EventToEmbed({ sender: "@test:localhost", content: { body: "@everyone Hello!", }, }, {avatar_url: "test"}, mockChannel as any); - Chai.assert.equal(evt.description, "@ everyone Hello!"); + Chai.assert.equal(embeds.messageEmbed.description, "@ everyone Hello!"); }); - it("Should remove here mentions if configured.", () => { + it("Should remove here mentions if configured.", async () => { const processor = createMatrixEventProcessor(false, false, true); - const evt = processor.EventToEmbed({ + const embeds = await processor.EventToEmbed({ sender: "@test:localhost", content: { body: "@here Hello!", }, }, {avatar_url: "test"}, mockChannel as any); - Chai.assert.equal(evt.description, "@ here Hello!"); + Chai.assert.equal(embeds.messageEmbed.description, "@ here Hello!"); }); - it("Should process custom discord emojis.", () => { + 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>(); @@ -348,16 +396,19 @@ describe("MatrixEventProcessor", () => { const mockChannelEmojis = new MockChannel("test", { emojis: mockCollectionEmojis, }); - const evt = processor.EventToEmbed({ + const embeds = await processor.EventToEmbed({ sender: "@test:localhost", content: { body: "I like :supercake:", }, }, {avatar_url: "test"}, mockChannelEmojis as any); - Chai.assert.equal(evt.description, "I like <:supercake:123>"); + Chai.assert.equal( + embeds.messageEmbed.description, + "I like <:supercake:123>" + ); }); - it("Should not process invalid custom discord emojis.", () => { + 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>(); @@ -366,17 +417,20 @@ describe("MatrixEventProcessor", () => { const mockChannelEmojis = new MockChannel("test", { emojis: mockCollectionEmojis, }); - const evt = processor.EventToEmbed({ + const embeds = await processor.EventToEmbed({ sender: "@test:localhost", content: { body: "I like :lamecake:", }, }, {avatar_url: "test"}, mockChannelEmojis as any); - Chai.assert.equal(evt.description, "I like :lamecake:"); + Chai.assert.equal( + embeds.messageEmbed.description, + "I like :lamecake:" + ); }); - it("Should replace /me with * displayname, and italicize message", () => { + it("Should replace /me with * displayname, and italicize message", async () => { const processor = createMatrixEventProcessor(); - const evt = processor.EventToEmbed({ + const embeds = await processor.EventToEmbed({ sender: "@test:localhost", content: { body: "likes puppies", @@ -385,11 +439,14 @@ describe("MatrixEventProcessor", () => { }, { displayname: "displayname", }, mockChannel as any); - Chai.assert.equal(evt.description, "*displayname likes puppies*"); + Chai.assert.equal( + embeds.messageEmbed.description, + "*displayname likes puppies*" + ); }); - it("Should handle stickers.", () => { + it("Should handle stickers.", async () => { const processor = createMatrixEventProcessor(); - const evt = processor.EventToEmbed({ + const embeds = await processor.EventToEmbed({ sender: "@test:localhost", type: "m.sticker", content: { @@ -397,7 +454,7 @@ describe("MatrixEventProcessor", () => { url: "mxc://bunny", }, }, {avatar_url: "test"}, mockChannel as any); - Chai.assert.equal(evt.description, ""); + Chai.assert.equal(embeds.messageEmbed.description, ""); }); }); describe("FindMentionsInPlainBody", () => { @@ -603,4 +660,121 @@ describe("MatrixEventProcessor", () => { }); }); }); + describe("GetEmbedForReply", () => { + it("should handle reply-less events", async () => { + const processor = createMatrixEventProcessor(); + const result = await processor.GetEmbedForReply({ + sender: "@test:localhost", + type: "m.room.message", + content: { + body: "Test", + }, + }); + expect(result).to.be.undefined; + }); + it("should handle replies with a wrongly formatted body", async () => { + const processor = createMatrixEventProcessor(); + const result = await processor.GetEmbedForReply({ + sender: "@test:localhost", + type: "m.room.message", + content: { + body: "Test", + "m.relates_to": { + "m.in_reply_to": { + event_id: "!event:thing", + }, + }, + }, + }); + expect(result).to.be.undefined; + }); + it("should handle replies with a missing event", async () => { + const processor = createMatrixEventProcessor(); + const result = await processor.GetEmbedForReply({ + sender: "@test:localhost", + type: "m.room.message", + content: { + body: `> <@doggo:localhost> This is the fake body + +This is where the reply goes`, + "m.relates_to": { + "m.in_reply_to": { + event_id: "!event:thing", + }, + }, + }, + }); + expect(result[0].description).to.be.equal("This is the fake body"); + 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"); + }); + it("should handle replies with a valid reply event", async () => { + const processor = createMatrixEventProcessor(); + const result = await processor.GetEmbedForReply({ + sender: "@test:localhost", + type: "m.room.message", + content: { + body: `> <@doggo:localhost> This is the original body + +This is where the reply goes`, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$goodEvent:localhost", + }, + }, + }, + }); + 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"); + }); + it("should handle replies on top of replies", async () => { + const processor = createMatrixEventProcessor(); + const result = await processor.GetEmbedForReply({ + sender: "@test:localhost", + type: "m.room.message", + content: { + body: `> <@doggo:localhost> This is the first reply + +This is the second reply`, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$reply:localhost", + }, + }, + }, + }); + 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"); + }); + it("should handle replies with non text events", async () => { + const processor = createMatrixEventProcessor(); + const result = await processor.GetEmbedForReply({ + sender: "@test:localhost", + type: "m.room.message", + content: { + body: `> <@doggo:localhost> sent an image. + +This is the reply`, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$nontext:localhost", + }, + }, + }, + }); + 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"); + }); + }); });