Skip to content

Commit cc1de68

Browse files
committed
feat: add quote feature
1 parent bf7ee87 commit cc1de68

3 files changed

Lines changed: 373 additions & 0 deletions

File tree

src/common/events/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { DiscordEvent } from './types.js';
66
import archiveChannels from '@/features/archive-channels/index.js';
77
import { tagReceivedEvent } from '@/features/tags/tag-received.js';
88
import { reactionAddEvent } from '@/features/reactions/index.js';
9+
import { quoteEvent } from '@/features/quote/index.js';
910

1011
export const events: DiscordEvent[] = [
1112
readyEvent,
@@ -15,4 +16,5 @@ export const events: DiscordEvent[] = [
1516
archiveChannels,
1617
tagReceivedEvent,
1718
reactionAddEvent,
19+
quoteEvent,
1820
].flat();

src/features/quote/embed.ts

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import { clampText } from '@/util/text.js';
2+
import {
3+
ActionRowBuilder,
4+
type APIEmbedField,
5+
ButtonBuilder,
6+
ButtonStyle,
7+
ComponentType,
8+
EmbedBuilder,
9+
type Message,
10+
type MessageActionRowComponentBuilder,
11+
type MessageCreateOptions,
12+
MessageFlags,
13+
TextDisplayBuilder,
14+
type User,
15+
} from 'discord.js';
16+
17+
const EMBED_DESC_LIMIT = 4096;
18+
const FIELD_VALUE_LIMIT = 1024;
19+
const JUMP_BUTTON_LABEL = 'Jump to message';
20+
21+
type OriginalQuoteInfo = {
22+
authorMention: string;
23+
channelName: string;
24+
jumpLink: string;
25+
};
26+
27+
// Captures the pieces of a line we previously generated:
28+
// "<@quotedBy> quoted <@author> from **#channel** [link ↗](<url>)" (V2, has link)
29+
// "<@quotedBy> quoted <@author> from **#channel**" (V1, no link)
30+
const QUOTE_LINE_CAPTURE_REGEX =
31+
/^(?:-#\s)?<@!?\d+>\squoted\s(<@!?\d+>)\sfrom\s\*\*#(.+?)\*\*(?:\s\[link \]\(<(.+?)>\))?$/;
32+
33+
type ParsedQuoteLine = {
34+
authorMention: string;
35+
channelName: string;
36+
jumpLink?: string;
37+
};
38+
39+
const parseQuoteLine = (text: string): ParsedQuoteLine | null => {
40+
const match = QUOTE_LINE_CAPTURE_REGEX.exec(text);
41+
if (!match) {
42+
return null;
43+
}
44+
const [, authorMention, channelName, jumpLink] = match;
45+
return { authorMention, channelName, jumpLink };
46+
};
47+
48+
const buildQuoteLine = (
49+
quotedBy: User,
50+
info: OriginalQuoteInfo,
51+
includeLink: boolean
52+
): string =>
53+
clampText(
54+
includeLink
55+
? `${quotedBy.toString()} quoted ${info.authorMention} from **#${info.channelName}** [link ↗](<${info.jumpLink}>)`
56+
: `${quotedBy.toString()} quoted ${info.authorMention} from **#${info.channelName}**`,
57+
FIELD_VALUE_LIMIT
58+
);
59+
60+
const findExistingJumpButtonUrl = (message: Message): string | null => {
61+
for (const row of message.components) {
62+
if (row.type !== ComponentType.ActionRow) {
63+
continue;
64+
}
65+
for (const component of row.components) {
66+
if (
67+
component.type === ComponentType.Button &&
68+
component.style === ButtonStyle.Link &&
69+
component.label === JUMP_BUTTON_LABEL
70+
) {
71+
return component.url ?? null;
72+
}
73+
}
74+
}
75+
return null;
76+
};
77+
78+
export const createQuoteEmbed = ({
79+
quotedMessage,
80+
quotedBy,
81+
}: {
82+
quotedMessage: Message;
83+
quotedBy: User;
84+
}): MessageCreateOptions | null => {
85+
const channelName = !quotedMessage.channel.isDMBased()
86+
? quotedMessage.channel.name
87+
: 'Direct Message';
88+
89+
// Default: quotedMessage is an original, non-quote message, so it *is*
90+
// the source of truth for author/channel/link.
91+
const freshInfo: OriginalQuoteInfo = {
92+
authorMention: `${quotedMessage.author.toString()}`,
93+
channelName,
94+
jumpLink: quotedMessage.url,
95+
};
96+
97+
const isV2 = quotedMessage.flags.has(MessageFlags.IsComponentsV2);
98+
99+
if (isV2) {
100+
const components = quotedMessage.components.map((component) =>
101+
component.toJSON()
102+
);
103+
104+
const existingLineIndex = components.findIndex(
105+
(component) => component.type === ComponentType.TextDisplay
106+
);
107+
const existingContent =
108+
existingLineIndex !== -1
109+
? (components[existingLineIndex] as { content: string }).content
110+
: null;
111+
112+
const parsed =
113+
existingContent !== null ? parseQuoteLine(existingContent) : null;
114+
115+
const originalInfo: OriginalQuoteInfo = parsed
116+
? {
117+
authorMention: parsed.authorMention,
118+
channelName: parsed.channelName,
119+
jumpLink: parsed.jumpLink ?? freshInfo.jumpLink,
120+
}
121+
: freshInfo;
122+
123+
const attributionLine = new TextDisplayBuilder()
124+
.setContent(`-# ${buildQuoteLine(quotedBy, originalInfo, true)}`)
125+
.toJSON();
126+
127+
if (existingLineIndex !== -1) {
128+
components[existingLineIndex] = attributionLine;
129+
} else {
130+
components.push(attributionLine);
131+
}
132+
133+
return {
134+
allowedMentions: { parse: [] },
135+
components,
136+
flags: MessageFlags.IsComponentsV2,
137+
};
138+
}
139+
140+
// Legacy (non-V2 components)
141+
const attachmentUrls = quotedMessage.attachments.map(
142+
(attachment) => attachment.url
143+
);
144+
const firstImage = quotedMessage.attachments.find((attachment) =>
145+
attachment.contentType?.startsWith('image/')
146+
);
147+
148+
let embeds = quotedMessage.embeds
149+
.filter((embed) => embed.data.type === 'rich')
150+
.slice(0, 9) // leave room for our wrapper, max 10 embeds/message
151+
.map((embed) => EmbedBuilder.from(embed));
152+
153+
// Find an existing "Quoted by" field, if quotedMessage is itself a quote.
154+
let existingField: APIEmbedField | null = null;
155+
for (const embed of embeds) {
156+
const found = embed.data.fields?.find(
157+
(field) => /^quoted by$/i.test(field.name) && parseQuoteLine(field.value)
158+
);
159+
if (found) {
160+
existingField = found;
161+
break;
162+
}
163+
}
164+
165+
const parsedField = existingField
166+
? parseQuoteLine(existingField.value)
167+
: null;
168+
169+
// Recover link from the existing jump button, if present, otherwise fall back to the parsed field or fresh info.
170+
const originalInfo: OriginalQuoteInfo = parsedField
171+
? {
172+
authorMention: parsedField.authorMention,
173+
channelName: parsedField.channelName,
174+
jumpLink:
175+
findExistingJumpButtonUrl(quotedMessage) ??
176+
parsedField.jumpLink ??
177+
freshInfo.jumpLink,
178+
}
179+
: freshInfo;
180+
181+
const quotedByField: APIEmbedField = {
182+
name: 'Quoted by',
183+
value: buildQuoteLine(quotedBy, originalInfo, false),
184+
inline: false,
185+
};
186+
187+
if (existingField) {
188+
// Already a quote: swap the field's value in place, keep everything
189+
// else (original author/description/image/timestamp) untouched.
190+
existingField.value = quotedByField.value;
191+
} else {
192+
// First-time quote: build the wrapper/annotation.
193+
const authorOptions = {
194+
name: quotedMessage.author.username,
195+
iconURL: quotedMessage.author.displayAvatarURL({ size: 64 }),
196+
};
197+
198+
const stampAsQuote = (embed: EmbedBuilder) =>
199+
embed.setAuthor(authorOptions).addFields(quotedByField).setTimestamp();
200+
201+
const hasContent = quotedMessage.content.length > 0;
202+
const hasEmbeds = embeds.length > 0;
203+
const hasAttachments = attachmentUrls.length > 0;
204+
const hasStickers = quotedMessage.stickers.size > 0;
205+
206+
if (!hasContent && !hasStickers && !hasEmbeds && !hasAttachments) {
207+
return null;
208+
}
209+
210+
if (hasContent || hasStickers || (!hasEmbeds && !hasAttachments)) {
211+
const wrapper = stampAsQuote(new EmbedBuilder()).setDescription(
212+
hasContent
213+
? clampText(quotedMessage.content, EMBED_DESC_LIMIT)
214+
: hasStickers
215+
? '*sent a sticker*'
216+
: null
217+
);
218+
if (firstImage) {
219+
wrapper.setImage(firstImage.url);
220+
}
221+
embeds = [wrapper, ...embeds];
222+
} else if (hasEmbeds) {
223+
embeds[0] = stampAsQuote(embeds[0]);
224+
} else {
225+
embeds = [
226+
stampAsQuote(new EmbedBuilder()).setImage(firstImage?.url ?? null),
227+
];
228+
}
229+
}
230+
231+
// Don't re-send the image we already used as the embed's setImage,
232+
// otherwise it shows up twice.
233+
const filesToSend = firstImage
234+
? attachmentUrls.filter((url) => url !== firstImage.url)
235+
: attachmentUrls;
236+
237+
return {
238+
allowedMentions: { parse: [] },
239+
embeds: embeds.length > 0 ? embeds : undefined,
240+
components: [
241+
new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents(
242+
new ButtonBuilder()
243+
.setURL(originalInfo.jumpLink)
244+
.setLabel(JUMP_BUTTON_LABEL)
245+
.setStyle(ButtonStyle.Link)
246+
),
247+
],
248+
files: filesToSend.length > 0 ? filesToSend : undefined,
249+
};
250+
};

src/features/quote/index.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { Client, Events, type Message } from 'discord.js';
2+
import { createEvent } from '@/common/events/create-event.js';
3+
import { UserBotMessagesService } from '@/services/user-bot-messages/user-bot-messages-service.js';
4+
import { createQuoteEmbed } from './embed.js';
5+
6+
export const quoteEvent = createEvent(
7+
{
8+
name: Events.MessageCreate,
9+
},
10+
async (message) => {
11+
if (message.system || message.author.bot) {
12+
return;
13+
}
14+
const guildId = message.guildId;
15+
16+
const messageLinkRegex = new RegExp(
17+
`https:\\/\\/discord\\.com\\/channels\\/${guildId}\\/(\\d+)\\/(\\d+)`,
18+
'g'
19+
);
20+
21+
const matchedQuoteLinks = Array.from(
22+
message.content.matchAll(messageLinkRegex)
23+
);
24+
25+
if (matchedQuoteLinks.length === 0) {
26+
return;
27+
}
28+
29+
const quotedMessages = await Promise.allSettled(
30+
matchedQuoteLinks.map((match) =>
31+
getMessage({
32+
channelId: match[1],
33+
messageId: match[2],
34+
client: message.client,
35+
})
36+
)
37+
);
38+
39+
const validQuotedMessages = quotedMessages.reduce<Message<true>[]>(
40+
(acc, result) => {
41+
if (result.status === 'fulfilled' && result.value !== null) {
42+
acc.push(result.value);
43+
}
44+
return acc;
45+
},
46+
[]
47+
);
48+
49+
const onlyContainsLinks =
50+
message.content.replace(messageLinkRegex, '').trim().length === 0;
51+
52+
const embedOptions = validQuotedMessages.map((quotedMessage) =>
53+
createQuoteEmbed({ quotedMessage, quotedBy: message.author })
54+
);
55+
56+
const validEmbeds = embedOptions.filter(
57+
(embed): embed is NonNullable<typeof embed> => embed !== null
58+
);
59+
60+
const shouldDelete = onlyContainsLinks && validEmbeds.length > 0;
61+
62+
if (shouldDelete) {
63+
try {
64+
void message.delete();
65+
} catch {}
66+
}
67+
68+
if (validEmbeds.length === 0) {
69+
return;
70+
}
71+
72+
const referenceMessageId =
73+
message.reference?.messageId || (shouldDelete ? undefined : message.id);
74+
75+
const channel = message.channel;
76+
77+
const results = await Promise.allSettled(
78+
validEmbeds.map(async (options, i) => {
79+
const sentMessage = await channel.send(
80+
i === 0 && referenceMessageId
81+
? { ...options, reply: { messageReference: referenceMessageId } }
82+
: options
83+
);
84+
void UserBotMessagesService.addUserBotMessage({
85+
messageId: sentMessage.id,
86+
userId: message.author.id,
87+
channelId: message.channel.id,
88+
});
89+
})
90+
);
91+
92+
for (const result of results) {
93+
if (result.status === 'rejected') {
94+
console.error('Failed to send quote message:', result.reason);
95+
}
96+
}
97+
98+
return;
99+
}
100+
);
101+
102+
async function getMessage({
103+
channelId,
104+
messageId,
105+
client,
106+
}: {
107+
channelId: string;
108+
messageId: string;
109+
client: Client;
110+
}) {
111+
const channel = await client.channels.fetch(channelId);
112+
if (!channel?.isTextBased() || channel.isDMBased()) {
113+
return null;
114+
}
115+
try {
116+
const quotedMessage = await channel.messages.fetch(messageId);
117+
return quotedMessage;
118+
} catch {
119+
return null;
120+
}
121+
}

0 commit comments

Comments
 (0)