@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,118 @@
1
+ import { decodeReaction } from "./interaction.js";
2
+ /** Wire Gateway events to normalized turns/commands. Mirrors attachSlackListener. */
3
+ export function attachDiscordListener(cfg) {
4
+ const { client, botUserId, onTurn, onCommand, onReaction, commandPending } = cfg;
5
+ client.on("messageCreate", (msg) => {
6
+ const botId = typeof botUserId === "function" ? botUserId() : botUserId;
7
+ if (!shouldAnswer(msg, botId))
8
+ return;
9
+ const replyTarget = {
10
+ channelId: msg.channelId,
11
+ ...(msg.guildId ? { guildId: msg.guildId } : {}),
12
+ };
13
+ void Promise.resolve(onTurn({
14
+ conversationKey: msg.channelId,
15
+ replyTarget,
16
+ userText: stripMention(msg.content, botId),
17
+ senderUserId: msg.author.id,
18
+ })).catch((e) => console.error("[bot-discord] onTurn handler failed:", e));
19
+ });
20
+ client.on("interactionCreate", async (i) => {
21
+ if (typeof i?.isChatInputCommand !== "function" || !i.isChatInputCommand())
22
+ return;
23
+ // Register the live interaction with the timer-race registry, arming an
24
+ // auto-`deferReply` ~500ms before Discord's 3s window. This replaces the
25
+ // old eager `i.reply(...)` so a handler can `openModal` first; if it
26
+ // doesn't, `settle` acks (deferReply) and the real reply is delivered
27
+ // out-of-band as a channel message.
28
+ const triggerId = commandPending?.register(i);
29
+ const rawOptions = {};
30
+ for (const opt of i.options?.data ?? [])
31
+ rawOptions[opt.name] = opt.value;
32
+ const replyTarget = {
33
+ channelId: i.channelId,
34
+ ...(i.guildId ? { guildId: i.guildId } : {}),
35
+ };
36
+ try {
37
+ await onCommand({
38
+ command: i.commandName,
39
+ text: Object.values(rawOptions).map(String).join(" "),
40
+ rawOptions,
41
+ conversationKey: i.channelId,
42
+ replyTarget,
43
+ senderUserId: i.user.id,
44
+ triggerId,
45
+ });
46
+ }
47
+ catch (e) {
48
+ console.error("[bot-discord] onCommand handler failed:", e);
49
+ }
50
+ finally {
51
+ if (triggerId !== undefined)
52
+ await commandPending?.settle(triggerId);
53
+ // The deferReply(ephemeral) auto-ack only satisfies Discord's 3s window;
54
+ // the real response is delivered out-of-band as channel messages, so
55
+ // remove the dangling ephemeral "thinking…" once dispatch completes. A
56
+ // modal (showModal) does not defer, so `i.deferred` is false there and
57
+ // this is skipped; likewise if the handler itself already replied.
58
+ try {
59
+ if (i.deferred && !i.replied)
60
+ await i.deleteReply?.();
61
+ }
62
+ catch {
63
+ /* interaction already gone / cleared */
64
+ }
65
+ }
66
+ });
67
+ if (onReaction) {
68
+ const handleReaction = (added) => async (reaction, user) => {
69
+ const botId = typeof botUserId === "function" ? botUserId() : botUserId;
70
+ const u = user;
71
+ // Skip the bot's own reaction. `u.bot` is `undefined` on a PARTIAL user
72
+ // (the uncached path these handlers support via Partials), so also guard
73
+ // by id — matching the other platforms' bot-id guard.
74
+ if (u?.bot || u?.id === botId)
75
+ return;
76
+ try {
77
+ const r = reaction;
78
+ if (r.partial)
79
+ await r.fetch?.();
80
+ if (r.message?.partial)
81
+ await r.message.fetch?.();
82
+ }
83
+ catch {
84
+ return;
85
+ }
86
+ // Keep the sink dispatch inside a try/catch so a throwing/rejecting
87
+ // user handler degrades-never-throws instead of escaping as an
88
+ // unhandled rejection — mirroring the onTurn/onCommand paths above.
89
+ try {
90
+ const evt = decodeReaction(reaction, user, added);
91
+ if (evt)
92
+ await onReaction(evt);
93
+ }
94
+ catch (e) {
95
+ console.error("[bot-discord] onReaction handler failed:", e);
96
+ }
97
+ };
98
+ client.on("messageReactionAdd", handleReaction(true));
99
+ client.on("messageReactionRemove", handleReaction(false));
100
+ }
101
+ }
102
+ /** Answer @-mentions and DMs; skip our own messages and other bots. */
103
+ function shouldAnswer(msg, botUserId) {
104
+ if (msg.author.id === botUserId)
105
+ return false;
106
+ if (msg.author.bot)
107
+ return false;
108
+ if (msg.channel.isDMBased())
109
+ return true;
110
+ // Only answer a DIRECT user mention. discord.js `mentions.has()` also returns
111
+ // true for role mentions and @everyone/@here that happen to include the bot,
112
+ // so narrow to the explicit user-mention set.
113
+ return msg.mentions.users?.has?.(botUserId) ?? false;
114
+ }
115
+ /** Drop a leading <@botId> / <@!botId> mention from the message text. */
116
+ function stripMention(content, botUserId) {
117
+ return content.replace(new RegExp(`<@!?${botUserId}>`, "g"), "").trim();
118
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=discord-listener.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"discord-listener.test.d.ts","sourceRoot":"","sources":["../src/discord-listener.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,361 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { attachDiscordListener } from "./discord-listener.js";
3
+ import { PendingInteractions } from "./pending-interactions.js";
4
+ function fakeClient() {
5
+ const handlers = {};
6
+ return {
7
+ on(event, cb) {
8
+ handlers[event] = cb;
9
+ },
10
+ emit(event, ...args) {
11
+ handlers[event]?.(...args);
12
+ },
13
+ };
14
+ }
15
+ /** A complete (non-partial) reaction with the fields decodeReaction needs. */
16
+ function reaction(over = {}) {
17
+ return {
18
+ partial: false,
19
+ emoji: { name: "👍", id: null },
20
+ message: { id: "m1", channelId: "c1", guildId: "g1", partial: false },
21
+ ...over,
22
+ };
23
+ }
24
+ const botId = "bot-1";
25
+ function message(over) {
26
+ return {
27
+ author: { id: "u1", bot: false, username: "ann", globalName: "Ann" },
28
+ content: "hello",
29
+ channelId: "c1",
30
+ guildId: "g1",
31
+ mentions: { has: () => false, users: { has: () => false } },
32
+ channel: { isDMBased: () => false },
33
+ ...over,
34
+ };
35
+ }
36
+ describe("attachDiscordListener", () => {
37
+ it("emits a turn when the bot is mentioned", () => {
38
+ const client = fakeClient();
39
+ const onTurn = vi.fn();
40
+ attachDiscordListener({
41
+ client: client,
42
+ botUserId: botId,
43
+ onTurn,
44
+ onCommand: vi.fn(),
45
+ });
46
+ client.emit("messageCreate", message({
47
+ mentions: {
48
+ has: () => true,
49
+ users: { has: (q) => q === "bot-1" },
50
+ },
51
+ content: "<@bot-1> hi",
52
+ }));
53
+ expect(onTurn).toHaveBeenCalledWith(expect.objectContaining({
54
+ conversationKey: "c1",
55
+ replyTarget: { channelId: "c1", guildId: "g1" },
56
+ senderUserId: "u1",
57
+ }));
58
+ });
59
+ it("does not answer a role / @everyone mention that only matches via mentions.has", () => {
60
+ const client = fakeClient();
61
+ const onTurn = vi.fn();
62
+ attachDiscordListener({
63
+ client: client,
64
+ botUserId: botId,
65
+ onTurn,
66
+ onCommand: vi.fn(),
67
+ });
68
+ // `mentions.has` is true (e.g. a role mention the bot belongs to), but the
69
+ // bot is not a direct user mention, so we must NOT answer.
70
+ client.emit("messageCreate", message({
71
+ mentions: { has: () => true, users: { has: () => false } },
72
+ content: "<@&role-1> ping everyone",
73
+ }));
74
+ expect(onTurn).not.toHaveBeenCalled();
75
+ });
76
+ it("emits a turn for a DM even without a mention", () => {
77
+ const client = fakeClient();
78
+ const onTurn = vi.fn();
79
+ attachDiscordListener({
80
+ client: client,
81
+ botUserId: botId,
82
+ onTurn,
83
+ onCommand: vi.fn(),
84
+ });
85
+ client.emit("messageCreate", message({ channel: { isDMBased: () => true }, guildId: null }));
86
+ expect(onTurn).toHaveBeenCalledTimes(1);
87
+ });
88
+ it("ignores the bot's own messages", () => {
89
+ const client = fakeClient();
90
+ const onTurn = vi.fn();
91
+ attachDiscordListener({
92
+ client: client,
93
+ botUserId: botId,
94
+ onTurn,
95
+ onCommand: vi.fn(),
96
+ });
97
+ client.emit("messageCreate", message({ author: { id: "bot-1", bot: true } }));
98
+ expect(onTurn).not.toHaveBeenCalled();
99
+ });
100
+ it("ignores other bots", () => {
101
+ const client = fakeClient();
102
+ const onTurn = vi.fn();
103
+ attachDiscordListener({
104
+ client: client,
105
+ botUserId: botId,
106
+ onTurn,
107
+ onCommand: vi.fn(),
108
+ });
109
+ client.emit("messageCreate", message({ author: { id: "u2", bot: true } }));
110
+ expect(onTurn).not.toHaveBeenCalled();
111
+ });
112
+ it("resolves a getter-form botUserId per event", () => {
113
+ const client = fakeClient();
114
+ const onTurn = vi.fn();
115
+ let id = "";
116
+ attachDiscordListener({
117
+ client: client,
118
+ botUserId: () => id,
119
+ onTurn,
120
+ onCommand: vi.fn(),
121
+ });
122
+ // Before the id is known, the bot's own message would not match a mention,
123
+ // but a DM still answers regardless of id.
124
+ id = "bot-1";
125
+ client.emit("messageCreate", message({
126
+ mentions: {
127
+ has: (q) => q === "bot-1",
128
+ users: { has: (q) => q === "bot-1" },
129
+ },
130
+ content: "<@bot-1> hi",
131
+ }));
132
+ expect(onTurn).toHaveBeenCalledWith(expect.objectContaining({ conversationKey: "c1", senderUserId: "u1" }));
133
+ });
134
+ it("forwards a chat-input command via onCommand with a triggerId, then settles (deferReply) when no modal opens", async () => {
135
+ const client = fakeClient();
136
+ const onCommand = vi.fn();
137
+ const deferReply = vi.fn().mockResolvedValue(undefined);
138
+ const commandPending = new PendingInteractions({
139
+ ackBufferMs: 2500,
140
+ defer: (i) => i.deferReply(),
141
+ });
142
+ attachDiscordListener({
143
+ client: client,
144
+ botUserId: botId,
145
+ onTurn: vi.fn(),
146
+ onCommand,
147
+ commandPending,
148
+ });
149
+ client.emit("interactionCreate", {
150
+ isChatInputCommand: () => true,
151
+ id: "int-1",
152
+ commandName: "triage",
153
+ channelId: "c1",
154
+ guildId: "g1",
155
+ user: { id: "u1", username: "ann", globalName: "Ann" },
156
+ options: { data: [{ name: "priority", value: "high" }] },
157
+ deferReply,
158
+ });
159
+ // Let the async command handler (dispatch + settle) run to completion.
160
+ await Promise.resolve();
161
+ await Promise.resolve();
162
+ expect(onCommand).toHaveBeenCalledWith(expect.objectContaining({
163
+ command: "triage",
164
+ conversationKey: "c1",
165
+ rawOptions: { priority: "high" },
166
+ triggerId: "int-1",
167
+ }));
168
+ // No handler opened a modal, so `settle` acks via deferReply (the eager
169
+ // `i.reply(...)` is gone — the registry now owns the ack).
170
+ expect(deferReply).toHaveBeenCalledTimes(1);
171
+ });
172
+ it("clears the dangling deferred ephemeral after a non-modal command settles", async () => {
173
+ const client = fakeClient();
174
+ const deferReply = vi.fn().mockResolvedValue(undefined);
175
+ const deleteReply = vi.fn().mockResolvedValue(undefined);
176
+ const commandPending = new PendingInteractions({
177
+ ackBufferMs: 2500,
178
+ // The settle ack defers the reply, marking the interaction `deferred`.
179
+ defer: async (i) => {
180
+ await i.deferReply();
181
+ i.deferred = true;
182
+ },
183
+ });
184
+ attachDiscordListener({
185
+ client: client,
186
+ botUserId: botId,
187
+ onTurn: vi.fn(),
188
+ // No modal opened → the auto-defer ack leaves a dangling ephemeral
189
+ // "thinking…" spinner that must be cleared.
190
+ onCommand: vi.fn(),
191
+ commandPending,
192
+ });
193
+ const interaction = {
194
+ isChatInputCommand: () => true,
195
+ id: "int-3",
196
+ commandName: "triage",
197
+ channelId: "c1",
198
+ guildId: "g1",
199
+ user: { id: "u1" },
200
+ options: { data: [] },
201
+ deferred: false,
202
+ replied: false,
203
+ deferReply,
204
+ deleteReply,
205
+ };
206
+ client.emit("interactionCreate", interaction);
207
+ await new Promise((r) => setTimeout(r, 0));
208
+ expect(deferReply).toHaveBeenCalledTimes(1);
209
+ expect(deleteReply).toHaveBeenCalledTimes(1);
210
+ });
211
+ it("does not delete the reply when a command opened a modal (no defer)", async () => {
212
+ const client = fakeClient();
213
+ const deferReply = vi.fn().mockResolvedValue(undefined);
214
+ const deleteReply = vi.fn().mockResolvedValue(undefined);
215
+ const commandPending = new PendingInteractions({
216
+ ackBufferMs: 2500,
217
+ defer: (i) => i.deferReply(),
218
+ });
219
+ attachDiscordListener({
220
+ client: client,
221
+ botUserId: botId,
222
+ onTurn: vi.fn(),
223
+ // Simulate a handler that opened a modal: it marks the live interaction
224
+ // responded so `settle` never defers, and `deferred` stays false.
225
+ onCommand: async (cmd) => {
226
+ await commandPending.respondWith(cmd.triggerId, async () => { });
227
+ },
228
+ commandPending,
229
+ });
230
+ const interaction = {
231
+ isChatInputCommand: () => true,
232
+ id: "int-4",
233
+ commandName: "open-modal",
234
+ channelId: "c1",
235
+ guildId: "g1",
236
+ user: { id: "u1" },
237
+ options: { data: [] },
238
+ deferred: false,
239
+ replied: false,
240
+ deferReply,
241
+ deleteReply,
242
+ };
243
+ client.emit("interactionCreate", interaction);
244
+ await new Promise((r) => setTimeout(r, 0));
245
+ expect(deferReply).not.toHaveBeenCalled();
246
+ expect(deleteReply).not.toHaveBeenCalled();
247
+ });
248
+ it("does not ack or dispatch a non-command interaction", async () => {
249
+ const client = fakeClient();
250
+ const onCommand = vi.fn();
251
+ const deferReply = vi.fn().mockResolvedValue(undefined);
252
+ const commandPending = new PendingInteractions({
253
+ ackBufferMs: 2500,
254
+ defer: (i) => i.deferReply(),
255
+ });
256
+ attachDiscordListener({
257
+ client: client,
258
+ botUserId: botId,
259
+ onTurn: vi.fn(),
260
+ onCommand,
261
+ commandPending,
262
+ });
263
+ client.emit("interactionCreate", {
264
+ isChatInputCommand: () => false,
265
+ id: "int-2",
266
+ commandName: "triage",
267
+ channelId: "c1",
268
+ guildId: "g1",
269
+ user: { id: "u1" },
270
+ options: { data: [] },
271
+ deferReply,
272
+ });
273
+ await Promise.resolve();
274
+ await Promise.resolve();
275
+ expect(deferReply).not.toHaveBeenCalled();
276
+ expect(onCommand).not.toHaveBeenCalled();
277
+ });
278
+ it("catches a rejecting onTurn handler instead of letting it escape", async () => {
279
+ const client = fakeClient();
280
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => { });
281
+ const onTurn = vi.fn().mockRejectedValue(new Error("boom"));
282
+ attachDiscordListener({
283
+ client: client,
284
+ botUserId: botId,
285
+ onTurn,
286
+ onCommand: vi.fn(),
287
+ });
288
+ expect(() => client.emit("messageCreate", message({
289
+ mentions: {
290
+ has: () => true,
291
+ users: { has: (q) => q === "bot-1" },
292
+ },
293
+ content: "<@bot-1> hi",
294
+ }))).not.toThrow();
295
+ // Let the rejected promise settle so the .catch runs.
296
+ await Promise.resolve();
297
+ await Promise.resolve();
298
+ expect(errSpy).toHaveBeenCalledWith("[bot-discord] onTurn handler failed:", expect.any(Error));
299
+ errSpy.mockRestore();
300
+ });
301
+ it("catches a rejecting onReaction handler instead of letting it escape", async () => {
302
+ const client = fakeClient();
303
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => { });
304
+ const onReaction = vi.fn().mockRejectedValue(new Error("boom"));
305
+ attachDiscordListener({
306
+ client: client,
307
+ botUserId: botId,
308
+ onTurn: vi.fn(),
309
+ onCommand: vi.fn(),
310
+ onReaction,
311
+ });
312
+ expect(() => client.emit("messageReactionAdd", reaction(), { id: "u1", bot: false })).not.toThrow();
313
+ // Let the rejected promise settle so the .catch runs.
314
+ await Promise.resolve();
315
+ await Promise.resolve();
316
+ expect(onReaction).toHaveBeenCalledTimes(1);
317
+ expect(errSpy).toHaveBeenCalledWith("[bot-discord] onReaction handler failed:", expect.any(Error));
318
+ errSpy.mockRestore();
319
+ });
320
+ it("skips the bot's own reaction by id even when the bot flag is undefined (partial user)", () => {
321
+ const client = fakeClient();
322
+ const onReaction = vi.fn();
323
+ attachDiscordListener({
324
+ client: client,
325
+ botUserId: botId,
326
+ onTurn: vi.fn(),
327
+ onCommand: vi.fn(),
328
+ onReaction,
329
+ });
330
+ // A PARTIAL user has no `bot` flag, so the old `u?.bot` guard would let the
331
+ // bot's own reaction leak through and echo. Guarding by id must skip it.
332
+ client.emit("messageReactionAdd", reaction(), { id: botId });
333
+ expect(onReaction).not.toHaveBeenCalled();
334
+ });
335
+ it("dispatches a normal user's reaction add", () => {
336
+ const client = fakeClient();
337
+ const onReaction = vi.fn();
338
+ attachDiscordListener({
339
+ client: client,
340
+ botUserId: botId,
341
+ onTurn: vi.fn(),
342
+ onCommand: vi.fn(),
343
+ onReaction,
344
+ });
345
+ client.emit("messageReactionAdd", reaction(), { id: "U1", bot: false });
346
+ expect(onReaction).toHaveBeenCalledWith(expect.objectContaining({ added: true, conversationKey: "c1" }));
347
+ });
348
+ it("skips the bot's own reaction removal by id (partial user)", () => {
349
+ const client = fakeClient();
350
+ const onReaction = vi.fn();
351
+ attachDiscordListener({
352
+ client: client,
353
+ botUserId: botId,
354
+ onTurn: vi.fn(),
355
+ onCommand: vi.fn(),
356
+ onReaction,
357
+ });
358
+ client.emit("messageReactionRemove", reaction(), { id: botId });
359
+ expect(onReaction).not.toHaveBeenCalled();
360
+ });
361
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Inbound file transport. Discord messages can carry attachments; this turns
3
+ * them into AG-UI multimodal message content the agent's model can read —
4
+ * images as binary data parts and plain text/CSV/JSON/etc. as decoded `text`
5
+ * parts — downloading each from its public CDN URL (no auth required).
6
+ *
7
+ * The AgentContentPart union is intentionally identical to bot-slack's so the
8
+ * agent sees the same multimodal input shape across both adapters.
9
+ */
10
+ /** The subset of a Discord attachment we use. */
11
+ export interface DiscordAttachmentRef {
12
+ /** CDN URL — publicly fetchable, no auth header needed. */
13
+ url: string;
14
+ /** Original filename, used for extension-based MIME fallback. */
15
+ name: string;
16
+ /** MIME type reported by Discord (may be null/undefined for some uploads). */
17
+ contentType?: string | null;
18
+ /** Byte length reported by Discord. Used to gate the size cap pre-fetch. */
19
+ size: number;
20
+ }
21
+ /** A base64 data source, shared by every binary media part. */
22
+ export type MediaDataSource = {
23
+ type: "data";
24
+ value: string;
25
+ mimeType: string;
26
+ };
27
+ /**
28
+ * AG-UI multimodal content parts — SAME shape as bot-slack emits so the agent
29
+ * sees identical multimodal input across both adapters.
30
+ *
31
+ * Binary media (image/audio/video/document) is passed straight through as a
32
+ * data part; the agent's model decides what it can actually consume. Most
33
+ * models read images and PDFs; far fewer accept audio or video. The bridge
34
+ * stays transport-only and does not gate on model capability.
35
+ */
36
+ export type AgentContentPart = {
37
+ type: "text";
38
+ text: string;
39
+ } | {
40
+ type: "image";
41
+ source: MediaDataSource;
42
+ } | {
43
+ type: "audio";
44
+ source: MediaDataSource;
45
+ } | {
46
+ type: "video";
47
+ source: MediaDataSource;
48
+ } | {
49
+ type: "document";
50
+ source: MediaDataSource;
51
+ };
52
+ /** Tunables for inbound file handling (all optional; sane defaults). */
53
+ export interface FileDeliveryConfig {
54
+ /**
55
+ * Skip a single file larger than this many bytes without fetching it.
56
+ * Default 10 MiB.
57
+ */
58
+ maxBytes?: number;
59
+ /**
60
+ * Truncate decoded text files to this many bytes before injecting them into
61
+ * the prompt (keeps a large text upload from blowing the token budget).
62
+ * Default 200 KiB.
63
+ */
64
+ maxTextBytes?: number;
65
+ /**
66
+ * Process at most this many attachments per message. Extra attachments beyond
67
+ * this cap are ignored without fetching, bounding the multimodal payload /
68
+ * token budget a single message can inject. Default 5.
69
+ */
70
+ maxFiles?: number;
71
+ /**
72
+ * Inject a custom fetch implementation (for testing or environments without
73
+ * the global `fetch`).
74
+ */
75
+ fetchImpl?: typeof fetch;
76
+ }
77
+ /**
78
+ * Download Discord message attachments and convert them to AG-UI content parts.
79
+ *
80
+ * Files whose reported size exceeds `maxBytes` are skipped WITHOUT fetching;
81
+ * the actual downloaded byte length is re-checked against the same cap after
82
+ * fetching. Images map to an image part; text/* MIME types and text-named
83
+ * files (.txt, .csv, .json, .md, .log, .tsv, .yaml) — including ones reported
84
+ * with a generic binary MIME like application/octet-stream — map to a text
85
+ * part (truncated to `maxTextBytes`); other binary content is skipped with a
86
+ * short text note explaining why (bot-slack parity), so the model knows a file
87
+ * was dropped.
88
+ *
89
+ * At most `maxFiles` attachments (default 5) are processed; any beyond that cap
90
+ * are ignored without fetching, bounding the multimodal payload a single
91
+ * message can inject.
92
+ */
93
+ export declare function buildFileContentParts(attachments: readonly DiscordAttachmentRef[], cfg?: FileDeliveryConfig): Promise<AgentContentPart[]>;
94
+ //# sourceMappingURL=download-files.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"download-files.d.ts","sourceRoot":"","sources":["../src/download-files.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,iDAAiD;AACjD,MAAM,WAAW,oBAAoB;IACnC,2DAA2D;IAC3D,GAAG,EAAE,MAAM,CAAC;IACZ,iEAAiE;IACjE,IAAI,EAAE,MAAM,CAAC;IACb,8EAA8E;IAC9E,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAC;CACd;AAED,+DAA+D;AAC/D,MAAM,MAAM,eAAe,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAEhF;;;;;;;;GAQG;AACH,MAAM,MAAM,gBAAgB,GACxB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,CAAC;AAElD,wEAAwE;AACxE,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AA8BD;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,qBAAqB,CACzC,WAAW,EAAE,SAAS,oBAAoB,EAAE,EAC5C,GAAG,GAAE,kBAAuB,GAC3B,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAsG7B"}