@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,384 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { ChunkedMessageStream } from "./chunked-message-stream.js";
3
+ function makeFakeDiscord(updateDurationMs = 0) {
4
+ const posts = [];
5
+ const updates = [];
6
+ let counter = 0;
7
+ return {
8
+ posts,
9
+ updates,
10
+ postPlaceholder: async (text) => {
11
+ counter++;
12
+ const id = `${counter}.0`;
13
+ posts.push({ id, text });
14
+ return id;
15
+ },
16
+ updateAt: async (id, text) => {
17
+ if (updateDurationMs > 0)
18
+ await new Promise((r) => setTimeout(r, updateDurationMs));
19
+ updates.push({ id, text });
20
+ },
21
+ };
22
+ }
23
+ const longString = (n) => "lorem ipsum ".repeat(Math.ceil(n / 12)).slice(0, n);
24
+ describe("ChunkedMessageStream", () => {
25
+ it("stays as a single message when buffer fits in one chunk", async () => {
26
+ const discord = makeFakeDiscord();
27
+ const s = new ChunkedMessageStream({
28
+ ...discord,
29
+ limit: 100,
30
+ minIntervalMs: 0,
31
+ });
32
+ s.append("short reply");
33
+ await s.finish();
34
+ expect(discord.posts).toHaveLength(1);
35
+ expect(discord.posts[0].text).toBe("_thinking…_");
36
+ expect(discord.updates.at(-1)?.text).toBe("short reply");
37
+ });
38
+ it("splits into multiple messages when buffer exceeds the limit", async () => {
39
+ const discord = makeFakeDiscord();
40
+ const s = new ChunkedMessageStream({
41
+ ...discord,
42
+ limit: 50,
43
+ minIntervalMs: 0,
44
+ });
45
+ s.append(longString(200));
46
+ await s.finish();
47
+ expect(discord.posts.length).toBeGreaterThanOrEqual(4);
48
+ // First placeholder = thinking, rest = continued
49
+ expect(discord.posts[0].text).toBe("_thinking…_");
50
+ expect(discord.posts[1].text).toBe("_…(continued)_");
51
+ });
52
+ it("frozen boundaries don't move — already-posted chunks never shrink", async () => {
53
+ const discord = makeFakeDiscord();
54
+ const s = new ChunkedMessageStream({
55
+ ...discord,
56
+ limit: 50,
57
+ minIntervalMs: 0,
58
+ });
59
+ s.append(longString(60));
60
+ await s.finish();
61
+ const chunkAfter1 = discord.updates.filter((u) => u.id === "1.0").at(-1)?.text ?? "";
62
+ // Now feed a fresh stream the same prefix and see that chunk 1 keeps
63
+ // the same content even when extra growth happens.
64
+ const discord2 = makeFakeDiscord();
65
+ const s2 = new ChunkedMessageStream({
66
+ ...discord2,
67
+ limit: 50,
68
+ minIntervalMs: 0,
69
+ });
70
+ s2.append(longString(60));
71
+ // Let the first dispatch settle by yielding briefly.
72
+ await new Promise((r) => setTimeout(r, 5));
73
+ s2.append(longString(120));
74
+ await s2.finish();
75
+ const chunkAfter1_b = discord2.updates.filter((u) => u.id === "1.0").at(-1)?.text ?? "";
76
+ expect(chunkAfter1).toBe(chunkAfter1_b);
77
+ });
78
+ it("prefers to break at newlines, then spaces", async () => {
79
+ const discord = makeFakeDiscord();
80
+ const s = new ChunkedMessageStream({
81
+ ...discord,
82
+ limit: 30,
83
+ minIntervalMs: 0,
84
+ });
85
+ s.append("first paragraph here.\nsecond paragraph here.\nthird here.");
86
+ await s.finish();
87
+ const chunk1 = discord.updates.filter((u) => u.id === "1.0").at(-1)?.text ?? "";
88
+ // Should not contain a partial word — last char should be a space or newline boundary.
89
+ expect(chunk1.endsWith("\n") || chunk1.endsWith(" ") || /\.[\s]?$/.test(chunk1)).toBe(true);
90
+ });
91
+ it("concatenated chunks equal the full buffer (no characters lost)", async () => {
92
+ const discord = makeFakeDiscord();
93
+ const s = new ChunkedMessageStream({
94
+ ...discord,
95
+ limit: 40,
96
+ minIntervalMs: 0,
97
+ });
98
+ const full = longString(300);
99
+ s.append(full);
100
+ await s.finish();
101
+ // Take the last update per id in posted order
102
+ const final = {};
103
+ for (const u of discord.updates)
104
+ final[u.id] = u.text;
105
+ const concatenated = Object.entries(final)
106
+ .sort(([a], [b]) => Number(a) - Number(b))
107
+ .map(([, v]) => v)
108
+ .join("");
109
+ expect(concatenated).toBe(full);
110
+ });
111
+ it("applies the transform per chunk before message edit", async () => {
112
+ const discord = makeFakeDiscord();
113
+ const s = new ChunkedMessageStream({
114
+ ...discord,
115
+ limit: 100,
116
+ minIntervalMs: 0,
117
+ transform: (t) => t.toUpperCase(),
118
+ });
119
+ s.append("hello world");
120
+ await s.finish();
121
+ expect(discord.updates.at(-1)?.text).toBe("HELLO WORLD");
122
+ });
123
+ it("block-keeps-whole: a fenced block that fits in a new chunk goes whole into the next message", async () => {
124
+ // 200 chars of prose + a 60-char fenced block. With limit=120, the
125
+ // ideal boundary would land inside the fence. The block-keeps-whole
126
+ // logic should pull the boundary back to BEFORE the fence so chunk 2
127
+ // contains the entire fence cleanly.
128
+ const discord = makeFakeDiscord();
129
+ const s = new ChunkedMessageStream({
130
+ ...discord,
131
+ limit: 120,
132
+ minIntervalMs: 0,
133
+ });
134
+ // Prose just under the limit, so we end up with exactly two chunks.
135
+ const prose = "lorem ipsum dolor sit amet ".repeat(4); // 108 chars
136
+ const block = "```python\nprint('hi')\nprint('bye')\n```";
137
+ const fullText = prose + block;
138
+ s.append(fullText);
139
+ await s.finish();
140
+ expect(discord.posts.length).toBeGreaterThanOrEqual(2);
141
+ // Find the FINAL state of each chunk message
142
+ const finals = {};
143
+ for (const u of discord.updates)
144
+ finals[u.id] = u.text;
145
+ const chunk1 = finals["1.0"] ?? "";
146
+ const chunk2 = finals["2.0"] ?? "";
147
+ // Chunk 1 must not contain the fence at all — the block moved to chunk 2.
148
+ expect(chunk1.includes("```")).toBe(false);
149
+ // Chunk 2 must contain the whole block.
150
+ expect(chunk2.includes("```python")).toBe(true);
151
+ expect(chunk2.trim().endsWith("```")).toBe(true);
152
+ expect(chunk2.includes("print('hi')")).toBe(true);
153
+ expect(chunk2.includes("print('bye')")).toBe(true);
154
+ });
155
+ it("fallback: a block too big to fit in one chunk uses the re-opener path", async () => {
156
+ // Make a block bigger than the limit so the boundary can't move
157
+ // back without leaving chunk N empty. Re-opener prefix is used instead.
158
+ const discord = makeFakeDiscord();
159
+ const s = new ChunkedMessageStream({
160
+ ...discord,
161
+ limit: 80,
162
+ minIntervalMs: 0,
163
+ });
164
+ const code = "x = 1\n".repeat(40); // ~240 chars of code
165
+ const fullText = "preamble. ```python\n" + code + "```";
166
+ s.append(fullText);
167
+ await s.finish();
168
+ expect(discord.posts.length).toBeGreaterThanOrEqual(2);
169
+ const finals = {};
170
+ for (const u of discord.updates)
171
+ finals[u.id] = u.text;
172
+ const chunk2 = finals["2.0"] ?? "";
173
+ // Chunk 2 must begin with a fence opener (re-opener path).
174
+ expect(chunk2.startsWith("```python\n")).toBe(true);
175
+ });
176
+ it("continuation chunk re-opens a fence when the boundary is inside ```python", async () => {
177
+ // Reproduces the python decorators bug: a long fenced code block split
178
+ // across two Discord messages → chunk 2 must start with the fence opener.
179
+ const discord = makeFakeDiscord();
180
+ const s = new ChunkedMessageStream({
181
+ ...discord,
182
+ limit: 80,
183
+ minIntervalMs: 0,
184
+ });
185
+ // Synthesise enough code in one fence to overflow the limit.
186
+ const longCode = Array.from({ length: 12 }, (_, i) => `print(${i})`).join("\n");
187
+ const fullText = "```python\n" + longCode + "\n```";
188
+ s.append(fullText);
189
+ await s.finish();
190
+ expect(discord.posts.length).toBeGreaterThan(1); // multiple messages
191
+ const lastForSecondId = [...discord.updates]
192
+ .toReversed()
193
+ .find((u) => u.id === "2.0");
194
+ expect(lastForSecondId?.text.startsWith("```python\n")).toBe(true);
195
+ });
196
+ it("continuation chunk re-opens a fence without language when none was specified", async () => {
197
+ const discord = makeFakeDiscord();
198
+ const s = new ChunkedMessageStream({
199
+ ...discord,
200
+ limit: 60,
201
+ minIntervalMs: 0,
202
+ });
203
+ const fullText = "```\n" + "abcdefghij\n".repeat(10) + "```";
204
+ s.append(fullText);
205
+ await s.finish();
206
+ expect(discord.posts.length).toBeGreaterThan(1);
207
+ const second = [...discord.updates]
208
+ .toReversed()
209
+ .find((u) => u.id === "2.0");
210
+ expect(second?.text.startsWith("```\n")).toBe(true);
211
+ });
212
+ it("finish() is idempotent and safe to call without any appends", async () => {
213
+ const discord = makeFakeDiscord();
214
+ const s = new ChunkedMessageStream({
215
+ ...discord,
216
+ limit: 100,
217
+ minIntervalMs: 0,
218
+ });
219
+ await s.finish();
220
+ await s.finish();
221
+ expect(discord.posts).toHaveLength(0);
222
+ expect(discord.updates).toHaveLength(0);
223
+ });
224
+ it("a >2000-char buffer splits into multiple messages", async () => {
225
+ const discord = makeFakeDiscord();
226
+ const s = new ChunkedMessageStream({
227
+ ...discord,
228
+ // Use default limit (2000)
229
+ minIntervalMs: 0,
230
+ });
231
+ s.append(longString(2500));
232
+ await s.finish();
233
+ expect(discord.posts.length).toBeGreaterThanOrEqual(2);
234
+ });
235
+ it("no transformed chunk exceeds Discord's 2000 hard limit, even with an open fence forcing a closer + re-opener", async () => {
236
+ // Regression for the no-headroom bug. The input is one long unbroken line
237
+ // inside an OPEN ```<lang> fence: with no newline/space in the back of the
238
+ // window, the boundary lands at the soft-limit edge, so chunk 1 slices to
239
+ // ~limit raw chars. The per-chunk transform then APPENDS a fence closer
240
+ // ("\n```") and the continuation chunk gets a "```<lang>\n" re-opener
241
+ // PREPENDED — growth that, at a 2000 soft limit, tips chunk 1 past 2000 and
242
+ // makes Discord reject the edit with BASE_TYPE_MAX_LENGTH. With the default
243
+ // 1900 soft limit there is headroom, and the safety clamp guarantees the
244
+ // exact string handed to Discord is never above 2000 regardless.
245
+ const discord = makeFakeDiscord();
246
+ const lang = "supercalifragilisticpython";
247
+ const fullText = "```" + lang + "\n" + "x".repeat(3000); // open fence
248
+ const s = new ChunkedMessageStream({
249
+ ...discord,
250
+ // Default limit (1900) — leaves headroom for the transform.
251
+ minIntervalMs: 0,
252
+ });
253
+ s.append(fullText);
254
+ await s.finish();
255
+ expect(discord.posts.length).toBeGreaterThanOrEqual(2);
256
+ expect(discord.updates.length).toBeGreaterThan(0);
257
+ // Every edited string is the post-transform text actually sent to Discord.
258
+ for (const u of discord.updates) {
259
+ expect(u.text.length).toBeLessThanOrEqual(2000);
260
+ }
261
+ // Sanity: the continuation chunk really did get a fence re-opener prepended,
262
+ // so this exercise genuinely grows the transformed text.
263
+ const second = [...discord.updates]
264
+ .toReversed()
265
+ .find((u) => u.id === "2.0");
266
+ expect(second?.text.startsWith("```" + lang + "\n")).toBe(true);
267
+ });
268
+ it("soft limit gives the transform headroom: limit=1900 fits under 2000 where limit=2000 hits the hard cap", async () => {
269
+ // Red/green via the `limit` option, same open-fence single-line input.
270
+ // chunk 1's transform appends "\n```" (+4 chars). At limit=2000 the raw
271
+ // chunk fills to 2000 and the appended closer overflows to 2004, so the
272
+ // safety clamp has to fire and drop content to land exactly at the 2000
273
+ // hard cap. At limit=1900 the closer fits (1904) so no clamp engages and
274
+ // chunk 1 stays strictly below 2000 — the headroom the fix buys us.
275
+ const lang = "supercalifragilisticpython";
276
+ const fullText = "```" + lang + "\n" + "x".repeat(3000);
277
+ const chunk1Len = async (limit) => {
278
+ const discord = makeFakeDiscord();
279
+ const s = new ChunkedMessageStream({
280
+ ...discord,
281
+ limit,
282
+ minIntervalMs: 0,
283
+ });
284
+ s.append(fullText);
285
+ await s.finish();
286
+ for (const u of discord.updates) {
287
+ // The clamp must hold the hard invariant at every limit.
288
+ expect(u.text.length).toBeLessThanOrEqual(2000);
289
+ }
290
+ return [...discord.updates].toReversed().find((u) => u.id === "1.0").text
291
+ .length;
292
+ };
293
+ // RED at 2000: transform overflowed → clamp forced chunk 1 to the hard cap.
294
+ expect(await chunk1Len(2000)).toBe(2000);
295
+ // GREEN at 1900: headroom → chunk 1's transform fits strictly under 2000.
296
+ expect(await chunk1Len(1900)).toBeLessThan(2000);
297
+ });
298
+ it("a rejecting postPlaceholder surfaces at finish() without an unhandled rejection", async () => {
299
+ const unhandled = [];
300
+ const onUnhandled = (reason) => unhandled.push(reason);
301
+ process.on("unhandledRejection", onUnhandled);
302
+ try {
303
+ const sendError = new Error("channel.send failed");
304
+ const s = new ChunkedMessageStream({
305
+ postPlaceholder: async () => {
306
+ throw sendError;
307
+ },
308
+ updateAt: async () => { },
309
+ limit: 100,
310
+ minIntervalMs: 0,
311
+ });
312
+ // First append schedules the (doomed) setup. No throw here — the failure
313
+ // is recorded on the chain, not surfaced synchronously on the first append.
314
+ s.append("hello world");
315
+ // Give the microtask queue a chance to settle the rejected setup chain;
316
+ // the `.catch` must absorb it so Node never reports an unhandled rejection.
317
+ await new Promise((r) => setTimeout(r, 10));
318
+ // The failure surfaces deterministically at finish(), with the original
319
+ // send error attached as the cause.
320
+ await expect(s.finish()).rejects.toThrow(/setup failed/i);
321
+ const caught = await s.finish().catch((e) => e);
322
+ expect(caught.cause).toBe(sendError);
323
+ }
324
+ finally {
325
+ // Let any stray rejection flush before asserting.
326
+ await new Promise((r) => setTimeout(r, 10));
327
+ process.off("unhandledRejection", onUnhandled);
328
+ }
329
+ expect(unhandled).toHaveLength(0);
330
+ });
331
+ it("a rejecting postPlaceholder surfaces at the next append", async () => {
332
+ const sendError = new Error("channel.send failed");
333
+ let calls = 0;
334
+ const s = new ChunkedMessageStream({
335
+ postPlaceholder: async () => {
336
+ calls++;
337
+ throw sendError;
338
+ },
339
+ updateAt: async () => { },
340
+ limit: 100,
341
+ minIntervalMs: 0,
342
+ });
343
+ s.append("first");
344
+ // Wait for the first setup attempt to reject and be recorded.
345
+ await new Promise((r) => setTimeout(r, 10));
346
+ // The next append observes the recorded failure and rethrows it.
347
+ expect(() => s.append("second")).toThrow(/setup failed/i);
348
+ expect(calls).toBe(1);
349
+ });
350
+ it("a fenced code block is never split mid-fence (block-keeps-whole with default 2000 limit)", async () => {
351
+ const discord = makeFakeDiscord();
352
+ const s = new ChunkedMessageStream({
353
+ ...discord,
354
+ // Use default limit (2000)
355
+ minIntervalMs: 0,
356
+ });
357
+ // ~1900 chars of prose followed by a 200-char fenced block — boundary
358
+ // would naturally land inside the fence without the block-keeps-whole logic.
359
+ const prose = "lorem ipsum dolor sit amet. ".repeat(68); // ~1904 chars
360
+ const block = "```python\n" + "print('hello')\n".repeat(10) + "```";
361
+ s.append(prose + block);
362
+ await s.finish();
363
+ const finals = {};
364
+ for (const u of discord.updates)
365
+ finals[u.id] = u.text;
366
+ const chunk1 = finals["1.0"] ?? "";
367
+ // Chunk 1 must not split into the middle of the fence — it either
368
+ // excludes the fence entirely OR the fence is wholly within chunk 1.
369
+ // Given the prose is ~1904 chars and limit is 2000, the fence (>100 chars)
370
+ // should be moved into chunk 2.
371
+ if (chunk1.includes("```")) {
372
+ // If fence ended up in chunk 1, it must be a complete balanced fence.
373
+ const fenceCount = (chunk1.match(/```/g) || []).length;
374
+ expect(fenceCount % 2).toBe(0);
375
+ }
376
+ else {
377
+ // Fence is in chunk 2 — chunk 1 has no backtick fences at all.
378
+ expect(chunk1.includes("```")).toBe(false);
379
+ }
380
+ // The code content must appear somewhere across the chunks.
381
+ const allText = Object.values(finals).join("");
382
+ expect(allText.includes("print('hello')")).toBe(true);
383
+ });
384
+ });
@@ -0,0 +1,29 @@
1
+ import type { CommandSpec } from "@copilotkit/channels";
2
+ /** A REST client subset (discord.js REST exposes `put`). */
3
+ export interface RestLike {
4
+ put(route: `/${string}`, options: {
5
+ body: unknown;
6
+ }): Promise<unknown>;
7
+ }
8
+ interface DiscordOption {
9
+ name: string;
10
+ description: string;
11
+ type: number;
12
+ required: boolean;
13
+ choices?: {
14
+ name: string;
15
+ value: string | number;
16
+ }[];
17
+ }
18
+ /** Map a JSON Schema (CommandSpec.options) to Discord application-command options. */
19
+ export declare function jsonSchemaToDiscordOptions(schema: Record<string, unknown> | undefined): DiscordOption[];
20
+ /** The REST body for one application command. */
21
+ export declare function buildCommandBody(spec: CommandSpec): {
22
+ name: string;
23
+ description: string;
24
+ options: DiscordOption[];
25
+ };
26
+ /** Publish the bot's commands: guild-scoped when guildId is set (instant), else global. */
27
+ export declare function registerCommands(rest: RestLike, appId: string, guildId: string | undefined, specs: readonly CommandSpec[]): Promise<void>;
28
+ export {};
29
+ //# sourceMappingURL=commands.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../src/commands.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAmCxD,4DAA4D;AAC5D,MAAM,WAAW,QAAQ;IACvB,GAAG,CAAC,KAAK,EAAE,IAAI,MAAM,EAAE,EAAE,OAAO,EAAE;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACxE;AAED,UAAU,aAAa;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,EAAE,CAAC;CACtD;AAoDD,sFAAsF;AACtF,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAC1C,aAAa,EAAE,CA4EjB;AAwCD,iDAAiD;AACjD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,WAAW,GAAG;IACnD,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,aAAa,EAAE,CAAC;CAC1B,CAMA;AAED,2FAA2F;AAC3F,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,QAAQ,EACd,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,KAAK,EAAE,SAAS,WAAW,EAAE,GAC5B,OAAO,CAAC,IAAI,CAAC,CAMf"}
@@ -0,0 +1,193 @@
1
+ import { Routes } from "discord.js";
2
+ import { truncateText } from "./render/budget.js";
3
+ // Discord caps command and option descriptions at 100 chars; a longer one
4
+ // rejects the whole command. Truncate defensively.
5
+ const DESCRIPTION_MAX = 100;
6
+ // Discord caps choice name and string-choice value at 100 chars.
7
+ const CHOICE_NAME_MAX = 100;
8
+ const CHOICE_VALUE_MAX = 100;
9
+ // Discord caps choices-per-option and options-per-command at 25.
10
+ const CHOICES_MAX = 25;
11
+ const OPTIONS_MAX = 25;
12
+ // Discord chat-input command/option names must match ^[-_\p{L}\p{N}]{1,32}$ and be lowercase.
13
+ const NAME_MAX = 32;
14
+ /**
15
+ * Normalize a command/option name to satisfy Discord's chat-input naming rule
16
+ * (`^[-_\p{L}\p{N}]{1,32}$`, lowercase). Lowercase, replace any disallowed char
17
+ * with "_", collapse repeated underscores, truncate to 32, and fall back to a
18
+ * sensible default if the result is empty. A malformed name would otherwise
19
+ * reject the whole registration batch.
20
+ */
21
+ function normalizeName(name, fallback) {
22
+ let slug;
23
+ try {
24
+ // Unicode-aware: keep letters/numbers from any script, plus "-" and "_".
25
+ slug = name.toLowerCase().replace(/[^-_\p{L}\p{N}]/gu, "_");
26
+ }
27
+ catch {
28
+ // Pragmatic fallback if \p{L}/\p{N} are unsupported in the target runtime.
29
+ slug = name.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
30
+ }
31
+ slug = slug.replace(/_+/g, "_").slice(0, NAME_MAX);
32
+ return slug.length > 0 ? slug : fallback;
33
+ }
34
+ // ApplicationCommandOptionType values (Discord API).
35
+ const OPT_STRING = 3;
36
+ const OPT_INTEGER = 4;
37
+ const OPT_BOOLEAN = 5;
38
+ const OPT_NUMBER = 10;
39
+ /**
40
+ * Build numeric choices from an enum, skipping entries that don't satisfy `keep`
41
+ * (integers vs any finite number) and entries whose name is empty/whitespace.
42
+ * A non-numeric enum value would serialize to `null` (NaN → null) and make Discord
43
+ * reject the whole command batch, so drop those choices (with a warning) instead.
44
+ * Returns `undefined` (not `[]`) when zero choices survive, since Discord rejects
45
+ * an empty `choices` array.
46
+ */
47
+ function numericChoices(enumValues, keep) {
48
+ if (!Array.isArray(enumValues))
49
+ return undefined;
50
+ const choices = [];
51
+ for (const v of enumValues) {
52
+ const name = String(v);
53
+ if (name.trim().length === 0) {
54
+ console.warn(`[bot-discord] skipping enum choice with empty name for a numeric option.`);
55
+ continue;
56
+ }
57
+ const value = Number(v);
58
+ if (keep(value)) {
59
+ choices.push({ name: truncateText(name, CHOICE_NAME_MAX), value });
60
+ }
61
+ else {
62
+ console.warn(`[bot-discord] skipping non-numeric enum choice "${name}" for a numeric option.`);
63
+ }
64
+ }
65
+ if (choices.length === 0)
66
+ return undefined;
67
+ return clampChoices(choices);
68
+ }
69
+ /** Clamp a choices array to Discord's 25-choice cap, warning (not silently dropping) on overflow. */
70
+ function clampChoices(choices) {
71
+ if (choices.length <= CHOICES_MAX)
72
+ return choices;
73
+ console.warn(`[bot-discord] option has ${choices.length} choices; clamping to Discord's max of ${CHOICES_MAX}.`);
74
+ return choices.slice(0, CHOICES_MAX);
75
+ }
76
+ /** Map a JSON Schema (CommandSpec.options) to Discord application-command options. */
77
+ export function jsonSchemaToDiscordOptions(schema) {
78
+ if (!schema)
79
+ return [];
80
+ const properties = schema.properties ?? {};
81
+ const required = new Set(schema.required ?? []);
82
+ const out = [];
83
+ for (const [rawName, prop] of Object.entries(properties)) {
84
+ const name = normalizeName(rawName, "option");
85
+ const rawDescription = typeof prop.description === "string" ? prop.description : rawName;
86
+ const description = truncateText(rawDescription, DESCRIPTION_MAX);
87
+ const base = { name, description, required: required.has(rawName) };
88
+ // zod-to-json-schema can emit a nullable type as an array (e.g. ["string", "null"]).
89
+ // Normalize to the first non-"null" member so the switch matches the real type
90
+ // instead of falling through to the warn+string default.
91
+ const type = Array.isArray(prop.type)
92
+ ? (prop.type.find((x) => x !== "null") ?? prop.type[0])
93
+ : prop.type;
94
+ switch (type) {
95
+ case "string": {
96
+ const choices = stringChoices(prop.enum);
97
+ out.push({
98
+ ...base,
99
+ type: OPT_STRING,
100
+ ...(choices ? { choices } : {}),
101
+ });
102
+ break;
103
+ }
104
+ case "integer": {
105
+ const choices = numericChoices(prop.enum, (v) => Number.isInteger(v));
106
+ out.push({
107
+ ...base,
108
+ type: OPT_INTEGER,
109
+ ...(choices ? { choices } : {}),
110
+ });
111
+ break;
112
+ }
113
+ case "number": {
114
+ const choices = numericChoices(prop.enum, (v) => Number.isFinite(v));
115
+ out.push({
116
+ ...base,
117
+ type: OPT_NUMBER,
118
+ ...(choices ? { choices } : {}),
119
+ });
120
+ break;
121
+ }
122
+ case "boolean":
123
+ out.push({ ...base, type: OPT_BOOLEAN });
124
+ break;
125
+ default:
126
+ console.warn(`[bot-discord] command option "${rawName}" has unsupported type "${String(type)}"; ` +
127
+ "registering it as a free-text string option.");
128
+ out.push({ ...base, type: OPT_STRING });
129
+ }
130
+ }
131
+ // Discord rejects a command whose optional option precedes a required one
132
+ // ("Required options must be placed before optional options"). Stable-partition
133
+ // so all required options come first, preserving declaration order within each group.
134
+ const ordered = [
135
+ ...out.filter((o) => o.required),
136
+ ...out.filter((o) => !o.required),
137
+ ];
138
+ // Discord caps a command at 25 options. Clamp (don't silently drop) on overflow.
139
+ if (ordered.length > OPTIONS_MAX) {
140
+ console.warn(`[bot-discord] command has ${ordered.length} options; clamping to Discord's max of ${OPTIONS_MAX}.`);
141
+ return ordered.slice(0, OPTIONS_MAX);
142
+ }
143
+ return ordered;
144
+ }
145
+ /**
146
+ * Build string choices from an enum: skip empty/whitespace names, and clamp the
147
+ * array to 25. The choice `value` is what Discord sends back on selection and is
148
+ * validated against the original enum, so it must round-trip exactly — never
149
+ * truncate it. Instead, SKIP (with a warning) any choice whose value exceeds the
150
+ * 100-char cap, since a truncated value would fail enum validation. The `name` is
151
+ * display-only and may still be truncated to 100. Returns `undefined` when zero
152
+ * choices survive, since Discord rejects an empty `choices` array.
153
+ */
154
+ function stringChoices(enumValues) {
155
+ if (!Array.isArray(enumValues))
156
+ return undefined;
157
+ const choices = [];
158
+ for (const v of enumValues) {
159
+ const value = String(v);
160
+ if (value.trim().length === 0) {
161
+ console.warn(`[bot-discord] skipping enum choice with empty name for a string option.`);
162
+ continue;
163
+ }
164
+ if (value.length > CHOICE_VALUE_MAX) {
165
+ console.warn(`[bot-discord] skipping string enum choice whose value exceeds ${CHOICE_VALUE_MAX} chars; ` +
166
+ "a truncated value would no longer match the enum on selection.");
167
+ continue;
168
+ }
169
+ choices.push({
170
+ name: truncateText(value, CHOICE_NAME_MAX),
171
+ value,
172
+ });
173
+ }
174
+ if (choices.length === 0)
175
+ return undefined;
176
+ return clampChoices(choices);
177
+ }
178
+ /** The REST body for one application command. */
179
+ export function buildCommandBody(spec) {
180
+ return {
181
+ name: normalizeName(spec.name, "command"),
182
+ description: truncateText(spec.description || spec.name, DESCRIPTION_MAX),
183
+ options: jsonSchemaToDiscordOptions(spec.options),
184
+ };
185
+ }
186
+ /** Publish the bot's commands: guild-scoped when guildId is set (instant), else global. */
187
+ export async function registerCommands(rest, appId, guildId, specs) {
188
+ const body = specs.map(buildCommandBody);
189
+ const route = guildId
190
+ ? Routes.applicationGuildCommands(appId, guildId)
191
+ : Routes.applicationCommands(appId);
192
+ await rest.put(route, { body });
193
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=commands.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"commands.test.d.ts","sourceRoot":"","sources":["../src/commands.test.ts"],"names":[],"mappings":""}