@chat-adapter/discord 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +9 -0
- package/README.md +231 -0
- package/dist/index.d.ts +301 -0
- package/dist/index.js +1569 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1569 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
extractCard,
|
|
4
|
+
extractFiles,
|
|
5
|
+
NetworkError,
|
|
6
|
+
toBuffer,
|
|
7
|
+
ValidationError
|
|
8
|
+
} from "@chat-adapter/shared";
|
|
9
|
+
import { convertEmojiPlaceholders as convertEmojiPlaceholders2, defaultEmojiResolver, getEmoji } from "chat";
|
|
10
|
+
import {
|
|
11
|
+
Client,
|
|
12
|
+
Events,
|
|
13
|
+
GatewayIntentBits
|
|
14
|
+
} from "discord.js";
|
|
15
|
+
import {
|
|
16
|
+
ChannelType,
|
|
17
|
+
InteractionType
|
|
18
|
+
} from "discord-api-types/v10";
|
|
19
|
+
import {
|
|
20
|
+
InteractionResponseType as DiscordInteractionResponseType,
|
|
21
|
+
verifyKey
|
|
22
|
+
} from "discord-interactions";
|
|
23
|
+
|
|
24
|
+
// src/cards.ts
|
|
25
|
+
import { convertEmojiPlaceholders } from "chat";
|
|
26
|
+
import { ButtonStyle } from "discord-api-types/v10";
|
|
27
|
+
function convertEmoji(text) {
|
|
28
|
+
return convertEmojiPlaceholders(text, "discord");
|
|
29
|
+
}
|
|
30
|
+
function cardToDiscordPayload(card) {
|
|
31
|
+
const embed = {};
|
|
32
|
+
const fields = [];
|
|
33
|
+
const components = [];
|
|
34
|
+
if (card.title) {
|
|
35
|
+
embed.title = convertEmoji(card.title);
|
|
36
|
+
}
|
|
37
|
+
if (card.subtitle) {
|
|
38
|
+
embed.description = convertEmoji(card.subtitle);
|
|
39
|
+
}
|
|
40
|
+
if (card.imageUrl) {
|
|
41
|
+
embed.image = {
|
|
42
|
+
url: card.imageUrl
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
embed.color = 5793266;
|
|
46
|
+
const textParts = [];
|
|
47
|
+
for (const child of card.children) {
|
|
48
|
+
processChild(child, textParts, fields, components);
|
|
49
|
+
}
|
|
50
|
+
if (textParts.length > 0) {
|
|
51
|
+
if (embed.description) {
|
|
52
|
+
embed.description += `
|
|
53
|
+
|
|
54
|
+
${textParts.join("\n\n")}`;
|
|
55
|
+
} else {
|
|
56
|
+
embed.description = textParts.join("\n\n");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (fields.length > 0) {
|
|
60
|
+
embed.fields = fields;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
embeds: [embed],
|
|
64
|
+
components
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function processChild(child, textParts, fields, components) {
|
|
68
|
+
switch (child.type) {
|
|
69
|
+
case "text":
|
|
70
|
+
textParts.push(convertTextElement(child));
|
|
71
|
+
break;
|
|
72
|
+
case "image":
|
|
73
|
+
break;
|
|
74
|
+
case "divider":
|
|
75
|
+
textParts.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
76
|
+
break;
|
|
77
|
+
case "actions":
|
|
78
|
+
components.push(convertActionsElement(child));
|
|
79
|
+
break;
|
|
80
|
+
case "section":
|
|
81
|
+
processSectionElement(child, textParts, fields, components);
|
|
82
|
+
break;
|
|
83
|
+
case "fields":
|
|
84
|
+
convertFieldsElement(child, fields);
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function convertTextElement(element) {
|
|
89
|
+
let text = convertEmoji(element.content);
|
|
90
|
+
if (element.style === "bold") {
|
|
91
|
+
text = `**${text}**`;
|
|
92
|
+
} else if (element.style === "muted") {
|
|
93
|
+
text = `*${text}*`;
|
|
94
|
+
}
|
|
95
|
+
return text;
|
|
96
|
+
}
|
|
97
|
+
function convertActionsElement(element) {
|
|
98
|
+
const buttons = element.children.map(
|
|
99
|
+
(button) => convertButtonElement(button)
|
|
100
|
+
);
|
|
101
|
+
return {
|
|
102
|
+
type: 1,
|
|
103
|
+
// Action Row
|
|
104
|
+
components: buttons
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function convertButtonElement(button) {
|
|
108
|
+
const discordButton = {
|
|
109
|
+
type: 2,
|
|
110
|
+
// Button
|
|
111
|
+
style: getButtonStyle(button.style),
|
|
112
|
+
label: button.label,
|
|
113
|
+
custom_id: button.id
|
|
114
|
+
};
|
|
115
|
+
return discordButton;
|
|
116
|
+
}
|
|
117
|
+
function getButtonStyle(style) {
|
|
118
|
+
switch (style) {
|
|
119
|
+
case "primary":
|
|
120
|
+
return ButtonStyle.Primary;
|
|
121
|
+
case "danger":
|
|
122
|
+
return ButtonStyle.Danger;
|
|
123
|
+
default:
|
|
124
|
+
return ButtonStyle.Secondary;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function processSectionElement(element, textParts, fields, components) {
|
|
128
|
+
for (const child of element.children) {
|
|
129
|
+
processChild(child, textParts, fields, components);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function convertFieldsElement(element, fields) {
|
|
133
|
+
for (const field of element.children) {
|
|
134
|
+
fields.push({
|
|
135
|
+
name: convertEmoji(field.label),
|
|
136
|
+
value: convertEmoji(field.value),
|
|
137
|
+
inline: true
|
|
138
|
+
// Discord fields can be inline
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function cardToFallbackText(card) {
|
|
143
|
+
const parts = [];
|
|
144
|
+
if (card.title) {
|
|
145
|
+
parts.push(`**${convertEmoji(card.title)}**`);
|
|
146
|
+
}
|
|
147
|
+
if (card.subtitle) {
|
|
148
|
+
parts.push(convertEmoji(card.subtitle));
|
|
149
|
+
}
|
|
150
|
+
for (const child of card.children) {
|
|
151
|
+
const text = childToFallbackText(child);
|
|
152
|
+
if (text) {
|
|
153
|
+
parts.push(text);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return parts.join("\n\n");
|
|
157
|
+
}
|
|
158
|
+
function childToFallbackText(child) {
|
|
159
|
+
switch (child.type) {
|
|
160
|
+
case "text":
|
|
161
|
+
return convertEmoji(child.content);
|
|
162
|
+
case "fields":
|
|
163
|
+
return child.children.map((f) => `**${convertEmoji(f.label)}**: ${convertEmoji(f.value)}`).join("\n");
|
|
164
|
+
case "actions":
|
|
165
|
+
return `[${child.children.map((b) => convertEmoji(b.label)).join("] [")}]`;
|
|
166
|
+
case "section":
|
|
167
|
+
return child.children.map((c) => childToFallbackText(c)).filter(Boolean).join("\n");
|
|
168
|
+
case "divider":
|
|
169
|
+
return "---";
|
|
170
|
+
default:
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// src/markdown.ts
|
|
176
|
+
import {
|
|
177
|
+
BaseFormatConverter,
|
|
178
|
+
getNodeChildren,
|
|
179
|
+
getNodeValue,
|
|
180
|
+
isBlockquoteNode,
|
|
181
|
+
isCodeNode,
|
|
182
|
+
isDeleteNode,
|
|
183
|
+
isEmphasisNode,
|
|
184
|
+
isInlineCodeNode,
|
|
185
|
+
isLinkNode,
|
|
186
|
+
isListItemNode,
|
|
187
|
+
isListNode,
|
|
188
|
+
isParagraphNode,
|
|
189
|
+
isStrongNode,
|
|
190
|
+
isTextNode,
|
|
191
|
+
parseMarkdown
|
|
192
|
+
} from "chat";
|
|
193
|
+
var DiscordFormatConverter = class extends BaseFormatConverter {
|
|
194
|
+
/**
|
|
195
|
+
* Convert @mentions to Discord format in plain text.
|
|
196
|
+
* @name → <@name>
|
|
197
|
+
*/
|
|
198
|
+
convertMentionsToDiscord(text) {
|
|
199
|
+
return text.replace(/@(\w+)/g, "<@$1>");
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Override renderPostable to convert @mentions in plain strings.
|
|
203
|
+
*/
|
|
204
|
+
renderPostable(message) {
|
|
205
|
+
if (typeof message === "string") {
|
|
206
|
+
return this.convertMentionsToDiscord(message);
|
|
207
|
+
}
|
|
208
|
+
if ("raw" in message) {
|
|
209
|
+
return this.convertMentionsToDiscord(message.raw);
|
|
210
|
+
}
|
|
211
|
+
if ("markdown" in message) {
|
|
212
|
+
return this.fromAst(parseMarkdown(message.markdown));
|
|
213
|
+
}
|
|
214
|
+
if ("ast" in message) {
|
|
215
|
+
return this.fromAst(message.ast);
|
|
216
|
+
}
|
|
217
|
+
return "";
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Render an AST to Discord markdown format.
|
|
221
|
+
*/
|
|
222
|
+
fromAst(ast) {
|
|
223
|
+
return this.fromAstWithNodeConverter(
|
|
224
|
+
ast,
|
|
225
|
+
(node) => this.nodeToDiscordMarkdown(node)
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Parse Discord markdown into an AST.
|
|
230
|
+
*/
|
|
231
|
+
toAst(discordMarkdown) {
|
|
232
|
+
let markdown = discordMarkdown;
|
|
233
|
+
markdown = markdown.replace(/<@!?(\w+)>/g, "@$1");
|
|
234
|
+
markdown = markdown.replace(/<#(\w+)>/g, "#$1");
|
|
235
|
+
markdown = markdown.replace(/<@&(\w+)>/g, "@&$1");
|
|
236
|
+
markdown = markdown.replace(/<a?:(\w+):\d+>/g, ":$1:");
|
|
237
|
+
markdown = markdown.replace(/\|\|([^|]+)\|\|/g, "[spoiler: $1]");
|
|
238
|
+
return parseMarkdown(markdown);
|
|
239
|
+
}
|
|
240
|
+
nodeToDiscordMarkdown(node) {
|
|
241
|
+
if (isParagraphNode(node)) {
|
|
242
|
+
return getNodeChildren(node).map((child) => this.nodeToDiscordMarkdown(child)).join("");
|
|
243
|
+
}
|
|
244
|
+
if (isTextNode(node)) {
|
|
245
|
+
return node.value.replace(/@(\w+)/g, "<@$1>");
|
|
246
|
+
}
|
|
247
|
+
if (isStrongNode(node)) {
|
|
248
|
+
const content = getNodeChildren(node).map((child) => this.nodeToDiscordMarkdown(child)).join("");
|
|
249
|
+
return `**${content}**`;
|
|
250
|
+
}
|
|
251
|
+
if (isEmphasisNode(node)) {
|
|
252
|
+
const content = getNodeChildren(node).map((child) => this.nodeToDiscordMarkdown(child)).join("");
|
|
253
|
+
return `*${content}*`;
|
|
254
|
+
}
|
|
255
|
+
if (isDeleteNode(node)) {
|
|
256
|
+
const content = getNodeChildren(node).map((child) => this.nodeToDiscordMarkdown(child)).join("");
|
|
257
|
+
return `~~${content}~~`;
|
|
258
|
+
}
|
|
259
|
+
if (isInlineCodeNode(node)) {
|
|
260
|
+
return `\`${node.value}\``;
|
|
261
|
+
}
|
|
262
|
+
if (isCodeNode(node)) {
|
|
263
|
+
return `\`\`\`${node.lang || ""}
|
|
264
|
+
${node.value}
|
|
265
|
+
\`\`\``;
|
|
266
|
+
}
|
|
267
|
+
if (isLinkNode(node)) {
|
|
268
|
+
const linkText = getNodeChildren(node).map((child) => this.nodeToDiscordMarkdown(child)).join("");
|
|
269
|
+
return `[${linkText}](${node.url})`;
|
|
270
|
+
}
|
|
271
|
+
if (isBlockquoteNode(node)) {
|
|
272
|
+
return getNodeChildren(node).map((child) => `> ${this.nodeToDiscordMarkdown(child)}`).join("\n");
|
|
273
|
+
}
|
|
274
|
+
if (isListNode(node)) {
|
|
275
|
+
return getNodeChildren(node).map((item, i) => {
|
|
276
|
+
const prefix = node.ordered ? `${i + 1}.` : "-";
|
|
277
|
+
const content = getNodeChildren(item).map((child) => this.nodeToDiscordMarkdown(child)).join("");
|
|
278
|
+
return `${prefix} ${content}`;
|
|
279
|
+
}).join("\n");
|
|
280
|
+
}
|
|
281
|
+
if (isListItemNode(node)) {
|
|
282
|
+
return getNodeChildren(node).map((child) => this.nodeToDiscordMarkdown(child)).join("");
|
|
283
|
+
}
|
|
284
|
+
if (node.type === "break") {
|
|
285
|
+
return "\n";
|
|
286
|
+
}
|
|
287
|
+
if (node.type === "thematicBreak") {
|
|
288
|
+
return "---";
|
|
289
|
+
}
|
|
290
|
+
const children = getNodeChildren(node);
|
|
291
|
+
if (children.length > 0) {
|
|
292
|
+
return children.map((child) => this.nodeToDiscordMarkdown(child)).join("");
|
|
293
|
+
}
|
|
294
|
+
return getNodeValue(node);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
// src/index.ts
|
|
299
|
+
var DISCORD_API_BASE = "https://discord.com/api/v10";
|
|
300
|
+
var DISCORD_MAX_CONTENT_LENGTH = 2e3;
|
|
301
|
+
var DiscordAdapter = class {
|
|
302
|
+
name = "discord";
|
|
303
|
+
userName;
|
|
304
|
+
botUserId;
|
|
305
|
+
botToken;
|
|
306
|
+
publicKey;
|
|
307
|
+
applicationId;
|
|
308
|
+
mentionRoleIds;
|
|
309
|
+
chat = null;
|
|
310
|
+
logger;
|
|
311
|
+
formatConverter = new DiscordFormatConverter();
|
|
312
|
+
constructor(config) {
|
|
313
|
+
this.botToken = config.botToken;
|
|
314
|
+
this.publicKey = config.publicKey.trim().toLowerCase();
|
|
315
|
+
this.applicationId = config.applicationId;
|
|
316
|
+
this.mentionRoleIds = config.mentionRoleIds ?? [];
|
|
317
|
+
this.botUserId = config.applicationId;
|
|
318
|
+
this.logger = config.logger;
|
|
319
|
+
this.userName = config.userName ?? "bot";
|
|
320
|
+
if (!/^[0-9a-f]{64}$/.test(this.publicKey)) {
|
|
321
|
+
this.logger.error("Invalid Discord public key format", {
|
|
322
|
+
length: this.publicKey.length,
|
|
323
|
+
isHex: /^[0-9a-f]+$/.test(this.publicKey)
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
async initialize(chat) {
|
|
328
|
+
this.chat = chat;
|
|
329
|
+
this.logger.info("Discord adapter initialized", {
|
|
330
|
+
applicationId: this.applicationId,
|
|
331
|
+
// Log full public key for debugging - it's public, not secret
|
|
332
|
+
publicKey: this.publicKey
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Handle incoming Discord webhook (HTTP Interactions or forwarded Gateway events).
|
|
337
|
+
*/
|
|
338
|
+
async handleWebhook(request, options) {
|
|
339
|
+
const bodyBuffer = await request.arrayBuffer();
|
|
340
|
+
const bodyBytes = new Uint8Array(bodyBuffer);
|
|
341
|
+
const body = new TextDecoder().decode(bodyBytes);
|
|
342
|
+
const gatewayToken = request.headers.get("x-discord-gateway-token");
|
|
343
|
+
if (gatewayToken) {
|
|
344
|
+
if (gatewayToken !== this.botToken) {
|
|
345
|
+
this.logger.warn("Invalid gateway token");
|
|
346
|
+
return new Response("Invalid gateway token", { status: 401 });
|
|
347
|
+
}
|
|
348
|
+
this.logger.info("Discord forwarded Gateway event received");
|
|
349
|
+
try {
|
|
350
|
+
const event = JSON.parse(body);
|
|
351
|
+
return this.handleForwardedGatewayEvent(event, options);
|
|
352
|
+
} catch {
|
|
353
|
+
return new Response("Invalid JSON", { status: 400 });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
this.logger.info("Discord webhook received", {
|
|
357
|
+
bodyLength: body.length,
|
|
358
|
+
bodyBytesLength: bodyBytes.length,
|
|
359
|
+
hasSignature: !!request.headers.get("x-signature-ed25519"),
|
|
360
|
+
hasTimestamp: !!request.headers.get("x-signature-timestamp")
|
|
361
|
+
});
|
|
362
|
+
const signature = request.headers.get("x-signature-ed25519");
|
|
363
|
+
const timestamp = request.headers.get("x-signature-timestamp");
|
|
364
|
+
const signatureValid = await this.verifySignature(
|
|
365
|
+
bodyBytes,
|
|
366
|
+
signature,
|
|
367
|
+
timestamp
|
|
368
|
+
);
|
|
369
|
+
if (!signatureValid) {
|
|
370
|
+
this.logger.warn("Discord signature verification failed, returning 401");
|
|
371
|
+
return new Response("Invalid signature", { status: 401 });
|
|
372
|
+
}
|
|
373
|
+
this.logger.info("Discord signature verification passed");
|
|
374
|
+
let interaction;
|
|
375
|
+
try {
|
|
376
|
+
interaction = JSON.parse(body);
|
|
377
|
+
} catch {
|
|
378
|
+
return new Response("Invalid JSON", { status: 400 });
|
|
379
|
+
}
|
|
380
|
+
this.logger.info("Discord interaction parsed", {
|
|
381
|
+
type: interaction.type,
|
|
382
|
+
typeIsPing: interaction.type === InteractionType.Ping,
|
|
383
|
+
expectedPingType: InteractionType.Ping,
|
|
384
|
+
id: interaction.id
|
|
385
|
+
});
|
|
386
|
+
if (interaction.type === InteractionType.Ping) {
|
|
387
|
+
const responseBody = JSON.stringify({
|
|
388
|
+
type: DiscordInteractionResponseType.PONG
|
|
389
|
+
});
|
|
390
|
+
this.logger.info("Discord PING received, responding with PONG", {
|
|
391
|
+
responseBody,
|
|
392
|
+
responseType: DiscordInteractionResponseType.PONG
|
|
393
|
+
});
|
|
394
|
+
return new Response(responseBody, {
|
|
395
|
+
status: 200,
|
|
396
|
+
headers: { "Content-Type": "application/json" }
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
if (interaction.type === InteractionType.MessageComponent) {
|
|
400
|
+
this.handleComponentInteraction(interaction, options);
|
|
401
|
+
return this.respondToInteraction({
|
|
402
|
+
type: 6 /* DeferredUpdateMessage */
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
if (interaction.type === InteractionType.ApplicationCommand) {
|
|
406
|
+
return this.respondToInteraction({
|
|
407
|
+
type: 5 /* DeferredChannelMessageWithSource */
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
return new Response("Unknown interaction type", { status: 400 });
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Verify Discord's Ed25519 signature using official discord-interactions library.
|
|
414
|
+
*/
|
|
415
|
+
async verifySignature(bodyBytes, signature, timestamp) {
|
|
416
|
+
if (!signature || !timestamp) {
|
|
417
|
+
this.logger.warn(
|
|
418
|
+
"Discord signature verification failed: missing headers",
|
|
419
|
+
{
|
|
420
|
+
hasSignature: !!signature,
|
|
421
|
+
hasTimestamp: !!timestamp
|
|
422
|
+
}
|
|
423
|
+
);
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
this.logger.info("Discord signature verification attempt", {
|
|
428
|
+
bodyBytesLength: bodyBytes.length,
|
|
429
|
+
signatureLength: signature.length,
|
|
430
|
+
timestampLength: timestamp.length,
|
|
431
|
+
publicKeyLength: this.publicKey.length,
|
|
432
|
+
timestamp,
|
|
433
|
+
signaturePrefix: signature.slice(0, 16),
|
|
434
|
+
publicKey: this.publicKey
|
|
435
|
+
});
|
|
436
|
+
const isValid = await verifyKey(
|
|
437
|
+
bodyBytes,
|
|
438
|
+
signature,
|
|
439
|
+
timestamp,
|
|
440
|
+
this.publicKey
|
|
441
|
+
);
|
|
442
|
+
if (!isValid) {
|
|
443
|
+
const bodyString = new TextDecoder().decode(bodyBytes);
|
|
444
|
+
this.logger.warn(
|
|
445
|
+
"Discord signature verification failed: invalid signature",
|
|
446
|
+
{
|
|
447
|
+
publicKeyLength: this.publicKey.length,
|
|
448
|
+
signatureLength: signature.length,
|
|
449
|
+
publicKeyPrefix: this.publicKey.slice(0, 8),
|
|
450
|
+
publicKeySuffix: this.publicKey.slice(-8),
|
|
451
|
+
timestamp,
|
|
452
|
+
bodyLength: bodyBytes.length,
|
|
453
|
+
bodyPrefix: bodyString.slice(0, 50)
|
|
454
|
+
}
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
return isValid;
|
|
458
|
+
} catch (error) {
|
|
459
|
+
this.logger.warn("Discord signature verification failed: exception", {
|
|
460
|
+
error
|
|
461
|
+
});
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Create a JSON response for Discord interactions.
|
|
467
|
+
*/
|
|
468
|
+
respondToInteraction(response) {
|
|
469
|
+
return new Response(JSON.stringify(response), {
|
|
470
|
+
headers: { "Content-Type": "application/json" }
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Handle MESSAGE_COMPONENT interactions (button clicks).
|
|
475
|
+
*/
|
|
476
|
+
handleComponentInteraction(interaction, options) {
|
|
477
|
+
if (!this.chat) {
|
|
478
|
+
this.logger.warn("Chat instance not initialized, ignoring interaction");
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const customId = interaction.data?.custom_id;
|
|
482
|
+
if (!customId) {
|
|
483
|
+
this.logger.warn("No custom_id in component interaction");
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const user = interaction.member?.user || interaction.user;
|
|
487
|
+
if (!user) {
|
|
488
|
+
this.logger.warn("No user in component interaction");
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const channelId = interaction.channel_id;
|
|
492
|
+
const guildId = interaction.guild_id || "@me";
|
|
493
|
+
const messageId = interaction.message?.id;
|
|
494
|
+
if (!channelId || !messageId) {
|
|
495
|
+
this.logger.warn("Missing channel_id or message_id in interaction");
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const threadId = this.encodeThreadId({
|
|
499
|
+
guildId,
|
|
500
|
+
channelId
|
|
501
|
+
});
|
|
502
|
+
const actionEvent = {
|
|
503
|
+
actionId: customId,
|
|
504
|
+
value: customId,
|
|
505
|
+
// Discord custom_id often contains the value
|
|
506
|
+
user: {
|
|
507
|
+
userId: user.id,
|
|
508
|
+
userName: user.username,
|
|
509
|
+
fullName: user.global_name || user.username,
|
|
510
|
+
isBot: user.bot ?? false,
|
|
511
|
+
isMe: false
|
|
512
|
+
},
|
|
513
|
+
messageId,
|
|
514
|
+
threadId,
|
|
515
|
+
adapter: this,
|
|
516
|
+
raw: interaction
|
|
517
|
+
};
|
|
518
|
+
this.logger.debug("Processing Discord button action", {
|
|
519
|
+
actionId: customId,
|
|
520
|
+
messageId,
|
|
521
|
+
threadId
|
|
522
|
+
});
|
|
523
|
+
this.chat.processAction(actionEvent, options);
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Handle a forwarded Gateway event received via webhook.
|
|
527
|
+
*/
|
|
528
|
+
async handleForwardedGatewayEvent(event, options) {
|
|
529
|
+
this.logger.info("Processing forwarded Gateway event", {
|
|
530
|
+
type: event.type,
|
|
531
|
+
timestamp: event.timestamp
|
|
532
|
+
});
|
|
533
|
+
switch (event.type) {
|
|
534
|
+
case "GATEWAY_MESSAGE_CREATE":
|
|
535
|
+
await this.handleForwardedMessage(
|
|
536
|
+
event.data,
|
|
537
|
+
options
|
|
538
|
+
);
|
|
539
|
+
break;
|
|
540
|
+
case "GATEWAY_MESSAGE_REACTION_ADD":
|
|
541
|
+
await this.handleForwardedReaction(
|
|
542
|
+
event.data,
|
|
543
|
+
true,
|
|
544
|
+
options
|
|
545
|
+
);
|
|
546
|
+
break;
|
|
547
|
+
case "GATEWAY_MESSAGE_REACTION_REMOVE":
|
|
548
|
+
await this.handleForwardedReaction(
|
|
549
|
+
event.data,
|
|
550
|
+
false,
|
|
551
|
+
options
|
|
552
|
+
);
|
|
553
|
+
break;
|
|
554
|
+
default:
|
|
555
|
+
this.logger.debug("Forwarded Gateway event (no handler)", {
|
|
556
|
+
type: event.type
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
560
|
+
status: 200,
|
|
561
|
+
headers: { "Content-Type": "application/json" }
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Handle a forwarded MESSAGE_CREATE event.
|
|
566
|
+
*/
|
|
567
|
+
async handleForwardedMessage(data, _options) {
|
|
568
|
+
if (!this.chat) return;
|
|
569
|
+
const guildId = data.guild_id || "@me";
|
|
570
|
+
const channelId = data.channel_id;
|
|
571
|
+
let discordThreadId;
|
|
572
|
+
let parentChannelId = channelId;
|
|
573
|
+
if (data.thread) {
|
|
574
|
+
discordThreadId = data.thread.id;
|
|
575
|
+
parentChannelId = data.thread.parent_id;
|
|
576
|
+
} else if (data.channel_type === 11 || data.channel_type === 12) {
|
|
577
|
+
try {
|
|
578
|
+
const response = await this.discordFetch(
|
|
579
|
+
`/channels/${channelId}`,
|
|
580
|
+
"GET"
|
|
581
|
+
);
|
|
582
|
+
const channel = await response.json();
|
|
583
|
+
if (channel.parent_id) {
|
|
584
|
+
discordThreadId = channelId;
|
|
585
|
+
parentChannelId = channel.parent_id;
|
|
586
|
+
this.logger.debug("Fetched thread parent for forwarded message", {
|
|
587
|
+
threadId: channelId,
|
|
588
|
+
parentId: channel.parent_id
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
} catch (error) {
|
|
592
|
+
this.logger.error("Failed to fetch thread parent", {
|
|
593
|
+
error: String(error),
|
|
594
|
+
channelId
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const isUserMentioned = data.is_mention || data.mentions.some((m) => m.id === this.applicationId);
|
|
599
|
+
const isRoleMentioned = this.mentionRoleIds.length > 0 && data.mention_roles?.some(
|
|
600
|
+
(roleId) => this.mentionRoleIds.includes(roleId)
|
|
601
|
+
);
|
|
602
|
+
const isMentioned = isUserMentioned || isRoleMentioned;
|
|
603
|
+
if (!discordThreadId && isMentioned) {
|
|
604
|
+
try {
|
|
605
|
+
const newThread = await this.createDiscordThread(channelId, data.id);
|
|
606
|
+
discordThreadId = newThread.id;
|
|
607
|
+
this.logger.debug("Created Discord thread for forwarded mention", {
|
|
608
|
+
channelId,
|
|
609
|
+
messageId: data.id,
|
|
610
|
+
threadId: newThread.id
|
|
611
|
+
});
|
|
612
|
+
} catch (error) {
|
|
613
|
+
this.logger.error("Failed to create Discord thread for mention", {
|
|
614
|
+
error: String(error),
|
|
615
|
+
messageId: data.id
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const threadId = this.encodeThreadId({
|
|
620
|
+
guildId,
|
|
621
|
+
channelId: parentChannelId,
|
|
622
|
+
threadId: discordThreadId
|
|
623
|
+
});
|
|
624
|
+
const chatMessage = {
|
|
625
|
+
id: data.id,
|
|
626
|
+
threadId,
|
|
627
|
+
text: data.content,
|
|
628
|
+
formatted: this.formatConverter.toAst(data.content),
|
|
629
|
+
author: {
|
|
630
|
+
userId: data.author.id,
|
|
631
|
+
userName: data.author.username,
|
|
632
|
+
fullName: data.author.global_name || data.author.username,
|
|
633
|
+
isBot: data.author.bot === true,
|
|
634
|
+
// Discord returns null for non-bots
|
|
635
|
+
isMe: data.author.id === this.applicationId
|
|
636
|
+
},
|
|
637
|
+
metadata: {
|
|
638
|
+
dateSent: new Date(data.timestamp),
|
|
639
|
+
edited: false
|
|
640
|
+
},
|
|
641
|
+
attachments: data.attachments.map((a) => ({
|
|
642
|
+
type: this.getAttachmentType(a.content_type),
|
|
643
|
+
url: a.url,
|
|
644
|
+
name: a.filename,
|
|
645
|
+
mimeType: a.content_type,
|
|
646
|
+
size: a.size
|
|
647
|
+
})),
|
|
648
|
+
raw: data,
|
|
649
|
+
isMention: isMentioned
|
|
650
|
+
};
|
|
651
|
+
try {
|
|
652
|
+
await this.chat.handleIncomingMessage(this, threadId, chatMessage);
|
|
653
|
+
} catch (error) {
|
|
654
|
+
this.logger.error("Error handling forwarded message", {
|
|
655
|
+
error: String(error),
|
|
656
|
+
messageId: data.id
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Handle a forwarded REACTION_ADD or REACTION_REMOVE event.
|
|
662
|
+
*/
|
|
663
|
+
async handleForwardedReaction(data, added, _options) {
|
|
664
|
+
if (!this.chat) return;
|
|
665
|
+
const guildId = data.guild_id || "@me";
|
|
666
|
+
const channelId = data.channel_id;
|
|
667
|
+
const threadId = this.encodeThreadId({
|
|
668
|
+
guildId,
|
|
669
|
+
channelId
|
|
670
|
+
});
|
|
671
|
+
const emojiName = data.emoji.name || "unknown";
|
|
672
|
+
const normalizedEmoji = this.normalizeDiscordEmoji(emojiName);
|
|
673
|
+
const userInfo = data.user ?? data.member?.user;
|
|
674
|
+
if (!userInfo) {
|
|
675
|
+
this.logger.warn("Reaction event missing user info", { data });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const reactionEvent = {
|
|
679
|
+
adapter: this,
|
|
680
|
+
threadId,
|
|
681
|
+
messageId: data.message_id,
|
|
682
|
+
emoji: normalizedEmoji,
|
|
683
|
+
rawEmoji: data.emoji.id ? `<:${emojiName}:${data.emoji.id}>` : emojiName,
|
|
684
|
+
added,
|
|
685
|
+
user: {
|
|
686
|
+
userId: userInfo.id,
|
|
687
|
+
userName: userInfo.username,
|
|
688
|
+
fullName: userInfo.username,
|
|
689
|
+
isBot: userInfo.bot === true,
|
|
690
|
+
// Discord returns null for non-bots
|
|
691
|
+
isMe: userInfo.id === this.applicationId
|
|
692
|
+
},
|
|
693
|
+
raw: data
|
|
694
|
+
};
|
|
695
|
+
this.chat.processReaction(reactionEvent);
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Post a message to a Discord channel or thread.
|
|
699
|
+
*/
|
|
700
|
+
async postMessage(threadId, message) {
|
|
701
|
+
let { channelId, threadId: discordThreadId } = this.decodeThreadId(threadId);
|
|
702
|
+
const actualThreadId = threadId;
|
|
703
|
+
if (discordThreadId) {
|
|
704
|
+
channelId = discordThreadId;
|
|
705
|
+
}
|
|
706
|
+
const payload = {};
|
|
707
|
+
const embeds = [];
|
|
708
|
+
const components = [];
|
|
709
|
+
const card = extractCard(message);
|
|
710
|
+
if (card) {
|
|
711
|
+
const cardPayload = cardToDiscordPayload(card);
|
|
712
|
+
embeds.push(...cardPayload.embeds);
|
|
713
|
+
components.push(...cardPayload.components);
|
|
714
|
+
payload.content = this.truncateContent(cardToFallbackText(card));
|
|
715
|
+
} else {
|
|
716
|
+
payload.content = this.truncateContent(
|
|
717
|
+
convertEmojiPlaceholders2(
|
|
718
|
+
this.formatConverter.renderPostable(message),
|
|
719
|
+
"discord"
|
|
720
|
+
)
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
if (embeds.length > 0) {
|
|
724
|
+
payload.embeds = embeds;
|
|
725
|
+
}
|
|
726
|
+
if (components.length > 0) {
|
|
727
|
+
payload.components = components;
|
|
728
|
+
}
|
|
729
|
+
const files = extractFiles(message);
|
|
730
|
+
if (files.length > 0) {
|
|
731
|
+
return this.postMessageWithFiles(
|
|
732
|
+
channelId,
|
|
733
|
+
actualThreadId,
|
|
734
|
+
payload,
|
|
735
|
+
files
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
this.logger.debug("Discord API: POST message", {
|
|
739
|
+
channelId,
|
|
740
|
+
contentLength: payload.content?.length || 0,
|
|
741
|
+
embedCount: embeds.length,
|
|
742
|
+
componentCount: components.length
|
|
743
|
+
});
|
|
744
|
+
const response = await this.discordFetch(
|
|
745
|
+
`/channels/${channelId}/messages`,
|
|
746
|
+
"POST",
|
|
747
|
+
payload
|
|
748
|
+
);
|
|
749
|
+
const result = await response.json();
|
|
750
|
+
this.logger.debug("Discord API: POST message response", {
|
|
751
|
+
messageId: result.id
|
|
752
|
+
});
|
|
753
|
+
return {
|
|
754
|
+
id: result.id,
|
|
755
|
+
threadId: actualThreadId,
|
|
756
|
+
raw: result
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Create a Discord thread from a message.
|
|
761
|
+
*/
|
|
762
|
+
async createDiscordThread(channelId, messageId) {
|
|
763
|
+
const threadName = `Thread ${(/* @__PURE__ */ new Date()).toLocaleString()}`;
|
|
764
|
+
this.logger.debug("Discord API: POST thread", {
|
|
765
|
+
channelId,
|
|
766
|
+
messageId,
|
|
767
|
+
threadName
|
|
768
|
+
});
|
|
769
|
+
const response = await this.discordFetch(
|
|
770
|
+
`/channels/${channelId}/messages/${messageId}/threads`,
|
|
771
|
+
"POST",
|
|
772
|
+
{
|
|
773
|
+
name: threadName,
|
|
774
|
+
auto_archive_duration: 1440
|
|
775
|
+
// 24 hours
|
|
776
|
+
}
|
|
777
|
+
);
|
|
778
|
+
const result = await response.json();
|
|
779
|
+
this.logger.debug("Discord API: POST thread response", {
|
|
780
|
+
threadId: result.id,
|
|
781
|
+
threadName: result.name
|
|
782
|
+
});
|
|
783
|
+
return result;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Truncate content to Discord's maximum length.
|
|
787
|
+
*/
|
|
788
|
+
truncateContent(content) {
|
|
789
|
+
if (content.length <= DISCORD_MAX_CONTENT_LENGTH) {
|
|
790
|
+
return content;
|
|
791
|
+
}
|
|
792
|
+
return `${content.slice(0, DISCORD_MAX_CONTENT_LENGTH - 3)}...`;
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Post a message with file attachments.
|
|
796
|
+
*/
|
|
797
|
+
async postMessageWithFiles(channelId, threadId, payload, files) {
|
|
798
|
+
const formData = new FormData();
|
|
799
|
+
formData.append("payload_json", JSON.stringify(payload));
|
|
800
|
+
for (let i = 0; i < files.length; i++) {
|
|
801
|
+
const file = files[i];
|
|
802
|
+
if (!file) continue;
|
|
803
|
+
const buffer = await toBuffer(file.data, {
|
|
804
|
+
platform: "discord"
|
|
805
|
+
});
|
|
806
|
+
if (!buffer) continue;
|
|
807
|
+
const blob = new Blob([new Uint8Array(buffer)], {
|
|
808
|
+
type: file.mimeType || "application/octet-stream"
|
|
809
|
+
});
|
|
810
|
+
formData.append(`files[${i}]`, blob, file.filename);
|
|
811
|
+
}
|
|
812
|
+
const response = await fetch(
|
|
813
|
+
`${DISCORD_API_BASE}/channels/${channelId}/messages`,
|
|
814
|
+
{
|
|
815
|
+
method: "POST",
|
|
816
|
+
headers: {
|
|
817
|
+
Authorization: `Bot ${this.botToken}`
|
|
818
|
+
},
|
|
819
|
+
body: formData
|
|
820
|
+
}
|
|
821
|
+
);
|
|
822
|
+
if (!response.ok) {
|
|
823
|
+
const error = await response.text();
|
|
824
|
+
throw new NetworkError(
|
|
825
|
+
"discord",
|
|
826
|
+
`Failed to post message: ${response.status} ${error}`
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
const result = await response.json();
|
|
830
|
+
return {
|
|
831
|
+
id: result.id,
|
|
832
|
+
threadId,
|
|
833
|
+
raw: result
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Edit an existing Discord message.
|
|
838
|
+
*/
|
|
839
|
+
async editMessage(threadId, messageId, message) {
|
|
840
|
+
const { channelId, threadId: discordThreadId } = this.decodeThreadId(threadId);
|
|
841
|
+
const targetChannelId = discordThreadId || channelId;
|
|
842
|
+
const payload = {};
|
|
843
|
+
const embeds = [];
|
|
844
|
+
const components = [];
|
|
845
|
+
const card = extractCard(message);
|
|
846
|
+
if (card) {
|
|
847
|
+
const cardPayload = cardToDiscordPayload(card);
|
|
848
|
+
embeds.push(...cardPayload.embeds);
|
|
849
|
+
components.push(...cardPayload.components);
|
|
850
|
+
payload.content = this.truncateContent(cardToFallbackText(card));
|
|
851
|
+
} else {
|
|
852
|
+
payload.content = this.truncateContent(
|
|
853
|
+
convertEmojiPlaceholders2(
|
|
854
|
+
this.formatConverter.renderPostable(message),
|
|
855
|
+
"discord"
|
|
856
|
+
)
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
if (embeds.length > 0) {
|
|
860
|
+
payload.embeds = embeds;
|
|
861
|
+
}
|
|
862
|
+
if (components.length > 0) {
|
|
863
|
+
payload.components = components;
|
|
864
|
+
}
|
|
865
|
+
this.logger.debug("Discord API: PATCH message", {
|
|
866
|
+
channelId: targetChannelId,
|
|
867
|
+
messageId,
|
|
868
|
+
contentLength: payload.content?.length || 0
|
|
869
|
+
});
|
|
870
|
+
const response = await this.discordFetch(
|
|
871
|
+
`/channels/${targetChannelId}/messages/${messageId}`,
|
|
872
|
+
"PATCH",
|
|
873
|
+
payload
|
|
874
|
+
);
|
|
875
|
+
const result = await response.json();
|
|
876
|
+
this.logger.debug("Discord API: PATCH message response", {
|
|
877
|
+
messageId: result.id
|
|
878
|
+
});
|
|
879
|
+
return {
|
|
880
|
+
id: result.id,
|
|
881
|
+
threadId,
|
|
882
|
+
raw: result
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Delete a Discord message.
|
|
887
|
+
*/
|
|
888
|
+
async deleteMessage(threadId, messageId) {
|
|
889
|
+
const { channelId } = this.decodeThreadId(threadId);
|
|
890
|
+
this.logger.debug("Discord API: DELETE message", {
|
|
891
|
+
channelId,
|
|
892
|
+
messageId
|
|
893
|
+
});
|
|
894
|
+
await this.discordFetch(
|
|
895
|
+
`/channels/${channelId}/messages/${messageId}`,
|
|
896
|
+
"DELETE"
|
|
897
|
+
);
|
|
898
|
+
this.logger.debug("Discord API: DELETE message response", { ok: true });
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Add a reaction to a Discord message.
|
|
902
|
+
*/
|
|
903
|
+
async addReaction(threadId, messageId, emoji) {
|
|
904
|
+
const { channelId } = this.decodeThreadId(threadId);
|
|
905
|
+
const emojiEncoded = this.encodeEmoji(emoji);
|
|
906
|
+
this.logger.debug("Discord API: PUT reaction", {
|
|
907
|
+
channelId,
|
|
908
|
+
messageId,
|
|
909
|
+
emoji: emojiEncoded
|
|
910
|
+
});
|
|
911
|
+
await this.discordFetch(
|
|
912
|
+
`/channels/${channelId}/messages/${messageId}/reactions/${emojiEncoded}/@me`,
|
|
913
|
+
"PUT"
|
|
914
|
+
);
|
|
915
|
+
this.logger.debug("Discord API: PUT reaction response", { ok: true });
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Remove a reaction from a Discord message.
|
|
919
|
+
*/
|
|
920
|
+
async removeReaction(threadId, messageId, emoji) {
|
|
921
|
+
const { channelId } = this.decodeThreadId(threadId);
|
|
922
|
+
const emojiEncoded = this.encodeEmoji(emoji);
|
|
923
|
+
this.logger.debug("Discord API: DELETE reaction", {
|
|
924
|
+
channelId,
|
|
925
|
+
messageId,
|
|
926
|
+
emoji: emojiEncoded
|
|
927
|
+
});
|
|
928
|
+
await this.discordFetch(
|
|
929
|
+
`/channels/${channelId}/messages/${messageId}/reactions/${emojiEncoded}/@me`,
|
|
930
|
+
"DELETE"
|
|
931
|
+
);
|
|
932
|
+
this.logger.debug("Discord API: DELETE reaction response", { ok: true });
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Encode an emoji for use in Discord API URLs.
|
|
936
|
+
*/
|
|
937
|
+
encodeEmoji(emoji) {
|
|
938
|
+
const emojiStr = defaultEmojiResolver.toDiscord ? defaultEmojiResolver.toDiscord(emoji) : String(emoji);
|
|
939
|
+
return encodeURIComponent(emojiStr);
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Start typing indicator in a Discord channel or thread.
|
|
943
|
+
*/
|
|
944
|
+
async startTyping(threadId) {
|
|
945
|
+
const { channelId, threadId: discordThreadId } = this.decodeThreadId(threadId);
|
|
946
|
+
const targetChannelId = discordThreadId || channelId;
|
|
947
|
+
this.logger.debug("Discord API: POST typing", {
|
|
948
|
+
channelId: targetChannelId
|
|
949
|
+
});
|
|
950
|
+
await this.discordFetch(`/channels/${targetChannelId}/typing`, "POST");
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Fetch messages from a Discord channel or thread.
|
|
954
|
+
* If threadId includes a Discord thread ID, fetches from that thread channel.
|
|
955
|
+
*/
|
|
956
|
+
async fetchMessages(threadId, options = {}) {
|
|
957
|
+
const { channelId, threadId: discordThreadId } = this.decodeThreadId(threadId);
|
|
958
|
+
const targetChannelId = discordThreadId || channelId;
|
|
959
|
+
const limit = options.limit || 50;
|
|
960
|
+
const direction = options.direction ?? "backward";
|
|
961
|
+
const params = new URLSearchParams();
|
|
962
|
+
params.set("limit", String(limit));
|
|
963
|
+
if (options.cursor) {
|
|
964
|
+
if (direction === "backward") {
|
|
965
|
+
params.set("before", options.cursor);
|
|
966
|
+
} else {
|
|
967
|
+
params.set("after", options.cursor);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
this.logger.debug("Discord API: GET messages", {
|
|
971
|
+
channelId: targetChannelId,
|
|
972
|
+
limit,
|
|
973
|
+
direction,
|
|
974
|
+
cursor: options.cursor
|
|
975
|
+
});
|
|
976
|
+
const response = await this.discordFetch(
|
|
977
|
+
`/channels/${targetChannelId}/messages?${params.toString()}`,
|
|
978
|
+
"GET"
|
|
979
|
+
);
|
|
980
|
+
const rawMessages = await response.json();
|
|
981
|
+
this.logger.debug("Discord API: GET messages response", {
|
|
982
|
+
messageCount: rawMessages.length
|
|
983
|
+
});
|
|
984
|
+
const sortedMessages = [...rawMessages].reverse();
|
|
985
|
+
const messages = sortedMessages.map(
|
|
986
|
+
(msg) => this.parseDiscordMessage(msg, threadId)
|
|
987
|
+
);
|
|
988
|
+
let nextCursor;
|
|
989
|
+
if (rawMessages.length === limit) {
|
|
990
|
+
if (direction === "backward") {
|
|
991
|
+
const oldest = rawMessages[rawMessages.length - 1];
|
|
992
|
+
nextCursor = oldest?.id;
|
|
993
|
+
} else {
|
|
994
|
+
const newest = rawMessages[0];
|
|
995
|
+
nextCursor = newest?.id;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
return {
|
|
999
|
+
messages,
|
|
1000
|
+
nextCursor
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Fetch thread/channel information.
|
|
1005
|
+
*/
|
|
1006
|
+
async fetchThread(threadId) {
|
|
1007
|
+
const { channelId, guildId } = this.decodeThreadId(threadId);
|
|
1008
|
+
this.logger.debug("Discord API: GET channel", { channelId });
|
|
1009
|
+
const response = await this.discordFetch(`/channels/${channelId}`, "GET");
|
|
1010
|
+
const channel = await response.json();
|
|
1011
|
+
return {
|
|
1012
|
+
id: threadId,
|
|
1013
|
+
channelId,
|
|
1014
|
+
channelName: channel.name,
|
|
1015
|
+
isDM: channel.type === ChannelType.DM || channel.type === ChannelType.GroupDM,
|
|
1016
|
+
metadata: {
|
|
1017
|
+
guildId,
|
|
1018
|
+
channelType: channel.type,
|
|
1019
|
+
raw: channel
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Open a DM with a user.
|
|
1025
|
+
*/
|
|
1026
|
+
async openDM(userId) {
|
|
1027
|
+
this.logger.debug("Discord API: POST DM channel", { userId });
|
|
1028
|
+
const response = await this.discordFetch(`/users/@me/channels`, "POST", {
|
|
1029
|
+
recipient_id: userId
|
|
1030
|
+
});
|
|
1031
|
+
const dmChannel = await response.json();
|
|
1032
|
+
this.logger.debug("Discord API: POST DM channel response", {
|
|
1033
|
+
channelId: dmChannel.id
|
|
1034
|
+
});
|
|
1035
|
+
return this.encodeThreadId({
|
|
1036
|
+
guildId: "@me",
|
|
1037
|
+
channelId: dmChannel.id
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Check if a thread is a DM.
|
|
1042
|
+
*/
|
|
1043
|
+
isDM(threadId) {
|
|
1044
|
+
const { guildId } = this.decodeThreadId(threadId);
|
|
1045
|
+
return guildId === "@me";
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Encode platform data into a thread ID string.
|
|
1049
|
+
*/
|
|
1050
|
+
encodeThreadId(platformData) {
|
|
1051
|
+
const threadPart = platformData.threadId ? `:${platformData.threadId}` : "";
|
|
1052
|
+
return `discord:${platformData.guildId}:${platformData.channelId}${threadPart}`;
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Decode thread ID string back to platform data.
|
|
1056
|
+
*/
|
|
1057
|
+
decodeThreadId(threadId) {
|
|
1058
|
+
const parts = threadId.split(":");
|
|
1059
|
+
if (parts.length < 3 || parts[0] !== "discord") {
|
|
1060
|
+
throw new ValidationError(
|
|
1061
|
+
"discord",
|
|
1062
|
+
`Invalid Discord thread ID: ${threadId}`
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
return {
|
|
1066
|
+
guildId: parts[1],
|
|
1067
|
+
channelId: parts[2],
|
|
1068
|
+
threadId: parts[3]
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Parse a Discord message into normalized format.
|
|
1073
|
+
*/
|
|
1074
|
+
parseMessage(raw) {
|
|
1075
|
+
const msg = raw;
|
|
1076
|
+
const guildId = msg.guild_id || "@me";
|
|
1077
|
+
const threadId = this.encodeThreadId({
|
|
1078
|
+
guildId,
|
|
1079
|
+
channelId: msg.channel_id
|
|
1080
|
+
});
|
|
1081
|
+
return this.parseDiscordMessage(msg, threadId);
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Parse a Discord API message into normalized format.
|
|
1085
|
+
*/
|
|
1086
|
+
parseDiscordMessage(msg, threadId) {
|
|
1087
|
+
const author = msg.author;
|
|
1088
|
+
const isBot = author.bot ?? false;
|
|
1089
|
+
const isMe = author.id === this.botUserId;
|
|
1090
|
+
return {
|
|
1091
|
+
id: msg.id,
|
|
1092
|
+
threadId,
|
|
1093
|
+
text: this.formatConverter.extractPlainText(msg.content),
|
|
1094
|
+
formatted: this.formatConverter.toAst(msg.content),
|
|
1095
|
+
raw: msg,
|
|
1096
|
+
author: {
|
|
1097
|
+
userId: author.id,
|
|
1098
|
+
userName: author.username,
|
|
1099
|
+
fullName: author.global_name || author.username,
|
|
1100
|
+
isBot,
|
|
1101
|
+
isMe
|
|
1102
|
+
},
|
|
1103
|
+
metadata: {
|
|
1104
|
+
dateSent: new Date(msg.timestamp),
|
|
1105
|
+
edited: msg.edited_timestamp !== null,
|
|
1106
|
+
editedAt: msg.edited_timestamp ? new Date(msg.edited_timestamp) : void 0
|
|
1107
|
+
},
|
|
1108
|
+
attachments: (msg.attachments || []).map((att) => ({
|
|
1109
|
+
type: this.getAttachmentType(att.content_type),
|
|
1110
|
+
url: att.url,
|
|
1111
|
+
name: att.filename,
|
|
1112
|
+
mimeType: att.content_type,
|
|
1113
|
+
size: att.size,
|
|
1114
|
+
width: att.width ?? void 0,
|
|
1115
|
+
height: att.height ?? void 0
|
|
1116
|
+
}))
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Determine attachment type from MIME type.
|
|
1121
|
+
*/
|
|
1122
|
+
getAttachmentType(mimeType) {
|
|
1123
|
+
if (!mimeType) return "file";
|
|
1124
|
+
if (mimeType.startsWith("image/")) return "image";
|
|
1125
|
+
if (mimeType.startsWith("video/")) return "video";
|
|
1126
|
+
if (mimeType.startsWith("audio/")) return "audio";
|
|
1127
|
+
return "file";
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Render formatted content to Discord markdown.
|
|
1131
|
+
*/
|
|
1132
|
+
renderFormatted(content) {
|
|
1133
|
+
return this.formatConverter.fromAst(content);
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Make a request to the Discord API.
|
|
1137
|
+
*/
|
|
1138
|
+
async discordFetch(path, method, body) {
|
|
1139
|
+
const url = `${DISCORD_API_BASE}${path}`;
|
|
1140
|
+
const headers = {
|
|
1141
|
+
Authorization: `Bot ${this.botToken}`
|
|
1142
|
+
};
|
|
1143
|
+
if (body) {
|
|
1144
|
+
headers["Content-Type"] = "application/json";
|
|
1145
|
+
}
|
|
1146
|
+
const response = await fetch(url, {
|
|
1147
|
+
method,
|
|
1148
|
+
headers,
|
|
1149
|
+
body: body ? JSON.stringify(body) : void 0
|
|
1150
|
+
});
|
|
1151
|
+
if (!response.ok) {
|
|
1152
|
+
const errorText = await response.text();
|
|
1153
|
+
this.logger.error("Discord API error", {
|
|
1154
|
+
path,
|
|
1155
|
+
method,
|
|
1156
|
+
status: response.status,
|
|
1157
|
+
error: errorText
|
|
1158
|
+
});
|
|
1159
|
+
throw new NetworkError(
|
|
1160
|
+
"discord",
|
|
1161
|
+
`Discord API error: ${response.status} ${errorText}`
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
return response;
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Start Gateway WebSocket listener for receiving messages/mentions.
|
|
1168
|
+
* Uses waitUntil to keep the connection alive for the specified duration.
|
|
1169
|
+
*
|
|
1170
|
+
* This is a workaround for serverless environments - the Gateway connection
|
|
1171
|
+
* will stay alive for the duration, listening for messages.
|
|
1172
|
+
*
|
|
1173
|
+
* @param options - Webhook options with waitUntil function
|
|
1174
|
+
* @param durationMs - How long to keep listening (default: 180000ms = 3 minutes)
|
|
1175
|
+
* @param abortSignal - Optional AbortSignal to stop the listener early (e.g., when a new listener starts)
|
|
1176
|
+
* @param webhookUrl - URL to forward Gateway events to (required for webhook forwarding mode)
|
|
1177
|
+
* @returns Response indicating the listener was started
|
|
1178
|
+
*/
|
|
1179
|
+
async startGatewayListener(options, durationMs = 18e4, abortSignal, webhookUrl) {
|
|
1180
|
+
if (!this.chat) {
|
|
1181
|
+
return new Response("Chat instance not initialized", { status: 500 });
|
|
1182
|
+
}
|
|
1183
|
+
if (!options.waitUntil) {
|
|
1184
|
+
return new Response("waitUntil not provided", { status: 500 });
|
|
1185
|
+
}
|
|
1186
|
+
this.logger.info("Starting Discord Gateway listener", {
|
|
1187
|
+
durationMs,
|
|
1188
|
+
webhookUrl: webhookUrl ? "configured" : "not configured"
|
|
1189
|
+
});
|
|
1190
|
+
const listenerPromise = this.runGatewayListener(
|
|
1191
|
+
durationMs,
|
|
1192
|
+
abortSignal,
|
|
1193
|
+
webhookUrl
|
|
1194
|
+
);
|
|
1195
|
+
options.waitUntil(listenerPromise);
|
|
1196
|
+
return new Response(
|
|
1197
|
+
JSON.stringify({
|
|
1198
|
+
status: "listening",
|
|
1199
|
+
durationMs,
|
|
1200
|
+
message: `Gateway listener started, will run for ${durationMs / 1e3} seconds`
|
|
1201
|
+
}),
|
|
1202
|
+
{
|
|
1203
|
+
status: 200,
|
|
1204
|
+
headers: { "Content-Type": "application/json" }
|
|
1205
|
+
}
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Run the Gateway listener for a specified duration.
|
|
1210
|
+
*/
|
|
1211
|
+
async runGatewayListener(durationMs, abortSignal, webhookUrl) {
|
|
1212
|
+
const client = new Client({
|
|
1213
|
+
intents: [
|
|
1214
|
+
GatewayIntentBits.Guilds,
|
|
1215
|
+
GatewayIntentBits.GuildMessages,
|
|
1216
|
+
GatewayIntentBits.MessageContent,
|
|
1217
|
+
GatewayIntentBits.DirectMessages,
|
|
1218
|
+
GatewayIntentBits.GuildMessageReactions,
|
|
1219
|
+
GatewayIntentBits.DirectMessageReactions
|
|
1220
|
+
]
|
|
1221
|
+
});
|
|
1222
|
+
let isShuttingDown = false;
|
|
1223
|
+
if (webhookUrl) {
|
|
1224
|
+
client.on("raw", async (packet) => {
|
|
1225
|
+
if (isShuttingDown) return;
|
|
1226
|
+
if (!packet.t) return;
|
|
1227
|
+
this.logger.info("Discord Gateway forwarding event", {
|
|
1228
|
+
type: packet.t
|
|
1229
|
+
});
|
|
1230
|
+
await this.forwardGatewayEvent(webhookUrl, {
|
|
1231
|
+
type: `GATEWAY_${packet.t}`,
|
|
1232
|
+
timestamp: Date.now(),
|
|
1233
|
+
data: packet.d
|
|
1234
|
+
});
|
|
1235
|
+
});
|
|
1236
|
+
} else {
|
|
1237
|
+
this.setupLegacyGatewayHandlers(client, () => isShuttingDown);
|
|
1238
|
+
}
|
|
1239
|
+
client.on(Events.ClientReady, () => {
|
|
1240
|
+
this.logger.info("Discord Gateway connected", {
|
|
1241
|
+
username: client.user?.username,
|
|
1242
|
+
id: client.user?.id
|
|
1243
|
+
});
|
|
1244
|
+
});
|
|
1245
|
+
client.on(Events.Error, (error) => {
|
|
1246
|
+
this.logger.error("Discord Gateway error", { error: String(error) });
|
|
1247
|
+
});
|
|
1248
|
+
try {
|
|
1249
|
+
await client.login(this.botToken);
|
|
1250
|
+
await new Promise((resolve) => {
|
|
1251
|
+
const timeout = setTimeout(resolve, durationMs);
|
|
1252
|
+
if (abortSignal) {
|
|
1253
|
+
if (abortSignal.aborted) {
|
|
1254
|
+
clearTimeout(timeout);
|
|
1255
|
+
resolve();
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
abortSignal.addEventListener(
|
|
1259
|
+
"abort",
|
|
1260
|
+
() => {
|
|
1261
|
+
this.logger.info(
|
|
1262
|
+
"Discord Gateway listener received abort signal (new listener started)"
|
|
1263
|
+
);
|
|
1264
|
+
clearTimeout(timeout);
|
|
1265
|
+
resolve();
|
|
1266
|
+
},
|
|
1267
|
+
{ once: true }
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
});
|
|
1271
|
+
this.logger.info(
|
|
1272
|
+
"Discord Gateway listener duration elapsed, disconnecting"
|
|
1273
|
+
);
|
|
1274
|
+
} catch (error) {
|
|
1275
|
+
this.logger.error("Discord Gateway listener error", {
|
|
1276
|
+
error: String(error)
|
|
1277
|
+
});
|
|
1278
|
+
} finally {
|
|
1279
|
+
isShuttingDown = true;
|
|
1280
|
+
client.destroy();
|
|
1281
|
+
this.logger.info("Discord Gateway listener stopped");
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Set up legacy Gateway handlers for direct processing (when webhookUrl is not provided).
|
|
1286
|
+
*/
|
|
1287
|
+
setupLegacyGatewayHandlers(client, isShuttingDown) {
|
|
1288
|
+
client.on(Events.MessageCreate, async (message) => {
|
|
1289
|
+
if (isShuttingDown()) {
|
|
1290
|
+
this.logger.debug("Ignoring message - Gateway is shutting down");
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
if (message.author.bot) {
|
|
1294
|
+
this.logger.debug("Ignoring message from bot", {
|
|
1295
|
+
authorId: message.author.id,
|
|
1296
|
+
authorName: message.author.username,
|
|
1297
|
+
isMe: message.author.id === client.user?.id
|
|
1298
|
+
});
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
const isUserMentioned = message.mentions.has(client.user?.id ?? "");
|
|
1302
|
+
const isRoleMentioned = this.mentionRoleIds.length > 0 && message.mentions.roles.some(
|
|
1303
|
+
(role) => this.mentionRoleIds.includes(role.id)
|
|
1304
|
+
);
|
|
1305
|
+
const isMentioned = isUserMentioned || isRoleMentioned;
|
|
1306
|
+
this.logger.info("Discord Gateway message received", {
|
|
1307
|
+
channelId: message.channelId,
|
|
1308
|
+
guildId: message.guildId,
|
|
1309
|
+
authorId: message.author.id,
|
|
1310
|
+
isMentioned,
|
|
1311
|
+
isUserMentioned,
|
|
1312
|
+
isRoleMentioned,
|
|
1313
|
+
content: message.content.slice(0, 100)
|
|
1314
|
+
});
|
|
1315
|
+
await this.handleGatewayMessage(message, isMentioned);
|
|
1316
|
+
});
|
|
1317
|
+
client.on(Events.MessageReactionAdd, async (reaction, user) => {
|
|
1318
|
+
if (isShuttingDown()) {
|
|
1319
|
+
this.logger.debug("Ignoring reaction - Gateway is shutting down");
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
if (user.bot) {
|
|
1323
|
+
this.logger.debug("Ignoring reaction from bot", {
|
|
1324
|
+
userId: user.id,
|
|
1325
|
+
isMe: user.id === client.user?.id
|
|
1326
|
+
});
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
this.logger.info("Discord Gateway reaction added", {
|
|
1330
|
+
emoji: reaction.emoji.name,
|
|
1331
|
+
messageId: reaction.message.id,
|
|
1332
|
+
channelId: reaction.message.channelId,
|
|
1333
|
+
userId: user.id
|
|
1334
|
+
});
|
|
1335
|
+
if (user.username) {
|
|
1336
|
+
await this.handleGatewayReaction(
|
|
1337
|
+
reaction,
|
|
1338
|
+
user,
|
|
1339
|
+
true
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
});
|
|
1343
|
+
client.on(Events.MessageReactionRemove, async (reaction, user) => {
|
|
1344
|
+
if (isShuttingDown()) {
|
|
1345
|
+
this.logger.debug(
|
|
1346
|
+
"Ignoring reaction removal - Gateway is shutting down"
|
|
1347
|
+
);
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
if (user.bot) {
|
|
1351
|
+
this.logger.debug("Ignoring reaction removal from bot", {
|
|
1352
|
+
userId: user.id,
|
|
1353
|
+
isMe: user.id === client.user?.id
|
|
1354
|
+
});
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
this.logger.info("Discord Gateway reaction removed", {
|
|
1358
|
+
emoji: reaction.emoji.name,
|
|
1359
|
+
messageId: reaction.message.id,
|
|
1360
|
+
channelId: reaction.message.channelId,
|
|
1361
|
+
userId: user.id
|
|
1362
|
+
});
|
|
1363
|
+
if (user.username) {
|
|
1364
|
+
await this.handleGatewayReaction(
|
|
1365
|
+
reaction,
|
|
1366
|
+
user,
|
|
1367
|
+
false
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Forward a Gateway event to the webhook endpoint.
|
|
1374
|
+
*/
|
|
1375
|
+
async forwardGatewayEvent(webhookUrl, event) {
|
|
1376
|
+
try {
|
|
1377
|
+
this.logger.debug("Forwarding Gateway event to webhook", {
|
|
1378
|
+
type: event.type,
|
|
1379
|
+
webhookUrl
|
|
1380
|
+
});
|
|
1381
|
+
const response = await fetch(webhookUrl, {
|
|
1382
|
+
method: "POST",
|
|
1383
|
+
headers: {
|
|
1384
|
+
"Content-Type": "application/json",
|
|
1385
|
+
"x-discord-gateway-token": this.botToken
|
|
1386
|
+
},
|
|
1387
|
+
body: JSON.stringify(event)
|
|
1388
|
+
});
|
|
1389
|
+
if (!response.ok) {
|
|
1390
|
+
const errorText = await response.text();
|
|
1391
|
+
this.logger.error("Failed to forward Gateway event", {
|
|
1392
|
+
type: event.type,
|
|
1393
|
+
status: response.status,
|
|
1394
|
+
error: errorText
|
|
1395
|
+
});
|
|
1396
|
+
} else {
|
|
1397
|
+
this.logger.debug("Gateway event forwarded successfully", {
|
|
1398
|
+
type: event.type
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
} catch (error) {
|
|
1402
|
+
this.logger.error("Error forwarding Gateway event", {
|
|
1403
|
+
type: event.type,
|
|
1404
|
+
error: String(error)
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Handle a message received via the Gateway WebSocket.
|
|
1410
|
+
*/
|
|
1411
|
+
async handleGatewayMessage(message, isMentioned) {
|
|
1412
|
+
if (!this.chat) return;
|
|
1413
|
+
const guildId = message.guildId || "@me";
|
|
1414
|
+
const channelId = message.channelId;
|
|
1415
|
+
const isInThread = message.channel.isThread();
|
|
1416
|
+
let discordThreadId;
|
|
1417
|
+
let parentChannelId = channelId;
|
|
1418
|
+
if (isInThread && "parentId" in message.channel && message.channel.parentId) {
|
|
1419
|
+
discordThreadId = channelId;
|
|
1420
|
+
parentChannelId = message.channel.parentId;
|
|
1421
|
+
}
|
|
1422
|
+
if (!discordThreadId && isMentioned) {
|
|
1423
|
+
try {
|
|
1424
|
+
const newThread = await this.createDiscordThread(channelId, message.id);
|
|
1425
|
+
discordThreadId = newThread.id;
|
|
1426
|
+
this.logger.debug("Created Discord thread for incoming mention", {
|
|
1427
|
+
channelId,
|
|
1428
|
+
messageId: message.id,
|
|
1429
|
+
threadId: newThread.id
|
|
1430
|
+
});
|
|
1431
|
+
} catch (error) {
|
|
1432
|
+
this.logger.error("Failed to create Discord thread for mention", {
|
|
1433
|
+
error: String(error),
|
|
1434
|
+
messageId: message.id
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
const threadId = this.encodeThreadId({
|
|
1439
|
+
guildId,
|
|
1440
|
+
channelId: parentChannelId,
|
|
1441
|
+
threadId: discordThreadId
|
|
1442
|
+
});
|
|
1443
|
+
const chatMessage = {
|
|
1444
|
+
id: message.id,
|
|
1445
|
+
threadId,
|
|
1446
|
+
text: message.content,
|
|
1447
|
+
formatted: this.formatConverter.toAst(message.content),
|
|
1448
|
+
author: {
|
|
1449
|
+
userId: message.author.id,
|
|
1450
|
+
userName: message.author.username,
|
|
1451
|
+
fullName: message.author.displayName || message.author.username,
|
|
1452
|
+
isBot: message.author.bot,
|
|
1453
|
+
isMe: false
|
|
1454
|
+
// Gateway messages are never from ourselves (we filter those)
|
|
1455
|
+
},
|
|
1456
|
+
metadata: {
|
|
1457
|
+
dateSent: message.createdAt,
|
|
1458
|
+
edited: message.editedAt !== null,
|
|
1459
|
+
editedAt: message.editedAt ?? void 0
|
|
1460
|
+
},
|
|
1461
|
+
attachments: message.attachments.map((a) => ({
|
|
1462
|
+
type: this.getAttachmentType(a.contentType),
|
|
1463
|
+
url: a.url,
|
|
1464
|
+
name: a.name,
|
|
1465
|
+
mimeType: a.contentType ?? void 0,
|
|
1466
|
+
size: a.size
|
|
1467
|
+
})),
|
|
1468
|
+
raw: {
|
|
1469
|
+
id: message.id,
|
|
1470
|
+
channel_id: channelId,
|
|
1471
|
+
guild_id: guildId,
|
|
1472
|
+
content: message.content,
|
|
1473
|
+
author: {
|
|
1474
|
+
id: message.author.id,
|
|
1475
|
+
username: message.author.username
|
|
1476
|
+
},
|
|
1477
|
+
timestamp: message.createdAt.toISOString()
|
|
1478
|
+
},
|
|
1479
|
+
// Add isMention flag for the chat handlers
|
|
1480
|
+
isMention: isMentioned
|
|
1481
|
+
};
|
|
1482
|
+
try {
|
|
1483
|
+
await this.chat.handleIncomingMessage(this, threadId, chatMessage);
|
|
1484
|
+
} catch (error) {
|
|
1485
|
+
this.logger.error("Error handling Gateway message", {
|
|
1486
|
+
error: String(error),
|
|
1487
|
+
messageId: message.id
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Handle a reaction received via the Gateway WebSocket.
|
|
1493
|
+
*/
|
|
1494
|
+
async handleGatewayReaction(reaction, user, added) {
|
|
1495
|
+
if (!this.chat) return;
|
|
1496
|
+
const guildId = reaction.message.guildId || "@me";
|
|
1497
|
+
const channelId = reaction.message.channelId;
|
|
1498
|
+
const threadId = this.encodeThreadId({
|
|
1499
|
+
guildId,
|
|
1500
|
+
channelId,
|
|
1501
|
+
threadId: void 0
|
|
1502
|
+
});
|
|
1503
|
+
const emojiName = reaction.emoji.name || "unknown";
|
|
1504
|
+
const normalizedEmoji = this.normalizeDiscordEmoji(emojiName);
|
|
1505
|
+
const reactionEvent = {
|
|
1506
|
+
adapter: this,
|
|
1507
|
+
threadId,
|
|
1508
|
+
messageId: reaction.message.id,
|
|
1509
|
+
emoji: normalizedEmoji,
|
|
1510
|
+
rawEmoji: reaction.emoji.id ? `<:${emojiName}:${reaction.emoji.id}>` : emojiName,
|
|
1511
|
+
added,
|
|
1512
|
+
user: {
|
|
1513
|
+
userId: user.id,
|
|
1514
|
+
userName: user.username,
|
|
1515
|
+
fullName: user.username,
|
|
1516
|
+
isBot: user.bot === true,
|
|
1517
|
+
// Match pattern from handleForwardedReaction
|
|
1518
|
+
isMe: user.id === this.applicationId
|
|
1519
|
+
},
|
|
1520
|
+
raw: {
|
|
1521
|
+
emoji: reaction.emoji,
|
|
1522
|
+
message_id: reaction.message.id,
|
|
1523
|
+
channel_id: reaction.message.channelId,
|
|
1524
|
+
guild_id: reaction.message.guildId,
|
|
1525
|
+
user_id: user.id
|
|
1526
|
+
}
|
|
1527
|
+
};
|
|
1528
|
+
this.chat.processReaction(reactionEvent);
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* Normalize a Discord emoji to our standard EmojiValue format.
|
|
1532
|
+
*/
|
|
1533
|
+
normalizeDiscordEmoji(emojiName) {
|
|
1534
|
+
const unicodeToName = {
|
|
1535
|
+
"\u{1F44D}": "thumbs_up",
|
|
1536
|
+
"\u{1F44E}": "thumbs_down",
|
|
1537
|
+
"\u2764\uFE0F": "heart",
|
|
1538
|
+
"\u2764": "heart",
|
|
1539
|
+
"\u{1F525}": "fire",
|
|
1540
|
+
"\u{1F680}": "rocket",
|
|
1541
|
+
"\u{1F64C}": "raised_hands",
|
|
1542
|
+
"\u2705": "check",
|
|
1543
|
+
"\u274C": "x",
|
|
1544
|
+
"\u{1F44B}": "wave",
|
|
1545
|
+
"\u{1F914}": "thinking",
|
|
1546
|
+
"\u{1F60A}": "smile",
|
|
1547
|
+
"\u{1F602}": "laugh",
|
|
1548
|
+
"\u{1F389}": "party",
|
|
1549
|
+
"\u2B50": "star",
|
|
1550
|
+
"\u2728": "sparkles",
|
|
1551
|
+
"\u{1F440}": "eyes",
|
|
1552
|
+
"\u{1F4AF}": "100"
|
|
1553
|
+
};
|
|
1554
|
+
const normalizedName = unicodeToName[emojiName] || emojiName;
|
|
1555
|
+
return getEmoji(normalizedName);
|
|
1556
|
+
}
|
|
1557
|
+
};
|
|
1558
|
+
function createDiscordAdapter(config) {
|
|
1559
|
+
return new DiscordAdapter(config);
|
|
1560
|
+
}
|
|
1561
|
+
export {
|
|
1562
|
+
DiscordAdapter,
|
|
1563
|
+
DiscordFormatConverter,
|
|
1564
|
+
DiscordFormatConverter as DiscordMarkdownConverter,
|
|
1565
|
+
cardToDiscordPayload,
|
|
1566
|
+
cardToFallbackText,
|
|
1567
|
+
createDiscordAdapter
|
|
1568
|
+
};
|
|
1569
|
+
//# sourceMappingURL=index.js.map
|