@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.
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/dist/__tests__/ephemeral.test.d.ts +2 -0
- package/dist/__tests__/ephemeral.test.d.ts.map +1 -0
- package/dist/__tests__/ephemeral.test.js +26 -0
- package/dist/__tests__/intents.test.d.ts +2 -0
- package/dist/__tests__/intents.test.d.ts.map +1 -0
- package/dist/__tests__/intents.test.js +8 -0
- package/dist/__tests__/modal-submit.test.d.ts +2 -0
- package/dist/__tests__/modal-submit.test.d.ts.map +1 -0
- package/dist/__tests__/modal-submit.test.js +26 -0
- package/dist/__tests__/reactions.test.d.ts +2 -0
- package/dist/__tests__/reactions.test.d.ts.map +1 -0
- package/dist/__tests__/reactions.test.js +87 -0
- package/dist/adapter.d.ts +118 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +567 -0
- package/dist/adapter.test.d.ts +2 -0
- package/dist/adapter.test.d.ts.map +1 -0
- package/dist/adapter.test.js +343 -0
- package/dist/auto-close-streaming.d.ts +61 -0
- package/dist/auto-close-streaming.d.ts.map +1 -0
- package/dist/auto-close-streaming.js +276 -0
- package/dist/auto-close-streaming.test.d.ts +2 -0
- package/dist/auto-close-streaming.test.d.ts.map +1 -0
- package/dist/auto-close-streaming.test.js +149 -0
- package/dist/built-in-context.d.ts +20 -0
- package/dist/built-in-context.d.ts.map +1 -0
- package/dist/built-in-context.js +30 -0
- package/dist/built-in-context.test.d.ts +2 -0
- package/dist/built-in-context.test.d.ts.map +1 -0
- package/dist/built-in-context.test.js +21 -0
- package/dist/built-in-tools.d.ts +4 -0
- package/dist/built-in-tools.d.ts.map +1 -0
- package/dist/built-in-tools.js +27 -0
- package/dist/built-in-tools.test.d.ts +2 -0
- package/dist/built-in-tools.test.d.ts.map +1 -0
- package/dist/built-in-tools.test.js +30 -0
- package/dist/chunked-message-stream.d.ts +94 -0
- package/dist/chunked-message-stream.d.ts.map +1 -0
- package/dist/chunked-message-stream.js +203 -0
- package/dist/chunked-message-stream.test.d.ts +2 -0
- package/dist/chunked-message-stream.test.d.ts.map +1 -0
- package/dist/chunked-message-stream.test.js +384 -0
- package/dist/commands.d.ts +29 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +193 -0
- package/dist/commands.test.d.ts +2 -0
- package/dist/commands.test.d.ts.map +1 -0
- package/dist/commands.test.js +281 -0
- package/dist/conversation-store.d.ts +45 -0
- package/dist/conversation-store.d.ts.map +1 -0
- package/dist/conversation-store.js +78 -0
- package/dist/conversation-store.test.d.ts +2 -0
- package/dist/conversation-store.test.d.ts.map +1 -0
- package/dist/conversation-store.test.js +106 -0
- package/dist/discord-listener.d.ts +88 -0
- package/dist/discord-listener.d.ts.map +1 -0
- package/dist/discord-listener.js +118 -0
- package/dist/discord-listener.test.d.ts +2 -0
- package/dist/discord-listener.test.d.ts.map +1 -0
- package/dist/discord-listener.test.js +361 -0
- package/dist/download-files.d.ts +94 -0
- package/dist/download-files.d.ts.map +1 -0
- package/dist/download-files.js +145 -0
- package/dist/download-files.test.d.ts +2 -0
- package/dist/download-files.test.d.ts.map +1 -0
- package/dist/download-files.test.js +181 -0
- package/dist/event-renderer.d.ts +36 -0
- package/dist/event-renderer.d.ts.map +1 -0
- package/dist/event-renderer.js +270 -0
- package/dist/event-renderer.test.d.ts +2 -0
- package/dist/event-renderer.test.d.ts.map +1 -0
- package/dist/event-renderer.test.js +160 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/interaction.d.ts +11 -0
- package/dist/interaction.d.ts.map +1 -0
- package/dist/interaction.js +138 -0
- package/dist/interaction.test.d.ts +2 -0
- package/dist/interaction.test.d.ts.map +1 -0
- package/dist/interaction.test.js +120 -0
- package/dist/markdown.d.ts +7 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +66 -0
- package/dist/markdown.test.d.ts +2 -0
- package/dist/markdown.test.d.ts.map +1 -0
- package/dist/markdown.test.js +47 -0
- package/dist/message-stream.d.ts +45 -0
- package/dist/message-stream.d.ts.map +1 -0
- package/dist/message-stream.js +76 -0
- package/dist/message-stream.test.d.ts +2 -0
- package/dist/message-stream.test.d.ts.map +1 -0
- package/dist/message-stream.test.js +162 -0
- package/dist/pending-interactions.d.ts +26 -0
- package/dist/pending-interactions.d.ts.map +1 -0
- package/dist/pending-interactions.js +50 -0
- package/dist/pending-interactions.test.d.ts +2 -0
- package/dist/pending-interactions.test.d.ts.map +1 -0
- package/dist/pending-interactions.test.js +73 -0
- package/dist/render/budget.d.ts +28 -0
- package/dist/render/budget.d.ts.map +1 -0
- package/dist/render/budget.js +49 -0
- package/dist/render/budget.test.d.ts +2 -0
- package/dist/render/budget.test.d.ts.map +1 -0
- package/dist/render/budget.test.js +59 -0
- package/dist/render/components-v2.d.ts +16 -0
- package/dist/render/components-v2.d.ts.map +1 -0
- package/dist/render/components-v2.js +396 -0
- package/dist/render/components-v2.test.d.ts +2 -0
- package/dist/render/components-v2.test.d.ts.map +1 -0
- package/dist/render/components-v2.test.js +395 -0
- package/dist/render/modal.d.ts +12 -0
- package/dist/render/modal.d.ts.map +1 -0
- package/dist/render/modal.js +44 -0
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.test.d.ts +2 -0
- package/dist/types.test.d.ts.map +1 -0
- package/dist/types.test.js +12 -0
- 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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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"}
|