@copilotkit/channels-discord 0.0.2

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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +308 -0
  3. package/dist/__tests__/ephemeral.test.d.ts +2 -0
  4. package/dist/__tests__/ephemeral.test.d.ts.map +1 -0
  5. package/dist/__tests__/ephemeral.test.js +26 -0
  6. package/dist/__tests__/intents.test.d.ts +2 -0
  7. package/dist/__tests__/intents.test.d.ts.map +1 -0
  8. package/dist/__tests__/intents.test.js +8 -0
  9. package/dist/__tests__/modal-submit.test.d.ts +2 -0
  10. package/dist/__tests__/modal-submit.test.d.ts.map +1 -0
  11. package/dist/__tests__/modal-submit.test.js +26 -0
  12. package/dist/__tests__/reactions.test.d.ts +2 -0
  13. package/dist/__tests__/reactions.test.d.ts.map +1 -0
  14. package/dist/__tests__/reactions.test.js +87 -0
  15. package/dist/adapter.d.ts +118 -0
  16. package/dist/adapter.d.ts.map +1 -0
  17. package/dist/adapter.js +567 -0
  18. package/dist/adapter.test.d.ts +2 -0
  19. package/dist/adapter.test.d.ts.map +1 -0
  20. package/dist/adapter.test.js +343 -0
  21. package/dist/auto-close-streaming.d.ts +61 -0
  22. package/dist/auto-close-streaming.d.ts.map +1 -0
  23. package/dist/auto-close-streaming.js +276 -0
  24. package/dist/auto-close-streaming.test.d.ts +2 -0
  25. package/dist/auto-close-streaming.test.d.ts.map +1 -0
  26. package/dist/auto-close-streaming.test.js +149 -0
  27. package/dist/built-in-context.d.ts +20 -0
  28. package/dist/built-in-context.d.ts.map +1 -0
  29. package/dist/built-in-context.js +30 -0
  30. package/dist/built-in-context.test.d.ts +2 -0
  31. package/dist/built-in-context.test.d.ts.map +1 -0
  32. package/dist/built-in-context.test.js +21 -0
  33. package/dist/built-in-tools.d.ts +4 -0
  34. package/dist/built-in-tools.d.ts.map +1 -0
  35. package/dist/built-in-tools.js +27 -0
  36. package/dist/built-in-tools.test.d.ts +2 -0
  37. package/dist/built-in-tools.test.d.ts.map +1 -0
  38. package/dist/built-in-tools.test.js +30 -0
  39. package/dist/chunked-message-stream.d.ts +94 -0
  40. package/dist/chunked-message-stream.d.ts.map +1 -0
  41. package/dist/chunked-message-stream.js +203 -0
  42. package/dist/chunked-message-stream.test.d.ts +2 -0
  43. package/dist/chunked-message-stream.test.d.ts.map +1 -0
  44. package/dist/chunked-message-stream.test.js +384 -0
  45. package/dist/commands.d.ts +29 -0
  46. package/dist/commands.d.ts.map +1 -0
  47. package/dist/commands.js +193 -0
  48. package/dist/commands.test.d.ts +2 -0
  49. package/dist/commands.test.d.ts.map +1 -0
  50. package/dist/commands.test.js +281 -0
  51. package/dist/conversation-store.d.ts +45 -0
  52. package/dist/conversation-store.d.ts.map +1 -0
  53. package/dist/conversation-store.js +78 -0
  54. package/dist/conversation-store.test.d.ts +2 -0
  55. package/dist/conversation-store.test.d.ts.map +1 -0
  56. package/dist/conversation-store.test.js +106 -0
  57. package/dist/discord-listener.d.ts +88 -0
  58. package/dist/discord-listener.d.ts.map +1 -0
  59. package/dist/discord-listener.js +118 -0
  60. package/dist/discord-listener.test.d.ts +2 -0
  61. package/dist/discord-listener.test.d.ts.map +1 -0
  62. package/dist/discord-listener.test.js +361 -0
  63. package/dist/download-files.d.ts +94 -0
  64. package/dist/download-files.d.ts.map +1 -0
  65. package/dist/download-files.js +145 -0
  66. package/dist/download-files.test.d.ts +2 -0
  67. package/dist/download-files.test.d.ts.map +1 -0
  68. package/dist/download-files.test.js +181 -0
  69. package/dist/event-renderer.d.ts +36 -0
  70. package/dist/event-renderer.d.ts.map +1 -0
  71. package/dist/event-renderer.js +270 -0
  72. package/dist/event-renderer.test.d.ts +2 -0
  73. package/dist/event-renderer.test.d.ts.map +1 -0
  74. package/dist/event-renderer.test.js +160 -0
  75. package/dist/index.d.ts +24 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +17 -0
  78. package/dist/interaction.d.ts +11 -0
  79. package/dist/interaction.d.ts.map +1 -0
  80. package/dist/interaction.js +138 -0
  81. package/dist/interaction.test.d.ts +2 -0
  82. package/dist/interaction.test.d.ts.map +1 -0
  83. package/dist/interaction.test.js +120 -0
  84. package/dist/markdown.d.ts +7 -0
  85. package/dist/markdown.d.ts.map +1 -0
  86. package/dist/markdown.js +66 -0
  87. package/dist/markdown.test.d.ts +2 -0
  88. package/dist/markdown.test.d.ts.map +1 -0
  89. package/dist/markdown.test.js +47 -0
  90. package/dist/message-stream.d.ts +45 -0
  91. package/dist/message-stream.d.ts.map +1 -0
  92. package/dist/message-stream.js +76 -0
  93. package/dist/message-stream.test.d.ts +2 -0
  94. package/dist/message-stream.test.d.ts.map +1 -0
  95. package/dist/message-stream.test.js +162 -0
  96. package/dist/pending-interactions.d.ts +26 -0
  97. package/dist/pending-interactions.d.ts.map +1 -0
  98. package/dist/pending-interactions.js +50 -0
  99. package/dist/pending-interactions.test.d.ts +2 -0
  100. package/dist/pending-interactions.test.d.ts.map +1 -0
  101. package/dist/pending-interactions.test.js +73 -0
  102. package/dist/render/budget.d.ts +28 -0
  103. package/dist/render/budget.d.ts.map +1 -0
  104. package/dist/render/budget.js +49 -0
  105. package/dist/render/budget.test.d.ts +2 -0
  106. package/dist/render/budget.test.d.ts.map +1 -0
  107. package/dist/render/budget.test.js +59 -0
  108. package/dist/render/components-v2.d.ts +16 -0
  109. package/dist/render/components-v2.d.ts.map +1 -0
  110. package/dist/render/components-v2.js +396 -0
  111. package/dist/render/components-v2.test.d.ts +2 -0
  112. package/dist/render/components-v2.test.d.ts.map +1 -0
  113. package/dist/render/components-v2.test.js +395 -0
  114. package/dist/render/modal.d.ts +12 -0
  115. package/dist/render/modal.d.ts.map +1 -0
  116. package/dist/render/modal.js +44 -0
  117. package/dist/types.d.ts +16 -0
  118. package/dist/types.d.ts.map +1 -0
  119. package/dist/types.js +4 -0
  120. package/dist/types.test.d.ts +2 -0
  121. package/dist/types.test.d.ts.map +1 -0
  122. package/dist/types.test.js +12 -0
  123. package/package.json +56 -0
@@ -0,0 +1,567 @@
1
+ import { Client, GatewayIntentBits, MessageFlags, Partials, REST, } from "discord.js";
2
+ import { toPlatformEmoji } from "@copilotkit/channels-ui";
3
+ import { DiscordConversationStore } from "./conversation-store.js";
4
+ import { attachDiscordListener } from "./discord-listener.js";
5
+ import { createRunRenderer } from "./event-renderer.js";
6
+ import { decodeInteraction, decodeModalSubmit } from "./interaction.js";
7
+ import { PendingInteractions } from "./pending-interactions.js";
8
+ import { renderComponents, renderDiscordMessage, } from "./render/components-v2.js";
9
+ import { renderDiscordModal } from "./render/modal.js";
10
+ import { registerCommands as putCommands } from "./commands.js";
11
+ import { ChunkedMessageStream, STREAM_PLACEHOLDERS, } from "./chunked-message-stream.js";
12
+ import { discordMarkdown } from "./markdown.js";
13
+ import { autoCloseOpenMarkdown } from "./auto-close-streaming.js";
14
+ /**
15
+ * Default Gateway intents for the bot.
16
+ *
17
+ * - `Guilds`, `GuildMessages`, `MessageContent`, `DirectMessages` — message ingress.
18
+ * - `GuildMembers` — privileged; backs `lookup_discord_user` / `thread.lookupUser`.
19
+ * Must be toggled on in the Discord Developer Portal (Bot → Privileged Gateway Intents).
20
+ * - `GuildMessageReactions` — non-privileged; no portal toggle needed.
21
+ * Required to receive reaction add/remove events in guild channels.
22
+ * - `DirectMessageReactions` — non-privileged; required to receive reaction
23
+ * add/remove events in DMs. Distinct from `GuildMessageReactions` in discord.js v14.
24
+ */
25
+ export const DISCORD_DEFAULT_INTENTS = [
26
+ GatewayIntentBits.Guilds,
27
+ GatewayIntentBits.GuildMessages,
28
+ GatewayIntentBits.MessageContent,
29
+ GatewayIntentBits.DirectMessages,
30
+ // Privileged intent — required for `guild.members.search` in
31
+ // `lookupUser`. Like MessageContent, it must be enabled in the
32
+ // Discord Developer Portal for the bot application.
33
+ GatewayIntentBits.GuildMembers,
34
+ GatewayIntentBits.GuildMessageReactions, // reactions in guild channels — non-privileged
35
+ GatewayIntentBits.DirectMessageReactions, // reactions in DMs — non-privileged
36
+ ];
37
+ /**
38
+ * Default partials for the bot.
39
+ *
40
+ * - `Channel` — needed to receive DMs.
41
+ * - `Message` + `Reaction` — required to receive reactions on uncached messages.
42
+ * Discord only delivers partial reaction events when the reacted-to message is not
43
+ * in the client's cache; without these partials those events are silently dropped.
44
+ */
45
+ export const DISCORD_DEFAULT_PARTIALS = [
46
+ Partials.Channel, // needed to receive DMs
47
+ Partials.Message, // receive reactions on uncached messages
48
+ Partials.Reaction,
49
+ ];
50
+ export class DiscordAdapter {
51
+ opts;
52
+ platform = "discord";
53
+ capabilities = {
54
+ supportsModals: true,
55
+ supportsTyping: true,
56
+ supportsReactions: true,
57
+ supportsEphemeral: false, // ephemeral only via interaction reply / DM fallback
58
+ supportsStreaming: true,
59
+ maxBlocksPerMessage: 40,
60
+ };
61
+ ackDeadlineMs = 3000;
62
+ client;
63
+ rest;
64
+ store;
65
+ botUserId = "";
66
+ isReady = false;
67
+ pendingCommands = [];
68
+ userCache = new Map();
69
+ /**
70
+ * Tracks live component interactions so a handler can open a modal (which
71
+ * must be the interaction's INITIAL response, within ~3s) before the adapter
72
+ * auto-defers. Constructed in `start()`. Used by `openModal` (Task D4).
73
+ */
74
+ pending;
75
+ /**
76
+ * Tracks live slash-command interactions (a command's initial response is a
77
+ * reply, not a component update, so it needs its own registry). `openModal`
78
+ * consults this too, so a command handler can `showModal` before the adapter
79
+ * auto-defers. Constructed in `start()`.
80
+ */
81
+ commandPending;
82
+ constructor(opts, injected) {
83
+ this.opts = opts;
84
+ this.client =
85
+ injected?.client ??
86
+ new Client({
87
+ intents: [...DISCORD_DEFAULT_INTENTS],
88
+ partials: [...DISCORD_DEFAULT_PARTIALS],
89
+ });
90
+ this.rest =
91
+ injected?.rest ??
92
+ new REST().setToken(opts.botToken);
93
+ // Reconstruct full channel history each turn (bot-slack parity). The store
94
+ // reads the Discord channel — the source of truth — so the inbound message
95
+ // and any earlier attachments are part of the rebuilt history.
96
+ this.store = new DiscordConversationStore({
97
+ fetchHistory: (channelId) => this.fetchHistory(channelId),
98
+ botUserId: () => this.botUserId,
99
+ filesConfig: undefined,
100
+ });
101
+ }
102
+ registerCommands(commands) {
103
+ this.pendingCommands = commands;
104
+ // `ready` may have already fired (start() resolves before the gateway READY
105
+ // event, and the engine calls registerCommands AFTER start()). If so, the
106
+ // once("ready") publish already ran with an empty list — publish now.
107
+ if (this.isReady)
108
+ void this.publishCommands();
109
+ }
110
+ /**
111
+ * Publish the registered commands. Guards against an empty list: an empty
112
+ * `setMyCommands`/`PUT` CLEARS all of the bot's commands, so a race where
113
+ * `ready` fires before commands are stashed must not wipe them.
114
+ */
115
+ async publishCommands() {
116
+ if (this.pendingCommands.length === 0)
117
+ return;
118
+ try {
119
+ await putCommands(this.rest, this.opts.appId, this.opts.guildId, this.pendingCommands);
120
+ }
121
+ catch (err) {
122
+ console.error("[bot-discord] command registration failed:", err);
123
+ }
124
+ }
125
+ async start(sink) {
126
+ this.client.once("ready", async () => {
127
+ this.botUserId = this.client.user?.id ?? "";
128
+ this.isReady = true;
129
+ await this.publishCommands();
130
+ });
131
+ // Slash commands ack via `deferReply` (a command's initial response is a
132
+ // reply, not a component update), so they need a registry distinct from
133
+ // the component one above. A handler may `openModal` first; otherwise the
134
+ // auto-defer fires inside the 3s window.
135
+ this.commandPending = new PendingInteractions({
136
+ ackBufferMs: this.ackDeadlineMs - 500,
137
+ defer: (i) => i
138
+ .deferReply({ flags: MessageFlags.Ephemeral })
139
+ .then(() => undefined),
140
+ });
141
+ attachDiscordListener({
142
+ client: this.client,
143
+ botUserId: () => this.botUserId, // read lazily — only known after `ready`
144
+ commandPending: this.commandPending,
145
+ onTurn: async (turn) => {
146
+ // The conversation store reconstructs the full channel history each
147
+ // turn — including the triggering message and ALL its attachments — so
148
+ // the bridge passes context only; no per-turn content-part building.
149
+ await sink.onTurn({
150
+ conversationKey: turn.conversationKey,
151
+ replyTarget: turn.replyTarget,
152
+ userText: turn.userText,
153
+ user: turn.senderUserId
154
+ ? await this.resolveUser(turn.senderUserId)
155
+ : undefined,
156
+ platform: "discord",
157
+ });
158
+ },
159
+ onCommand: async (cmd) => {
160
+ const user = cmd.senderUserId
161
+ ? await this.resolveUser(cmd.senderUserId)
162
+ : undefined;
163
+ await sink.onCommand({
164
+ command: cmd.command,
165
+ text: cmd.text,
166
+ rawOptions: cmd.rawOptions,
167
+ conversationKey: cmd.conversationKey,
168
+ replyTarget: cmd.replyTarget,
169
+ user,
170
+ platform: "discord",
171
+ triggerId: cmd.triggerId,
172
+ });
173
+ },
174
+ onReaction: sink.onReaction ? sink.onReaction.bind(sink) : undefined,
175
+ });
176
+ // One registry per adapter. Auto-defer fires `ackBufferMs` after register,
177
+ // leaving a ~500ms cushion inside Discord's 3s window. A handler may
178
+ // `openModal` (Task D4) first, which calls `pending.respondWith` to win the
179
+ // race and cancel the auto-defer.
180
+ this.pending = new PendingInteractions({
181
+ ackBufferMs: this.ackDeadlineMs - 500,
182
+ defer: (i) => i.deferUpdate(),
183
+ });
184
+ // Component interactions: register with the timer-race registry, hand the
185
+ // decoded event to the sink (so a handler can open a modal first), then
186
+ // settle — acking with deferUpdate if the handler never responded.
187
+ this.client.on("interactionCreate", async (i) => {
188
+ if (typeof i?.isButton !== "function")
189
+ return;
190
+ if (i.isButton() || i.isStringSelectMenu?.()) {
191
+ const triggerId = this.pending.register(i);
192
+ try {
193
+ const evt = this.decodeInteraction(i);
194
+ if (evt) {
195
+ evt.triggerId = triggerId;
196
+ await sink.onInteraction(evt);
197
+ }
198
+ }
199
+ catch (err) {
200
+ // Mirror discord-listener's onTurn/onCommand guards: a malformed
201
+ // payload (decode throw) or a rejected sink dispatch must not become
202
+ // an unhandled promise rejection. `settle` below still acks the
203
+ // interaction, so logging here is sufficient.
204
+ console.error("[bot-discord] interaction dispatch failed:", err);
205
+ }
206
+ await this.pending.settle(triggerId);
207
+ }
208
+ else if (i.isModalSubmit?.()) {
209
+ try {
210
+ await sink.onModalSubmit(decodeModalSubmit(i)); // result ignored — Discord can't re-open with errors
211
+ }
212
+ catch (err) {
213
+ console.error("[bot-discord] modal submit dispatch failed:", err);
214
+ }
215
+ // Ack must match the modal's origin: `deferUpdate` is only valid for a
216
+ // modal opened FROM a message component (button/select). A modal opened
217
+ // from a slash command has no originating message, so `deferUpdate`
218
+ // throws there — use `deferReply` (ephemeral) instead. Guard the ack so
219
+ // it can never become an unhandled rejection in the event listener.
220
+ try {
221
+ if (!i.replied && !i.deferred) {
222
+ if (i.isFromMessage?.())
223
+ await i.deferUpdate();
224
+ else
225
+ await i.deferReply({ flags: MessageFlags.Ephemeral });
226
+ }
227
+ }
228
+ catch (err) {
229
+ console.error("[bot-discord] modal submit ack failed:", err);
230
+ }
231
+ }
232
+ });
233
+ await this.client.login(this.opts.botToken);
234
+ }
235
+ async stop() {
236
+ await this.client.destroy();
237
+ }
238
+ render(ir) {
239
+ return renderComponents(ir);
240
+ }
241
+ async post(target, ir) {
242
+ const t = target;
243
+ const channel = await this.fetchSendable(t.channelId);
244
+ const { components, flags } = renderDiscordMessage(ir);
245
+ const msg = await channel.send({ components, flags });
246
+ return { id: msg.id, channelId: t.channelId };
247
+ }
248
+ async update(ref, ir) {
249
+ // An empty stream returns a ref with id "" (no message was ever posted);
250
+ // a fetch on "" would throw, so treat update/delete on it as a no-op.
251
+ if (!ref.id)
252
+ return;
253
+ const channel = await this.fetchSendable(this.channelIdOf(ref));
254
+ const msg = await channel.messages.fetch(ref.id);
255
+ const { components, flags } = renderDiscordMessage(ir);
256
+ await msg.edit({ components, flags });
257
+ }
258
+ async stream(target, chunks) {
259
+ const t = target;
260
+ const channel = await this.fetchSendable(t.channelId);
261
+ let firstId = "";
262
+ // One handle per posted Discord message, keyed by the id returned from
263
+ // `channel.send`. Multi-chunk replies (>2000 chars) post several
264
+ // messages; `updateAt(id, …)` must edit the message that owns `id`,
265
+ // not always message #0. Mirrors the `messages` Map in event-renderer.ts.
266
+ const handles = new Map();
267
+ const stream = new ChunkedMessageStream({
268
+ postPlaceholder: async (text) => {
269
+ const m = await channel.send(text);
270
+ if (!firstId)
271
+ firstId = m.id;
272
+ handles.set(m.id, m);
273
+ return m.id;
274
+ },
275
+ updateAt: async (id, text) => {
276
+ await handles.get(id)?.edit(text);
277
+ },
278
+ transform: (s) => discordMarkdown(autoCloseOpenMarkdown(s)),
279
+ });
280
+ let acc = "";
281
+ // If the source iterable rejects partway, `finish()` must still run so the
282
+ // already-posted "_thinking…_" placeholder gets drained to its accumulated
283
+ // text instead of being frozen forever; then let the original error
284
+ // propagate.
285
+ try {
286
+ for await (const chunk of chunks) {
287
+ acc += chunk;
288
+ stream.append(acc);
289
+ }
290
+ }
291
+ finally {
292
+ await stream.finish();
293
+ }
294
+ return { id: firstId, channelId: t.channelId };
295
+ }
296
+ async delete(ref) {
297
+ if (!ref.id)
298
+ return; // empty-stream ref — nothing was posted
299
+ const channel = await this.fetchSendable(this.channelIdOf(ref));
300
+ const msg = await channel.messages.fetch(ref.id);
301
+ await msg.delete();
302
+ }
303
+ createRunRenderer(target) {
304
+ const t = target;
305
+ // Resolve the channel lazily inside a thin wrapper so createRunRenderer stays sync.
306
+ const channelPromise = this.fetchSendable(t.channelId);
307
+ return createRunRenderer({
308
+ channel: {
309
+ async sendTyping() {
310
+ await (await channelPromise).sendTyping?.();
311
+ },
312
+ async send(payload) {
313
+ return (await channelPromise).send(payload);
314
+ },
315
+ },
316
+ interruptEventNames: this.opts.interruptEventNames,
317
+ });
318
+ }
319
+ decodeInteraction(raw) {
320
+ return decodeInteraction(raw);
321
+ }
322
+ async lookupUser(q) {
323
+ const query = q.query.trim();
324
+ if (!query)
325
+ return undefined;
326
+ // Search guild members by username/displayName across cached guilds.
327
+ for (const guild of this.client.guilds.cache.values()) {
328
+ try {
329
+ const found = await guild.members.search({ query, limit: 1 });
330
+ const member = found.first();
331
+ if (member) {
332
+ const user = member.user;
333
+ return {
334
+ id: user.id,
335
+ name: user.globalName ?? user.username,
336
+ handle: user.username,
337
+ };
338
+ }
339
+ }
340
+ catch (err) {
341
+ console.error(`[bot-discord] member search failed (guild ${guild.id}, query "${query}"):`, err);
342
+ /* try the next guild */
343
+ }
344
+ }
345
+ return undefined;
346
+ }
347
+ get conversationStore() {
348
+ return this.store;
349
+ }
350
+ async getMessages(target) {
351
+ const t = target;
352
+ try {
353
+ const channel = await this.fetchSendable(t.channelId);
354
+ const fetched = await channel.messages.fetch({ limit: 100 });
355
+ return ([...fetched.values()]
356
+ .toReversed()
357
+ // Drop the bot's own streaming placeholders ("_thinking…_" /
358
+ // "_…(continued)_") so they don't pollute the read_thread history
359
+ // (bot-slack parity — it filters its own status/placeholders too).
360
+ .filter((m) => !(Boolean(m.author?.bot) &&
361
+ STREAM_PLACEHOLDERS.includes(m.content ?? "")))
362
+ .map((m) => ({
363
+ text: m.content ?? "",
364
+ ts: m.id,
365
+ isBot: Boolean(m.author?.bot),
366
+ user: m.author
367
+ ? {
368
+ id: m.author.id,
369
+ name: m.author.globalName ?? m.author.username,
370
+ handle: m.author.username,
371
+ }
372
+ : undefined,
373
+ })));
374
+ }
375
+ catch (err) {
376
+ console.error(`[bot-discord] getMessages failed (channel ${t.channelId}):`, err);
377
+ return [];
378
+ }
379
+ }
380
+ async postFile(target, args) {
381
+ const t = target;
382
+ try {
383
+ const channel = await this.fetchSendable(t.channelId);
384
+ const msg = await channel.send({
385
+ files: [{ attachment: Buffer.from(args.bytes), name: args.filename }],
386
+ });
387
+ return { ok: true, fileId: msg.id };
388
+ }
389
+ catch (e) {
390
+ return { ok: false, error: e.message };
391
+ }
392
+ }
393
+ async addReaction(target, messageRef, emoji) {
394
+ const token = toPlatformEmoji(emoji, "discord") ?? String(emoji);
395
+ try {
396
+ // Fall back to the conversation's target channel when the reacted ref
397
+ // carries no channelId — parity with Slack/Telegram, which the bot-ui
398
+ // contract and the example rely on (the reacted ref is often just `{ id }`).
399
+ const channel = await this.fetchSendable(this.channelIdOf(messageRef) || target.channelId);
400
+ const msg = await channel.messages.fetch(messageRef.id);
401
+ await msg.react(token);
402
+ return { ok: true };
403
+ }
404
+ catch (err) {
405
+ return { ok: false, error: err.message };
406
+ }
407
+ }
408
+ async removeReaction(target, messageRef, emoji) {
409
+ const token = toPlatformEmoji(emoji, "discord") ?? String(emoji);
410
+ try {
411
+ // Fall back to the conversation's target channel when the reacted ref
412
+ // carries no channelId — parity with Slack/Telegram (see addReaction).
413
+ const channel = await this.fetchSendable(this.channelIdOf(messageRef) || target.channelId);
414
+ const msg = await channel.messages.fetch(messageRef.id);
415
+ // Discord may key the cache by the bare codepoint while `token` carries
416
+ // the table's trailing U+FE0F (e.g. "❤️" vs "❤"), so resolve tolerantly.
417
+ const cache = msg.reactions?.cache;
418
+ const reaction = cache?.get(token) ?? cache?.get(token.replace(/️/g, ""));
419
+ // Prefer the cached bot id (known after `ready`); fall back to the live
420
+ // client user without a non-null assertion that could throw pre-`ready`.
421
+ await reaction?.users?.remove(this.botUserId || this.client.user?.id);
422
+ return { ok: true };
423
+ }
424
+ catch (err) {
425
+ return { ok: false, error: err.message };
426
+ }
427
+ }
428
+ async postEphemeral(_target, user, ir, opts) {
429
+ // Native interaction-ephemeral is only reachable from within a live,
430
+ // unresponded interaction; that path is not plumbed into postEphemeral in
431
+ // this pass, so we use the DM fallback (or null).
432
+ if (!opts.fallbackToDM)
433
+ return null;
434
+ const userId = typeof user === "string" ? user : user.id;
435
+ try {
436
+ const u = await this.client.users.fetch(userId);
437
+ const dm = await u.createDM();
438
+ const { components, flags } = renderDiscordMessage(ir);
439
+ await dm.send({ components, flags });
440
+ return {
441
+ ok: true,
442
+ usedFallback: true,
443
+ ref: { id: "", channelId: dm.id },
444
+ };
445
+ }
446
+ catch (err) {
447
+ return { ok: false, error: err.message };
448
+ }
449
+ }
450
+ renderModal(ir) {
451
+ return renderDiscordModal(ir);
452
+ }
453
+ async openModal(_target, triggerId, ir) {
454
+ let modal;
455
+ try {
456
+ modal = renderDiscordModal(ir);
457
+ }
458
+ catch (err) {
459
+ return { ok: false, error: err.message };
460
+ }
461
+ let shown;
462
+ try {
463
+ const show = (i) => i.showModal(modal);
464
+ // A triggerId belongs to exactly one registry: component interactions
465
+ // (buttons/selects) live in `pending`, slash commands in `commandPending`.
466
+ // `respondWith` returns false (no throw) when the id isn't in a registry,
467
+ // so try both — without this, opening a modal from a slash command (e.g.
468
+ // /file-issue) silently fails because the command isn't in `pending`.
469
+ shown =
470
+ (await this.pending.respondWith(triggerId, show)) ||
471
+ (await this.commandPending.respondWith(triggerId, show));
472
+ }
473
+ catch (err) {
474
+ return { ok: false, error: err.message };
475
+ }
476
+ return shown
477
+ ? { ok: true }
478
+ : {
479
+ ok: false,
480
+ error: "interaction already acknowledged (open the modal before other work)",
481
+ };
482
+ }
483
+ async resolveUser(userId) {
484
+ const cached = this.userCache.get(userId);
485
+ if (cached)
486
+ return cached;
487
+ try {
488
+ const u = await this.client.users.fetch(userId);
489
+ const user = {
490
+ id: u.id,
491
+ name: u.globalName ?? u.username,
492
+ handle: u.username,
493
+ };
494
+ // Note: bots cannot read user email; PlatformUser.email stays undefined.
495
+ // Cache ONLY on success — a transient fetch failure must not pin the
496
+ // bare-id fallback forever; a later call should retry.
497
+ this.userCache.set(userId, user);
498
+ return user;
499
+ }
500
+ catch (err) {
501
+ console.error(`[bot-discord] resolveUser fetch failed (${userId}):`, err);
502
+ return { id: userId }; // bare-id fallback, intentionally not cached
503
+ }
504
+ }
505
+ channelIdOf(ref) {
506
+ return String(ref.channelId ?? "");
507
+ }
508
+ async fetchSendable(channelId) {
509
+ const ch = await this.client.channels.fetch(channelId);
510
+ if (!ch || !("send" in ch)) {
511
+ throw new Error(`channel ${channelId} is not sendable`);
512
+ }
513
+ return ch;
514
+ }
515
+ /**
516
+ * Fetch the channel's recent messages OLDEST→NEWEST, normalized for the
517
+ * conversation store's history reconstruction. Best-effort: on any fetch
518
+ * error returns [] (bot-slack's fetchHistory does the same).
519
+ */
520
+ async fetchHistory(channelId) {
521
+ const mapMsg = (m) => ({
522
+ id: m.id,
523
+ content: m.content ?? "",
524
+ authorId: m.author?.id,
525
+ authorIsBot: Boolean(m.author?.bot),
526
+ attachments: m.attachments
527
+ ? Array.from(m.attachments.values()).map((a) => ({
528
+ url: a.url,
529
+ name: a.name,
530
+ contentType: a.contentType,
531
+ size: a.size,
532
+ }))
533
+ : [],
534
+ });
535
+ try {
536
+ const channel = await this.fetchSendable(channelId);
537
+ const fetched = await channel.messages.fetch({ limit: 100 });
538
+ const msgs = [...fetched.values()].toReversed().map(mapMsg); // oldest→newest
539
+ // A thread's *starter* message (the one the thread was created from) lives
540
+ // in the PARENT channel and is NOT part of the thread's own message list,
541
+ // so `messages.fetch` above never returns it. Pull it in so an image/text
542
+ // on the message a thread was started from is part of the reconstructed
543
+ // history. (Slack's `conversations.replies` returns the parent
544
+ // automatically; Discord does not.) Best-effort: the starter may be
545
+ // deleted, or the thread may have none (e.g. forum/standalone threads).
546
+ const ch = channel;
547
+ if (typeof ch.isThread === "function" && ch.isThread()) {
548
+ try {
549
+ const starter = await ch.fetchStarterMessage?.();
550
+ if (starter)
551
+ msgs.unshift(mapMsg(starter));
552
+ }
553
+ catch {
554
+ /* starter deleted or unavailable — best-effort */
555
+ }
556
+ }
557
+ return msgs;
558
+ }
559
+ catch (err) {
560
+ console.warn(`[bot-discord] fetchHistory failed (channel ${channelId}):`, err);
561
+ return [];
562
+ }
563
+ }
564
+ }
565
+ export function discord(opts) {
566
+ return new DiscordAdapter(opts);
567
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=adapter.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.test.d.ts","sourceRoot":"","sources":["../src/adapter.test.ts"],"names":[],"mappings":""}