@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,45 @@
1
+ /**
2
+ * Per-Discord-message streaming text state machine.
3
+ *
4
+ * Wraps a single posted Discord message and lets callers feed it the growing
5
+ * text buffer of a streaming reply. Edits are throttled (Discord's edit rate
6
+ * limit is ~5 edits/5s per channel, tighter than Slack's ~1/sec per message)
7
+ * and *serialised* through a per-message promise queue so concurrent updates
8
+ * can't race — a hazard the previous implementation hit, where End-triggered
9
+ * flush of "ALPHA" could be overtaken by an in-flight flush of "AL" and the
10
+ * final state read "AL".
11
+ *
12
+ * Nothing in this file knows about AG-UI events or discord.js — it's a
13
+ * pure Discord-side primitive: "give me text, I'll keep one Discord message
14
+ * in sync with it."
15
+ */
16
+ export interface MessageStreamConfig {
17
+ /** Function that actually writes `text` to Discord (e.g. message.edit). */
18
+ update: (text: string) => Promise<void>;
19
+ /** Minimum gap between consecutive flushes, in ms (defaults to 1100). */
20
+ minIntervalMs?: number;
21
+ }
22
+ export declare class MessageStream {
23
+ private buffer;
24
+ private posted;
25
+ private queue;
26
+ private lastFlushedAt;
27
+ private flushTimer;
28
+ private readonly minIntervalMs;
29
+ private readonly update;
30
+ constructor(config: MessageStreamConfig);
31
+ /** Replace the in-flight buffer (callers pass the accumulated text). */
32
+ append(text: string): void;
33
+ /**
34
+ * Mark the stream done. Cancels any pending throttled flush, enqueues a
35
+ * final flush, and resolves once the entire queue (including the final
36
+ * flush and anything previously in flight) has drained.
37
+ *
38
+ * After this resolves, the Discord message reflects the final buffer state.
39
+ */
40
+ finish(): Promise<void>;
41
+ private scheduleFlush;
42
+ private enqueueFlush;
43
+ private flushNow;
44
+ }
45
+ //# sourceMappingURL=message-stream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"message-stream.d.ts","sourceRoot":"","sources":["../src/message-stream.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,mBAAmB;IAClC,2EAA2E;IAC3E,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACxC,yEAAyE;IACzE,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAID,qBAAa,aAAa;IACxB,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,KAAK,CAAoC;IACjD,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,UAAU,CAA6B;IAC/C,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkC;gBAE7C,MAAM,EAAE,mBAAmB;IAKvC,wEAAwE;IACxE,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAM1B;;;;;;OAMG;IACG,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAS7B,OAAO,CAAC,aAAa;IAUrB,OAAO,CAAC,YAAY;YAIN,QAAQ;CAwBvB"}
@@ -0,0 +1,76 @@
1
+ const DEFAULT_MIN_INTERVAL_MS = 1100;
2
+ export class MessageStream {
3
+ buffer = "";
4
+ posted = "";
5
+ queue = Promise.resolve();
6
+ lastFlushedAt = 0;
7
+ flushTimer;
8
+ minIntervalMs;
9
+ update;
10
+ constructor(config) {
11
+ this.update = config.update;
12
+ this.minIntervalMs = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
13
+ }
14
+ /** Replace the in-flight buffer (callers pass the accumulated text). */
15
+ append(text) {
16
+ if (text === this.buffer)
17
+ return;
18
+ this.buffer = text;
19
+ this.scheduleFlush();
20
+ }
21
+ /**
22
+ * Mark the stream done. Cancels any pending throttled flush, enqueues a
23
+ * final flush, and resolves once the entire queue (including the final
24
+ * flush and anything previously in flight) has drained.
25
+ *
26
+ * After this resolves, the Discord message reflects the final buffer state.
27
+ */
28
+ async finish() {
29
+ if (this.flushTimer) {
30
+ clearTimeout(this.flushTimer);
31
+ this.flushTimer = undefined;
32
+ }
33
+ this.enqueueFlush();
34
+ await this.queue;
35
+ }
36
+ scheduleFlush() {
37
+ if (this.flushTimer)
38
+ return;
39
+ const elapsed = Date.now() - this.lastFlushedAt;
40
+ const delay = Math.max(0, this.minIntervalMs - elapsed);
41
+ this.flushTimer = setTimeout(() => {
42
+ this.flushTimer = undefined;
43
+ this.enqueueFlush();
44
+ }, delay);
45
+ }
46
+ enqueueFlush() {
47
+ this.queue = this.queue.then(() => this.flushNow());
48
+ }
49
+ async flushNow() {
50
+ if (this.buffer === this.posted)
51
+ return;
52
+ const text = this.buffer;
53
+ try {
54
+ await this.update(text);
55
+ // Only mark `text` as delivered once the edit actually succeeds. If we
56
+ // marked it before the await and update() threw, the guard above would
57
+ // treat the failed text as posted — and a final failed flush (via
58
+ // finish() with no further appends) would be lost forever. Assigning
59
+ // after success leaves `posted` unchanged on failure so a subsequent
60
+ // flush re-attempts the same buffer.
61
+ this.posted = text;
62
+ }
63
+ catch (err) {
64
+ // Rate-limit (429) edits are already handled by discord.js with
65
+ // automatic retry (honoring Retry-After). If we still land here the
66
+ // edit genuinely failed — swallow it: a single failed edit shouldn't
67
+ // sink the stream, and future appends retry with the latest buffer.
68
+ console.error("[message-stream] update failed:", err);
69
+ }
70
+ finally {
71
+ // Set lastFlushedAt *after* the update returns so the throttle
72
+ // measures wall-clock time between completions, not starts.
73
+ this.lastFlushedAt = Date.now();
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=message-stream.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"message-stream.test.d.ts","sourceRoot":"","sources":["../src/message-stream.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { MessageStream } from "./message-stream.js";
3
+ /** A fake Discord `message.edit` that records every call in order and lets the
4
+ * test control how long each takes — useful for proving the in-flight /
5
+ * finish race that bit the original implementation can't happen here. */
6
+ function makeFakeDiscord(updateDurationMs = 0) {
7
+ const calls = [];
8
+ let started = 0;
9
+ let finished = 0;
10
+ const update = async (text) => {
11
+ started++;
12
+ if (updateDurationMs > 0) {
13
+ await new Promise((r) => setTimeout(r, updateDurationMs));
14
+ }
15
+ finished++;
16
+ calls.push({ text, finishedAt: Date.now() });
17
+ };
18
+ return {
19
+ update,
20
+ calls,
21
+ get inFlight() {
22
+ return started - finished;
23
+ },
24
+ };
25
+ }
26
+ describe("MessageStream", () => {
27
+ it("flushes the final buffer on finish() even when end races an in-flight update", async () => {
28
+ // Reproduces the ALPHA → AL bug: a slow message.edit for "AL" is in
29
+ // flight when the stream finishes with "ALPHA". The old implementation
30
+ // could let the slow "AL" update overwrite "ALPHA" because message.edit
31
+ // calls weren't serialised. With the queue, finish() must observe the
32
+ // final buffer regardless of in-flight state.
33
+ const discord = makeFakeDiscord(50);
34
+ const stream = new MessageStream({
35
+ update: discord.update,
36
+ minIntervalMs: 0,
37
+ });
38
+ stream.append("A");
39
+ stream.append("AL");
40
+ // Let the timer fire and the first flush kick off (in-flight)
41
+ await new Promise((r) => setTimeout(r, 5));
42
+ expect(discord.inFlight).toBeGreaterThanOrEqual(0); // best effort
43
+ stream.append("ALP");
44
+ stream.append("ALPH");
45
+ stream.append("ALPHA");
46
+ await stream.finish();
47
+ expect(discord.calls.length).toBeGreaterThan(0);
48
+ // The LAST message.edit — i.e. the final state of the Discord message — must be ALPHA
49
+ expect(discord.calls.at(-1)?.text).toBe("ALPHA");
50
+ // And no concurrent updates were ever in flight (queue invariant)
51
+ expect(discord.inFlight).toBe(0);
52
+ });
53
+ it("never has more than one update in flight at a time", async () => {
54
+ const discord = makeFakeDiscord(20);
55
+ let maxInFlight = 0;
56
+ const sampler = setInterval(() => {
57
+ if (discord.inFlight > maxInFlight)
58
+ maxInFlight = discord.inFlight;
59
+ }, 1);
60
+ const stream = new MessageStream({
61
+ update: discord.update,
62
+ minIntervalMs: 0,
63
+ });
64
+ for (let i = 1; i <= 20; i++) {
65
+ stream.append("x".repeat(i));
66
+ await new Promise((r) => setTimeout(r, 2));
67
+ }
68
+ await stream.finish();
69
+ clearInterval(sampler);
70
+ expect(maxInFlight).toBeLessThanOrEqual(1);
71
+ });
72
+ it("throttles flushes to roughly minIntervalMs between completions", async () => {
73
+ const discord = makeFakeDiscord(0);
74
+ const stream = new MessageStream({
75
+ update: discord.update,
76
+ minIntervalMs: 1100,
77
+ });
78
+ const start = Date.now();
79
+ for (let i = 0; i < 10; i++) {
80
+ stream.append(`buf${i}`);
81
+ await new Promise((r) => setTimeout(r, 5));
82
+ }
83
+ await stream.finish();
84
+ // 10 rapid appends should NOT have produced 10 message.edit calls; the
85
+ // throttle should have coalesced them. Exact count varies by timing but
86
+ // should be well under 10.
87
+ expect(discord.calls.length).toBeLessThan(10);
88
+ // Final state must still match the last append
89
+ expect(discord.calls.at(-1)?.text).toBe("buf9");
90
+ // And the test ran for at least one throttle window
91
+ expect(Date.now() - start).toBeGreaterThanOrEqual(0);
92
+ });
93
+ it("collapses duplicate appends into zero updates", async () => {
94
+ const discord = makeFakeDiscord(0);
95
+ const stream = new MessageStream({
96
+ update: discord.update,
97
+ minIntervalMs: 0,
98
+ });
99
+ stream.append("hello");
100
+ stream.append("hello");
101
+ stream.append("hello");
102
+ await stream.finish();
103
+ // One actual update, not three
104
+ expect(discord.calls).toHaveLength(1);
105
+ expect(discord.calls[0]?.text).toBe("hello");
106
+ });
107
+ it("is a no-op when finish() is called before any append", async () => {
108
+ const discord = makeFakeDiscord(0);
109
+ const stream = new MessageStream({
110
+ update: discord.update,
111
+ minIntervalMs: 0,
112
+ });
113
+ await stream.finish();
114
+ expect(discord.calls).toHaveLength(0);
115
+ });
116
+ it("coalesces rapid successive append/update calls within the throttle window into fewer underlying update calls and always flushes the final state", async () => {
117
+ const discord = makeFakeDiscord(0);
118
+ const stream = new MessageStream({
119
+ update: discord.update,
120
+ minIntervalMs: 1100,
121
+ });
122
+ // Fire 15 appends very rapidly (every 10ms), well within the 1100ms throttle window
123
+ for (let i = 1; i <= 15; i++) {
124
+ stream.append("msg".repeat(i));
125
+ await new Promise((r) => setTimeout(r, 10));
126
+ }
127
+ await stream.finish();
128
+ // All 15 rapid appends over ~150ms should coalesce to far fewer than 15 calls
129
+ // (the 1100ms throttle window means at most ~1 intermediate flush in that window)
130
+ expect(discord.calls.length).toBeLessThan(15);
131
+ // The final state must always reflect the last appended value
132
+ expect(discord.calls.at(-1)?.text).toBe("msg".repeat(15));
133
+ // No concurrent updates were in flight
134
+ expect(discord.inFlight).toBe(0);
135
+ });
136
+ it("retries the final buffer on a subsequent flush when the previous update() failed", async () => {
137
+ // The previous implementation marked `posted = text` *before* awaiting
138
+ // update(). If that final update() rejected, the guard treated the failed
139
+ // text as delivered, so the last segment was lost forever — never retried.
140
+ // With the fix, `posted` is only set after update() resolves, so a failed
141
+ // flush leaves the buffer un-posted and the next flush re-sends it.
142
+ const sent = [];
143
+ let calls = 0;
144
+ const update = async (text) => {
145
+ calls++;
146
+ if (calls === 1) {
147
+ // First flush fails (e.g. genuine edit failure after discord.js retries).
148
+ throw new Error("boom");
149
+ }
150
+ sent.push(text);
151
+ };
152
+ const stream = new MessageStream({ update, minIntervalMs: 0 });
153
+ stream.append("FINAL");
154
+ // First flush attempt rejects; `posted` must stay "" so the buffer is retried.
155
+ await stream.finish();
156
+ expect(sent).toHaveLength(0); // first attempt threw, nothing delivered yet
157
+ // A subsequent flush (here a fresh finish()) must re-attempt the same buffer.
158
+ await stream.finish();
159
+ expect(calls).toBe(2);
160
+ expect(sent).toEqual(["FINAL"]); // the final segment is re-sent after the failure
161
+ });
162
+ });
@@ -0,0 +1,26 @@
1
+ interface LiveInteraction {
2
+ id: string;
3
+ deferred?: boolean;
4
+ replied?: boolean;
5
+ }
6
+ export interface PendingOpts {
7
+ /** How long before the 3s window we auto-defer (e.g. 2500). */
8
+ ackBufferMs: number;
9
+ /** Ack the interaction (deferUpdate for components, deferReply for commands). */
10
+ defer: (interaction: LiveInteraction) => Promise<void>;
11
+ }
12
+ /** Tracks live discord.js interactions so a handler can open a modal before the adapter acks. */
13
+ export declare class PendingInteractions {
14
+ private opts;
15
+ private entries;
16
+ constructor(opts: PendingOpts);
17
+ /** Stash a live interaction, arm the auto-defer timer, return its triggerId. */
18
+ register(interaction: LiveInteraction): string;
19
+ /** Run `fn` (e.g. showModal) only if still unresponded. Returns whether it ran. */
20
+ respondWith(triggerId: string, fn: (interaction: LiveInteraction) => Promise<void>): Promise<boolean>;
21
+ /** After dispatch: ack if the handler never responded. Then forget the entry. */
22
+ settle(triggerId: string): Promise<void>;
23
+ private ack;
24
+ }
25
+ export {};
26
+ //# sourceMappingURL=pending-interactions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pending-interactions.d.ts","sourceRoot":"","sources":["../src/pending-interactions.ts"],"names":[],"mappings":"AAAA,UAAU,eAAe;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAMD,MAAM,WAAW,WAAW;IAC1B,+DAA+D;IAC/D,WAAW,EAAE,MAAM,CAAC;IACpB,iFAAiF;IACjF,KAAK,EAAE,CAAC,WAAW,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACxD;AAED,iGAAiG;AACjG,qBAAa,mBAAmB;IAElB,OAAO,CAAC,IAAI;IADxB,OAAO,CAAC,OAAO,CAA4B;gBACvB,IAAI,EAAE,WAAW;IAErC,gFAAgF;IAChF,QAAQ,CAAC,WAAW,EAAE,eAAe,GAAG,MAAM;IAS9C,mFAAmF;IAC7E,WAAW,CACf,SAAS,EAAE,MAAM,EACjB,EAAE,EAAE,CAAC,WAAW,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,GAClD,OAAO,CAAC,OAAO,CAAC;IASnB,iFAAiF;IAC3E,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;YAOhC,GAAG;CAUlB"}
@@ -0,0 +1,50 @@
1
+ /** Tracks live discord.js interactions so a handler can open a modal before the adapter acks. */
2
+ export class PendingInteractions {
3
+ opts;
4
+ entries = new Map();
5
+ constructor(opts) {
6
+ this.opts = opts;
7
+ }
8
+ /** Stash a live interaction, arm the auto-defer timer, return its triggerId. */
9
+ register(interaction) {
10
+ const prev = this.entries.get(interaction.id);
11
+ if (prev?.timer)
12
+ clearTimeout(prev.timer);
13
+ const entry = { interaction, responded: false };
14
+ entry.timer = setTimeout(() => void this.ack(entry), this.opts.ackBufferMs);
15
+ this.entries.set(interaction.id, entry);
16
+ return interaction.id;
17
+ }
18
+ /** Run `fn` (e.g. showModal) only if still unresponded. Returns whether it ran. */
19
+ async respondWith(triggerId, fn) {
20
+ const entry = this.entries.get(triggerId);
21
+ if (!entry || entry.responded)
22
+ return false;
23
+ entry.responded = true;
24
+ if (entry.timer)
25
+ clearTimeout(entry.timer);
26
+ await fn(entry.interaction);
27
+ return true;
28
+ }
29
+ /** After dispatch: ack if the handler never responded. Then forget the entry. */
30
+ async settle(triggerId) {
31
+ const entry = this.entries.get(triggerId);
32
+ if (!entry)
33
+ return;
34
+ await this.ack(entry);
35
+ this.entries.delete(triggerId);
36
+ }
37
+ async ack(entry) {
38
+ if (entry.responded)
39
+ return;
40
+ entry.responded = true;
41
+ if (entry.timer)
42
+ clearTimeout(entry.timer);
43
+ try {
44
+ await this.opts.defer(entry.interaction);
45
+ }
46
+ catch {
47
+ /* interaction already gone; nothing to do */
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=pending-interactions.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pending-interactions.test.d.ts","sourceRoot":"","sources":["../src/pending-interactions.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { PendingInteractions } from "./pending-interactions.js";
3
+ beforeEach(() => vi.useFakeTimers());
4
+ afterEach(() => vi.useRealTimers());
5
+ function fakeInteraction() {
6
+ return {
7
+ id: "i1",
8
+ deferred: false,
9
+ replied: false,
10
+ deferUpdate: vi.fn(async function () {
11
+ this.deferred = true;
12
+ }),
13
+ showModal: vi.fn(async () => { }),
14
+ };
15
+ }
16
+ describe("PendingInteractions", () => {
17
+ it("auto-defers at the deadline when nothing responds", async () => {
18
+ const reg = new PendingInteractions({
19
+ ackBufferMs: 2500,
20
+ defer: (i) => i.deferUpdate(),
21
+ });
22
+ const i = fakeInteraction();
23
+ reg.register(i);
24
+ await vi.advanceTimersByTimeAsync(2500);
25
+ expect(i.deferUpdate).toHaveBeenCalledTimes(1);
26
+ });
27
+ it("respondWith runs the responder and cancels the deferral", async () => {
28
+ const reg = new PendingInteractions({
29
+ ackBufferMs: 2500,
30
+ defer: (i) => i.deferUpdate(),
31
+ });
32
+ const i = fakeInteraction();
33
+ const tid = reg.register(i);
34
+ const ok = await reg.respondWith(tid, (live) => live.showModal({}));
35
+ expect(ok).toBe(true);
36
+ expect(i.showModal).toHaveBeenCalled();
37
+ await vi.advanceTimersByTimeAsync(5000);
38
+ expect(i.deferUpdate).not.toHaveBeenCalled();
39
+ });
40
+ it("respondWith returns false once the interaction was already acked", async () => {
41
+ const reg = new PendingInteractions({
42
+ ackBufferMs: 2500,
43
+ defer: (i) => i.deferUpdate(),
44
+ });
45
+ const i = fakeInteraction();
46
+ const tid = reg.register(i);
47
+ await vi.advanceTimersByTimeAsync(2500); // auto-defer fires
48
+ const ok = await reg.respondWith(tid, (live) => live.showModal({}));
49
+ expect(ok).toBe(false);
50
+ expect(i.showModal).not.toHaveBeenCalled();
51
+ });
52
+ it("settle acks an unresponded interaction immediately (fast handler path)", async () => {
53
+ const reg = new PendingInteractions({
54
+ ackBufferMs: 2500,
55
+ defer: (i) => i.deferUpdate(),
56
+ });
57
+ const i = fakeInteraction();
58
+ const tid = reg.register(i);
59
+ await reg.settle(tid);
60
+ expect(i.deferUpdate).toHaveBeenCalledTimes(1);
61
+ });
62
+ it("re-registering the same id clears the old timer so defer fires at most once", async () => {
63
+ const defer = vi.fn(async () => { });
64
+ const reg = new PendingInteractions({ ackBufferMs: 2500, defer });
65
+ const i = { id: "dup", deferred: false, replied: false };
66
+ // Register twice with the same id — simulates a re-registration race
67
+ reg.register(i);
68
+ reg.register(i);
69
+ // Advance past the ackBufferMs — both timers would fire if the first wasn't cleared
70
+ await vi.advanceTimersByTimeAsync(5000);
71
+ expect(defer).toHaveBeenCalledTimes(1);
72
+ });
73
+ });
@@ -0,0 +1,28 @@
1
+ /** Per-element ceilings the Discord API enforces. Degradation is truncate/clamp — never silent drop. */
2
+ export declare const DISCORD_LIMITS: {
3
+ readonly componentsPerMessage: 40;
4
+ readonly actionRows: 5;
5
+ readonly buttonsPerRow: 5;
6
+ readonly selectOptions: 25;
7
+ readonly selectPlaceholder: 150;
8
+ readonly textDisplayChars: 2000;
9
+ readonly totalTextChars: 4000;
10
+ readonly buttonLabel: 80;
11
+ readonly customId: 100;
12
+ readonly headerText: 256;
13
+ };
14
+ /** Truncate to max chars, appending an ellipsis marker if the input was longer. Never returns >max. */
15
+ export declare function truncateText(text: string, max: number): string;
16
+ /** Clamp an array to max items; return the kept items plus how many overflowed (for an overflow signal). */
17
+ export declare function clampArray<T>(items: readonly T[], max: number): {
18
+ items: T[];
19
+ overflow: number;
20
+ };
21
+ /**
22
+ * Truncate text that may contain ``` code fences, then re-balance: if truncation
23
+ * left an odd number of fence delimiters (i.e. a fence was cut open), append a
24
+ * closing fence. The result never exceeds `max` — the closing fence is added by
25
+ * trimming further if needed so the total stays within budget.
26
+ */
27
+ export declare function truncateFenced(text: string, max: number): string;
28
+ //# sourceMappingURL=budget.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"budget.d.ts","sourceRoot":"","sources":["../../src/render/budget.ts"],"names":[],"mappings":"AAAA,wGAAwG;AACxG,eAAO,MAAM,cAAc;;;;;;;;;;;CAWjB,CAAC;AAEX,uGAAuG;AACvG,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAI9D;AAED,4GAA4G;AAC5G,wBAAgB,UAAU,CAAC,CAAC,EAC1B,KAAK,EAAE,SAAS,CAAC,EAAE,EACnB,GAAG,EAAE,MAAM,GACV;IAAE,KAAK,EAAE,CAAC,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAGlC;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAchE"}
@@ -0,0 +1,49 @@
1
+ /** Per-element ceilings the Discord API enforces. Degradation is truncate/clamp — never silent drop. */
2
+ export const DISCORD_LIMITS = {
3
+ componentsPerMessage: 40, // total components (nested counted) in a Components V2 message
4
+ actionRows: 5, // action rows per message
5
+ buttonsPerRow: 5, // buttons per action row
6
+ selectOptions: 25, // options per string select
7
+ selectPlaceholder: 150, // chars per select placeholder
8
+ textDisplayChars: 2000, // chars per TextDisplay
9
+ totalTextChars: 4000, // summed text across the message
10
+ buttonLabel: 80, // button label chars
11
+ customId: 100, // custom_id chars
12
+ headerText: 256, // header line chars (TextDisplay with `# ` prefix)
13
+ };
14
+ /** Truncate to max chars, appending an ellipsis marker if the input was longer. Never returns >max. */
15
+ export function truncateText(text, max) {
16
+ if (text.length <= max)
17
+ return text;
18
+ if (max <= 1)
19
+ return text.slice(0, max);
20
+ return text.slice(0, max - 1) + "…"; // …
21
+ }
22
+ /** Clamp an array to max items; return the kept items plus how many overflowed (for an overflow signal). */
23
+ export function clampArray(items, max) {
24
+ if (items.length <= max)
25
+ return { items: [...items], overflow: 0 };
26
+ return { items: items.slice(0, max), overflow: items.length - max };
27
+ }
28
+ /**
29
+ * Truncate text that may contain ``` code fences, then re-balance: if truncation
30
+ * left an odd number of fence delimiters (i.e. a fence was cut open), append a
31
+ * closing fence. The result never exceeds `max` — the closing fence is added by
32
+ * trimming further if needed so the total stays within budget.
33
+ */
34
+ export function truncateFenced(text, max) {
35
+ const truncated = truncateText(text, max);
36
+ const fences = (truncated.match(/```/g) ?? []).length;
37
+ if (fences % 2 === 0)
38
+ return truncated;
39
+ // A fence was left open. Close it, trimming to keep within `max`.
40
+ const closer = "\n```";
41
+ // If there's no room for even the closer, we can't append a balanced fence
42
+ // without exceeding `max`. Fall back to a plain truncation (no fence append)
43
+ // so the documented "never exceeds max" invariant holds.
44
+ if (max <= closer.length)
45
+ return truncateText(text, max);
46
+ const room = max - closer.length;
47
+ const body = truncated.length > room ? truncated.slice(0, Math.max(0, room)) : truncated;
48
+ return body + closer;
49
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=budget.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"budget.test.d.ts","sourceRoot":"","sources":["../../src/render/budget.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { DISCORD_LIMITS, truncateText, truncateFenced, clampArray, } from "./budget.js";
3
+ describe("truncateText", () => {
4
+ it("returns short text unchanged", () => {
5
+ expect(truncateText("hi", 10)).toBe("hi");
6
+ });
7
+ it("truncates with an ellipsis at the limit", () => {
8
+ const out = truncateText("abcdefghij", 5);
9
+ expect(out.length).toBeLessThanOrEqual(5);
10
+ expect(out.endsWith("…")).toBe(true);
11
+ });
12
+ });
13
+ describe("clampArray", () => {
14
+ it("keeps all items under the cap and reports zero overflow", () => {
15
+ expect(clampArray([1, 2, 3], 5)).toEqual({ items: [1, 2, 3], overflow: 0 });
16
+ });
17
+ it("clamps over the cap and reports overflow count", () => {
18
+ expect(clampArray([1, 2, 3, 4], 2)).toEqual({ items: [1, 2], overflow: 2 });
19
+ });
20
+ });
21
+ describe("truncateFenced", () => {
22
+ it("leaves balanced-fence text within budget unchanged", () => {
23
+ const text = "```\nhi\n```";
24
+ expect(truncateFenced(text, 100)).toBe(text);
25
+ });
26
+ it("re-balances an open fence left by truncation", () => {
27
+ // A fenced table that gets cut mid-content leaves an odd fence count.
28
+ const text = "```\n| A | B |\n| 1 | 2 |\n| 3 | 4 |\n```";
29
+ const out = truncateFenced(text, 20);
30
+ expect(out.length).toBeLessThanOrEqual(20);
31
+ // Even number of fence delimiters → the block is closed.
32
+ expect((out.match(/```/g) ?? []).length % 2).toBe(0);
33
+ expect(out.endsWith("```")).toBe(true);
34
+ });
35
+ it("does not add a fence when none is open", () => {
36
+ const out = truncateFenced("plain text with no fences here", 10);
37
+ expect(out).not.toContain("```");
38
+ expect(out.length).toBeLessThanOrEqual(10);
39
+ });
40
+ it("never exceeds max even when max is smaller than the closing fence", () => {
41
+ // An open fence with max=3 (< "\n```".length = 4) must not append a 4-char
42
+ // closer that overruns max; it falls back to a plain clamp.
43
+ const longFenced = "```\n" + "a".repeat(50);
44
+ const out = truncateFenced(longFenced, 3);
45
+ expect(out.length).toBeLessThanOrEqual(3);
46
+ });
47
+ });
48
+ describe("DISCORD_LIMITS", () => {
49
+ it("pins the documented ceilings", () => {
50
+ expect(DISCORD_LIMITS.componentsPerMessage).toBe(40);
51
+ expect(DISCORD_LIMITS.customId).toBe(100);
52
+ expect(DISCORD_LIMITS.buttonsPerRow).toBe(5);
53
+ expect(DISCORD_LIMITS.actionRows).toBe(5);
54
+ expect(DISCORD_LIMITS.selectOptions).toBe(25);
55
+ expect(DISCORD_LIMITS.textDisplayChars).toBe(2000);
56
+ expect(DISCORD_LIMITS.totalTextChars).toBe(4000);
57
+ expect(DISCORD_LIMITS.selectPlaceholder).toBe(150);
58
+ });
59
+ });
@@ -0,0 +1,16 @@
1
+ import { ContainerBuilder } from "discord.js";
2
+ import type { BotNode } from "@copilotkit/channels-ui";
3
+ /**
4
+ * Render a cross-platform IR tree (already expanded + action-bound, so event
5
+ * props are `{ id }`) into a single top-level Components V2 `ContainerBuilder`.
6
+ *
7
+ * Total renderer: unknown intrinsics are skipped, never thrown. Per-element
8
+ * limits apply via truncate/clamp; nothing is silently dropped.
9
+ */
10
+ export declare function renderComponents(ir: BotNode[]): ContainerBuilder;
11
+ /** Ready-to-send payload for channel.send / message.edit. */
12
+ export declare function renderDiscordMessage(ir: BotNode[]): {
13
+ components: ContainerBuilder[];
14
+ flags: number;
15
+ };
16
+ //# sourceMappingURL=components-v2.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"components-v2.d.ts","sourceRoot":"","sources":["../../src/render/components-v2.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAYjB,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAC;AAwFvD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,OAAO,EAAE,GAAG,gBAAgB,CAiBhE;AAED,6DAA6D;AAC7D,wBAAgB,oBAAoB,CAAC,EAAE,EAAE,OAAO,EAAE,GAAG;IACnD,UAAU,EAAE,gBAAgB,EAAE,CAAC;IAC/B,KAAK,EAAE,MAAM,CAAC;CACf,CAKA"}