@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,395 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { ComponentType, ButtonStyle } from "discord.js";
|
|
3
|
+
import { renderComponents } from "./components-v2.js";
|
|
4
|
+
const text = (value) => ({ type: "text", props: { value } });
|
|
5
|
+
const node = (type, props = {}) => ({
|
|
6
|
+
type,
|
|
7
|
+
props,
|
|
8
|
+
});
|
|
9
|
+
describe("renderComponents", () => {
|
|
10
|
+
it("wraps a header + section in a container with text displays", () => {
|
|
11
|
+
const ir = [
|
|
12
|
+
node("message", {
|
|
13
|
+
children: [
|
|
14
|
+
node("header", { children: text("Hello") }),
|
|
15
|
+
node("section", { children: text("Body **bold**") }),
|
|
16
|
+
],
|
|
17
|
+
}),
|
|
18
|
+
];
|
|
19
|
+
const container = renderComponents(ir);
|
|
20
|
+
const json = container.toJSON();
|
|
21
|
+
expect(json.type).toBe(ComponentType.Container);
|
|
22
|
+
const texts = json.components.filter((c) => c.type === ComponentType.TextDisplay);
|
|
23
|
+
expect(texts[0].content).toBe("# Hello");
|
|
24
|
+
expect(texts[1].content).toContain("Body **bold**");
|
|
25
|
+
});
|
|
26
|
+
it("maps accent to the container accent color (#5865F2 → int)", () => {
|
|
27
|
+
const ir = [
|
|
28
|
+
node("message", { accent: "#5865F2", children: text("hi") }),
|
|
29
|
+
];
|
|
30
|
+
const json = renderComponents(ir).toJSON();
|
|
31
|
+
expect(json.accent_color).toBe(0x5865f2);
|
|
32
|
+
});
|
|
33
|
+
it("renders a button with its minted custom_id and style", () => {
|
|
34
|
+
const ir = [
|
|
35
|
+
node("message", {
|
|
36
|
+
children: node("actions", {
|
|
37
|
+
children: node("button", {
|
|
38
|
+
children: text("Approve"),
|
|
39
|
+
style: "primary",
|
|
40
|
+
onClick: { id: "ck:abc123" },
|
|
41
|
+
}),
|
|
42
|
+
}),
|
|
43
|
+
}),
|
|
44
|
+
];
|
|
45
|
+
const json = renderComponents(ir).toJSON();
|
|
46
|
+
const row = json.components.find((c) => c.type === ComponentType.ActionRow);
|
|
47
|
+
expect(row).toBeTruthy();
|
|
48
|
+
const btn = row.components[0];
|
|
49
|
+
expect(btn.custom_id).toBe("ck:abc123");
|
|
50
|
+
expect(btn.label).toBe("Approve");
|
|
51
|
+
expect(btn.style).toBe(ButtonStyle.Primary);
|
|
52
|
+
});
|
|
53
|
+
it("renders a url button as a Link-style button with no custom_id", () => {
|
|
54
|
+
const ir = [
|
|
55
|
+
node("message", {
|
|
56
|
+
children: node("actions", {
|
|
57
|
+
children: node("button", {
|
|
58
|
+
children: text("Open"),
|
|
59
|
+
url: "https://dash/deploy/42",
|
|
60
|
+
}),
|
|
61
|
+
}),
|
|
62
|
+
}),
|
|
63
|
+
];
|
|
64
|
+
const json = renderComponents(ir).toJSON();
|
|
65
|
+
const row = json.components.find((c) => c.type === ComponentType.ActionRow);
|
|
66
|
+
const btn = row.components[0];
|
|
67
|
+
expect(btn.style).toBe(ButtonStyle.Link);
|
|
68
|
+
expect(btn.url).toBe("https://dash/deploy/42");
|
|
69
|
+
expect(btn.custom_id).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
it("sets max_values on a multi-select so the decoder reads an array", () => {
|
|
72
|
+
const ir = [
|
|
73
|
+
node("message", {
|
|
74
|
+
children: node("actions", {
|
|
75
|
+
children: node("select", {
|
|
76
|
+
multi: true,
|
|
77
|
+
onSelect: { id: "ck:ms" },
|
|
78
|
+
options: [
|
|
79
|
+
{ label: "Core", value: "core" },
|
|
80
|
+
{ label: "Infra", value: "infra" },
|
|
81
|
+
],
|
|
82
|
+
}),
|
|
83
|
+
}),
|
|
84
|
+
}),
|
|
85
|
+
];
|
|
86
|
+
const json = renderComponents(ir).toJSON();
|
|
87
|
+
const row = json.components.find((c) => c.type === ComponentType.ActionRow);
|
|
88
|
+
const select = row.components[0];
|
|
89
|
+
expect(select.type).toBe(ComponentType.StringSelect);
|
|
90
|
+
expect(select.max_values).toBe(2);
|
|
91
|
+
expect(select.min_values).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
it("chunks more than 5 buttons into multiple action rows", () => {
|
|
94
|
+
const buttons = Array.from({ length: 7 }, (_, i) => node("button", { children: text(`b${i}`), onClick: { id: `ck:${i}` } }));
|
|
95
|
+
const ir = [
|
|
96
|
+
node("message", { children: node("actions", { children: buttons }) }),
|
|
97
|
+
];
|
|
98
|
+
const json = renderComponents(ir).toJSON();
|
|
99
|
+
const rows = json.components.filter((c) => c.type === ComponentType.ActionRow);
|
|
100
|
+
expect(rows.length).toBe(2);
|
|
101
|
+
expect(rows[0].components.length).toBe(5);
|
|
102
|
+
expect(rows[1].components.length).toBe(2);
|
|
103
|
+
});
|
|
104
|
+
it("renders a divider as a separator", () => {
|
|
105
|
+
const ir = [node("message", { children: node("divider") })];
|
|
106
|
+
const json = renderComponents(ir).toJSON();
|
|
107
|
+
expect(json.components.some((c) => c.type === ComponentType.Separator)).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
it("clamps an IR with > componentsPerMessage components and appends one overflow marker", () => {
|
|
110
|
+
// 60 dividers each cost one component; the message caps at 40 total.
|
|
111
|
+
const dividers = Array.from({ length: 60 }, () => node("divider"));
|
|
112
|
+
const ir = [node("message", { children: dividers })];
|
|
113
|
+
const json = renderComponents(ir).toJSON();
|
|
114
|
+
expect(json.components.length).toBeLessThanOrEqual(40);
|
|
115
|
+
const overflow = json.components.filter((c) => c.type === ComponentType.TextDisplay &&
|
|
116
|
+
c.content === "_…content truncated_");
|
|
117
|
+
expect(overflow.length).toBe(1);
|
|
118
|
+
});
|
|
119
|
+
it("clamps total text across the message at totalTextChars", () => {
|
|
120
|
+
// Three 2000-char sections (each within textDisplayChars) sum to 6000 > 4000.
|
|
121
|
+
const chunk = "x".repeat(2000);
|
|
122
|
+
const sections = Array.from({ length: 3 }, () => node("section", { children: text(chunk) }));
|
|
123
|
+
const ir = [node("message", { children: sections })];
|
|
124
|
+
const json = renderComponents(ir).toJSON();
|
|
125
|
+
const totalText = json.components
|
|
126
|
+
.filter((c) => c.type === ComponentType.TextDisplay)
|
|
127
|
+
.reduce((sum, c) => sum + (c.content?.length ?? 0), 0);
|
|
128
|
+
// Summed text never exceeds the budget plus the short overflow marker.
|
|
129
|
+
expect(totalText).toBeLessThanOrEqual(4000 + "_…content truncated_".length);
|
|
130
|
+
});
|
|
131
|
+
it("clamps a select to 25 real options with no fake selectable sentinel when it exceeds 25", () => {
|
|
132
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
133
|
+
const options = Array.from({ length: 30 }, (_, i) => ({
|
|
134
|
+
label: `opt${i}`,
|
|
135
|
+
value: `v${i}`,
|
|
136
|
+
}));
|
|
137
|
+
const ir = [
|
|
138
|
+
node("message", {
|
|
139
|
+
children: node("actions", {
|
|
140
|
+
children: node("select", { onSelect: { id: "ck:sel" }, options }),
|
|
141
|
+
}),
|
|
142
|
+
}),
|
|
143
|
+
];
|
|
144
|
+
const json = renderComponents(ir).toJSON();
|
|
145
|
+
const row = json.components.find((c) => c.type === ComponentType.ActionRow);
|
|
146
|
+
const select = row.components[0];
|
|
147
|
+
// Exactly 25 real options; the dropped 5 are warned, never turned into a
|
|
148
|
+
// selectable "__overflow__" garbage value.
|
|
149
|
+
expect(select.options.length).toBe(25);
|
|
150
|
+
expect(select.options.every((o) => o.value !== "__overflow__")).toBe(true);
|
|
151
|
+
expect(select.options.map((o) => o.value)).toEqual(Array.from({ length: 25 }, (_, i) => `v${i}`));
|
|
152
|
+
expect(warn).toHaveBeenCalled();
|
|
153
|
+
warn.mockRestore();
|
|
154
|
+
});
|
|
155
|
+
it("truncates a select placeholder over 150 chars", () => {
|
|
156
|
+
const long = "p".repeat(300);
|
|
157
|
+
const ir = [
|
|
158
|
+
node("message", {
|
|
159
|
+
children: node("actions", {
|
|
160
|
+
children: node("select", {
|
|
161
|
+
onSelect: { id: "ck:sel" },
|
|
162
|
+
placeholder: long,
|
|
163
|
+
options: [{ label: "a", value: "a" }],
|
|
164
|
+
}),
|
|
165
|
+
}),
|
|
166
|
+
}),
|
|
167
|
+
];
|
|
168
|
+
const json = renderComponents(ir).toJSON();
|
|
169
|
+
const row = json.components.find((c) => c.type === ComponentType.ActionRow);
|
|
170
|
+
const select = row.components[0];
|
|
171
|
+
expect(select.placeholder.length).toBeLessThanOrEqual(150);
|
|
172
|
+
});
|
|
173
|
+
it("accepts a strict 6-hex accent and rejects junk / out-of-range", () => {
|
|
174
|
+
const accepted = renderComponents([
|
|
175
|
+
node("message", { accent: "#5865F2", children: text("hi") }),
|
|
176
|
+
]).toJSON();
|
|
177
|
+
expect(accepted.accent_color).toBe(0x5865f2);
|
|
178
|
+
const partialHex = renderComponents([
|
|
179
|
+
node("message", { accent: "12xyz", children: text("hi") }),
|
|
180
|
+
]).toJSON();
|
|
181
|
+
expect(partialHex.accent_color).toBeUndefined();
|
|
182
|
+
const outOfRange = renderComponents([
|
|
183
|
+
node("message", { accent: 0x1000000, children: text("hi") }),
|
|
184
|
+
]).toJSON();
|
|
185
|
+
expect(outOfRange.accent_color).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
it("drops an over-large button value but still renders the button via its handler id", () => {
|
|
188
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
189
|
+
const bigValue = { blob: "z".repeat(200) }; // v:<json> exceeds 100 chars
|
|
190
|
+
const ir = [
|
|
191
|
+
node("message", {
|
|
192
|
+
children: node("actions", {
|
|
193
|
+
children: node("button", {
|
|
194
|
+
children: text("Go"),
|
|
195
|
+
onClick: { id: "ck:handler" },
|
|
196
|
+
value: bigValue,
|
|
197
|
+
}),
|
|
198
|
+
}),
|
|
199
|
+
}),
|
|
200
|
+
];
|
|
201
|
+
const json = renderComponents(ir).toJSON();
|
|
202
|
+
const row = json.components.find((c) => c.type === ComponentType.ActionRow);
|
|
203
|
+
const btn = row.components[0];
|
|
204
|
+
// Handler id wins; the value is never encoded into the custom_id.
|
|
205
|
+
expect(btn.custom_id).toBe("ck:handler");
|
|
206
|
+
warn.mockRestore();
|
|
207
|
+
});
|
|
208
|
+
it("warns and renders no button when an over-large value is the only id source", () => {
|
|
209
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
210
|
+
const bigValue = { blob: "z".repeat(200) };
|
|
211
|
+
const ir = [
|
|
212
|
+
node("message", {
|
|
213
|
+
children: node("actions", {
|
|
214
|
+
children: node("button", { children: text("Go"), value: bigValue }),
|
|
215
|
+
}),
|
|
216
|
+
}),
|
|
217
|
+
];
|
|
218
|
+
renderComponents(ir).toJSON();
|
|
219
|
+
expect(warn).toHaveBeenCalled();
|
|
220
|
+
warn.mockRestore();
|
|
221
|
+
});
|
|
222
|
+
it("keeps a fenced table balanced when truncated near the 2000-char limit", () => {
|
|
223
|
+
// Build a table whose fenced markdown overruns textDisplayChars.
|
|
224
|
+
const columns = [{ header: "A" }, { header: "B" }];
|
|
225
|
+
const rows = Array.from({ length: 200 }, (_, i) => node("row", {
|
|
226
|
+
children: [
|
|
227
|
+
node("cell", { children: text("x".repeat(20) + i) }),
|
|
228
|
+
node("cell", { children: text("y".repeat(20) + i) }),
|
|
229
|
+
],
|
|
230
|
+
}));
|
|
231
|
+
const ir = [
|
|
232
|
+
node("message", { children: node("table", { columns, children: rows }) }),
|
|
233
|
+
];
|
|
234
|
+
const json = renderComponents(ir).toJSON();
|
|
235
|
+
const td = json.components.find((c) => c.type === ComponentType.TextDisplay);
|
|
236
|
+
expect(td.content.length).toBeLessThanOrEqual(2000);
|
|
237
|
+
// Fence delimiters must be balanced (even count) so the closing ``` is present.
|
|
238
|
+
expect((td.content.match(/```/g) ?? []).length % 2).toBe(0);
|
|
239
|
+
});
|
|
240
|
+
it("counts nested buttons toward the component cap (5 rows × 5 buttons stays ≤ 40 real components)", () => {
|
|
241
|
+
// 25 buttons → 5 action rows × 5 buttons = 5 rows + 25 buttons = 30 nested
|
|
242
|
+
// components, plus a leading text. The naive +1-per-row accounting would
|
|
243
|
+
// count only 5; the cap is 40 total. Add enough rows to force overflow.
|
|
244
|
+
const buttons = Array.from({ length: 25 }, (_, i) => node("button", { children: text(`b${i}`), onClick: { id: `ck:${i}` } }));
|
|
245
|
+
const ir = [
|
|
246
|
+
node("message", {
|
|
247
|
+
children: [
|
|
248
|
+
node("section", { children: text("header text") }),
|
|
249
|
+
node("actions", { children: buttons }),
|
|
250
|
+
],
|
|
251
|
+
}),
|
|
252
|
+
];
|
|
253
|
+
const json = renderComponents(ir).toJSON();
|
|
254
|
+
// Count every real component Discord would tally: containers' direct children
|
|
255
|
+
// plus each action row's nested components.
|
|
256
|
+
const countComponents = (comps) => comps.reduce((sum, c) => {
|
|
257
|
+
if (c.type === ComponentType.ActionRow) {
|
|
258
|
+
return sum + 1 + (c.components?.length ?? 0);
|
|
259
|
+
}
|
|
260
|
+
return sum + 1;
|
|
261
|
+
}, 0);
|
|
262
|
+
const total = countComponents(json.components);
|
|
263
|
+
expect(total).toBeLessThanOrEqual(40);
|
|
264
|
+
});
|
|
265
|
+
it("overflow-signals before exceeding 40 when many rows of buttons overflow the cap", () => {
|
|
266
|
+
// 5 rows of 5 buttons would be 30 components, but pad with dividers first so
|
|
267
|
+
// the action rows push past the cap and an overflow marker is emitted.
|
|
268
|
+
const dividers = Array.from({ length: 30 }, () => node("divider"));
|
|
269
|
+
const buttons = Array.from({ length: 25 }, (_, i) => node("button", { children: text(`b${i}`), onClick: { id: `ck:${i}` } }));
|
|
270
|
+
const ir = [
|
|
271
|
+
node("message", {
|
|
272
|
+
children: [...dividers, node("actions", { children: buttons })],
|
|
273
|
+
}),
|
|
274
|
+
];
|
|
275
|
+
const json = renderComponents(ir).toJSON();
|
|
276
|
+
const countComponents = (comps) => comps.reduce((sum, c) => {
|
|
277
|
+
if (c.type === ComponentType.ActionRow) {
|
|
278
|
+
return sum + 1 + (c.components?.length ?? 0);
|
|
279
|
+
}
|
|
280
|
+
return sum + 1;
|
|
281
|
+
}, 0);
|
|
282
|
+
expect(countComponents(json.components)).toBeLessThanOrEqual(40);
|
|
283
|
+
const overflow = json.components.filter((c) => c.type === ComponentType.TextDisplay &&
|
|
284
|
+
c.content === "_…content truncated_");
|
|
285
|
+
expect(overflow.length).toBe(1);
|
|
286
|
+
});
|
|
287
|
+
it("emits no empty TextDisplay when the text budget is exhausted", () => {
|
|
288
|
+
// Two 4000-char sections: the first fills totalTextChars, the second clamps
|
|
289
|
+
// to "" — which must NOT become an empty TextDisplay (Discord rejects it).
|
|
290
|
+
const chunk = "x".repeat(4000);
|
|
291
|
+
const ir = [
|
|
292
|
+
node("message", {
|
|
293
|
+
children: [
|
|
294
|
+
node("section", { children: text(chunk) }),
|
|
295
|
+
node("section", { children: text(chunk) }),
|
|
296
|
+
],
|
|
297
|
+
}),
|
|
298
|
+
];
|
|
299
|
+
const json = renderComponents(ir).toJSON();
|
|
300
|
+
const texts = json.components.filter((c) => c.type === ComponentType.TextDisplay);
|
|
301
|
+
expect(texts.every((c) => (c.content?.length ?? 0) > 0)).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
it("emits no 'content truncated' marker for genuinely-empty input (empty Fields / empty Text)", () => {
|
|
304
|
+
const ir = [
|
|
305
|
+
node("message", {
|
|
306
|
+
children: [
|
|
307
|
+
node("fields", { children: [] }),
|
|
308
|
+
node("section", { children: text("") }),
|
|
309
|
+
node("context", { children: [] }),
|
|
310
|
+
],
|
|
311
|
+
}),
|
|
312
|
+
];
|
|
313
|
+
const json = renderComponents(ir).toJSON();
|
|
314
|
+
const overflow = json.components.filter((c) => c.type === ComponentType.TextDisplay &&
|
|
315
|
+
c.content === "_…content truncated_");
|
|
316
|
+
expect(overflow.length).toBe(0);
|
|
317
|
+
// No empty TextDisplay leaked through either.
|
|
318
|
+
const texts = json.components.filter((c) => c.type === ComponentType.TextDisplay);
|
|
319
|
+
expect(texts.every((c) => (c.content?.length ?? 0) > 0)).toBe(true);
|
|
320
|
+
});
|
|
321
|
+
it("keeps a fenced table balanced when clamped by the cumulative 4000-char text budget", () => {
|
|
322
|
+
// Pre-fill text close to the 4000 cumulative cap, then render a fenced table
|
|
323
|
+
// whose markdown is clamped by the cumulative budget (not the per-display
|
|
324
|
+
// limit). The cumulative clamp must not sever the fence open.
|
|
325
|
+
// Leave only a small cumulative budget (~10 chars) for the table so the
|
|
326
|
+
// cumulative clamp lands mid-fence, exercising the fence-aware path.
|
|
327
|
+
const filler = "x".repeat(1985);
|
|
328
|
+
const fillers = Array.from({ length: 2 }, () => node("section", { children: text(filler) }));
|
|
329
|
+
const columns = [{ header: "A" }, { header: "B" }];
|
|
330
|
+
const rows = Array.from({ length: 50 }, (_, i) => node("row", {
|
|
331
|
+
children: [
|
|
332
|
+
node("cell", { children: text("aaaa" + i) }),
|
|
333
|
+
node("cell", { children: text("bbbb" + i) }),
|
|
334
|
+
],
|
|
335
|
+
}));
|
|
336
|
+
const ir = [
|
|
337
|
+
node("message", {
|
|
338
|
+
children: [...fillers, node("table", { columns, children: rows })],
|
|
339
|
+
}),
|
|
340
|
+
];
|
|
341
|
+
const json = renderComponents(ir).toJSON();
|
|
342
|
+
const texts = json.components.filter((c) => c.type === ComponentType.TextDisplay);
|
|
343
|
+
// The table's TextDisplay is the one containing a fence; it must be present
|
|
344
|
+
// (a cut fence leaves an opening ```) and balanced.
|
|
345
|
+
const fenced = texts.filter((c) => (c.content ?? "").includes("```"));
|
|
346
|
+
expect(fenced.length).toBeGreaterThan(0);
|
|
347
|
+
for (const c of fenced) {
|
|
348
|
+
expect((c.content.match(/```/g) ?? []).length % 2).toBe(0);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
it("does not emit a Select with zero options", () => {
|
|
352
|
+
const ir = [
|
|
353
|
+
node("message", {
|
|
354
|
+
children: node("actions", {
|
|
355
|
+
children: node("select", { onSelect: { id: "ck:sel" }, options: [] }),
|
|
356
|
+
}),
|
|
357
|
+
}),
|
|
358
|
+
];
|
|
359
|
+
const json = renderComponents(ir).toJSON();
|
|
360
|
+
const row = json.components.find((c) => c.type === ComponentType.ActionRow);
|
|
361
|
+
// No options → no select → the action row is never created.
|
|
362
|
+
expect(row).toBeUndefined();
|
|
363
|
+
});
|
|
364
|
+
it("falls back to ' ' for an explicitly-empty select placeholder", () => {
|
|
365
|
+
const ir = [
|
|
366
|
+
node("message", {
|
|
367
|
+
children: node("actions", {
|
|
368
|
+
children: node("select", {
|
|
369
|
+
onSelect: { id: "ck:sel" },
|
|
370
|
+
placeholder: "",
|
|
371
|
+
options: [{ label: "a", value: "a" }],
|
|
372
|
+
}),
|
|
373
|
+
}),
|
|
374
|
+
}),
|
|
375
|
+
];
|
|
376
|
+
const json = renderComponents(ir).toJSON();
|
|
377
|
+
const row = json.components.find((c) => c.type === ComponentType.ActionRow);
|
|
378
|
+
const select = row.components[0];
|
|
379
|
+
expect(select.placeholder).toBe(" ");
|
|
380
|
+
});
|
|
381
|
+
it("counts the overflow marker against the text budget", () => {
|
|
382
|
+
// Fill text past the cap so an overflow marker is appended; total text
|
|
383
|
+
// (including the marker) must stay within totalTextChars.
|
|
384
|
+
const chunk = "x".repeat(2000);
|
|
385
|
+
const sections = Array.from({ length: 4 }, () => node("section", { children: text(chunk) }));
|
|
386
|
+
const ir = [node("message", { children: sections })];
|
|
387
|
+
const json = renderComponents(ir).toJSON();
|
|
388
|
+
const totalText = json.components
|
|
389
|
+
.filter((c) => c.type === ComponentType.TextDisplay)
|
|
390
|
+
.reduce((sum, c) => sum + (c.content?.length ?? 0), 0);
|
|
391
|
+
// Overflow marker is reserved for and charged against the budget, so the
|
|
392
|
+
// summed text never exceeds totalTextChars.
|
|
393
|
+
expect(totalText).toBeLessThanOrEqual(4000);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BotNode } from "@copilotkit/channels-ui";
|
|
2
|
+
import { ModalBuilder } from "discord.js";
|
|
3
|
+
/**
|
|
4
|
+
* Lower a modal IR tree to a discord.js {@link ModalBuilder}.
|
|
5
|
+
*
|
|
6
|
+
* Discord modals support **text inputs only** (up to 5). Any other modal
|
|
7
|
+
* element (`modal_select`, `modal_radio`) or more than five text inputs throws
|
|
8
|
+
* a {@link ModalRenderError}, which `adapter.openModal` translates to
|
|
9
|
+
* `{ ok: false, error }` (degrade-never-throw at the boundary).
|
|
10
|
+
*/
|
|
11
|
+
export declare function renderDiscordModal(ir: BotNode[]): ModalBuilder;
|
|
12
|
+
//# sourceMappingURL=modal.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"modal.d.ts","sourceRoot":"","sources":["../../src/render/modal.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAC;AAEvD,OAAO,EAEL,YAAY,EAGb,MAAM,YAAY,CAAC;AAEpB;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,OAAO,EAAE,GAAG,YAAY,CAkC9D"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ModalRenderError } from "@copilotkit/channels-ui";
|
|
2
|
+
import { ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, } from "discord.js";
|
|
3
|
+
/**
|
|
4
|
+
* Lower a modal IR tree to a discord.js {@link ModalBuilder}.
|
|
5
|
+
*
|
|
6
|
+
* Discord modals support **text inputs only** (up to 5). Any other modal
|
|
7
|
+
* element (`modal_select`, `modal_radio`) or more than five text inputs throws
|
|
8
|
+
* a {@link ModalRenderError}, which `adapter.openModal` translates to
|
|
9
|
+
* `{ ok: false, error }` (degrade-never-throw at the boundary).
|
|
10
|
+
*/
|
|
11
|
+
export function renderDiscordModal(ir) {
|
|
12
|
+
const root = ir.find((n) => n.type === "modal");
|
|
13
|
+
if (!root)
|
|
14
|
+
throw new ModalRenderError("renderDiscordModal: no <Modal> root in IR");
|
|
15
|
+
const p = root.props;
|
|
16
|
+
const kids = Array.isArray(p.children) ? p.children : [];
|
|
17
|
+
const inputs = kids.filter((k) => k.type === "modal_text_input");
|
|
18
|
+
const unsupported = kids.find((k) => k.type !== "modal_text_input");
|
|
19
|
+
if (unsupported) {
|
|
20
|
+
throw new ModalRenderError(`Discord modals support text inputs only; got "${String(unsupported.type)}"`);
|
|
21
|
+
}
|
|
22
|
+
if (inputs.length > 5) {
|
|
23
|
+
throw new ModalRenderError("Discord modals allow at most 5 text inputs");
|
|
24
|
+
}
|
|
25
|
+
const modal = new ModalBuilder()
|
|
26
|
+
.setCustomId(String(p.callbackId ?? ""))
|
|
27
|
+
.setTitle(String(p.title ?? ""));
|
|
28
|
+
for (const node of inputs) {
|
|
29
|
+
const fp = node.props;
|
|
30
|
+
const input = new TextInputBuilder()
|
|
31
|
+
.setCustomId(String(fp.id ?? ""))
|
|
32
|
+
.setLabel(String(fp.label ?? ""))
|
|
33
|
+
.setStyle(fp.multiline ? TextInputStyle.Paragraph : TextInputStyle.Short)
|
|
34
|
+
.setRequired(fp.optional !== true);
|
|
35
|
+
if (fp.placeholder)
|
|
36
|
+
input.setPlaceholder(String(fp.placeholder));
|
|
37
|
+
if (fp.initialValue)
|
|
38
|
+
input.setValue(String(fp.initialValue));
|
|
39
|
+
if (fp.maxLength)
|
|
40
|
+
input.setMaxLength(Number(fp.maxLength));
|
|
41
|
+
modal.addComponents(new ActionRowBuilder().addComponents(input));
|
|
42
|
+
}
|
|
43
|
+
return modal;
|
|
44
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Where a reply goes. Discord addresses channels, threads, and DMs all by channel id. */
|
|
2
|
+
export interface ReplyTarget {
|
|
3
|
+
channelId: string;
|
|
4
|
+
/** Present for guild channels/threads; absent for DMs. */
|
|
5
|
+
guildId?: string;
|
|
6
|
+
}
|
|
7
|
+
/** A normalized inbound turn, before the adapter resolves the sender profile. */
|
|
8
|
+
export interface IncomingTurn {
|
|
9
|
+
conversationKey: string;
|
|
10
|
+
replyTarget: ReplyTarget;
|
|
11
|
+
userText: string;
|
|
12
|
+
senderUserId?: string;
|
|
13
|
+
}
|
|
14
|
+
/** The conversation key is just the channel id (threads have their own id). */
|
|
15
|
+
export declare function conversationKeyOf(target: ReplyTarget): string;
|
|
16
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,0FAA0F;AAC1F,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,0DAA0D;IAC1D,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,iFAAiF;AACjF,MAAM,WAAW,YAAY;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,+EAA+E;AAC/E,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAE7D"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.test.d.ts","sourceRoot":"","sources":["../src/types.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { conversationKeyOf } from "./types.js";
|
|
3
|
+
describe("conversationKeyOf", () => {
|
|
4
|
+
it("uses the channel id as the conversation key", () => {
|
|
5
|
+
const t = { channelId: "123", guildId: "g1" };
|
|
6
|
+
expect(conversationKeyOf(t)).toBe("123");
|
|
7
|
+
});
|
|
8
|
+
it("works for DMs with no guild", () => {
|
|
9
|
+
const t = { channelId: "dm-999" };
|
|
10
|
+
expect(conversationKeyOf(t)).toBe("dm-999");
|
|
11
|
+
});
|
|
12
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@copilotkit/channels-discord",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Discord PlatformAdapter for @copilotkit/channels.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/CopilotKit/CopilotKit.git",
|
|
9
|
+
"directory": "packages/channels-discord"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/CopilotKit/CopilotKit",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"ai",
|
|
14
|
+
"agent",
|
|
15
|
+
"bot",
|
|
16
|
+
"discord",
|
|
17
|
+
"copilotkit",
|
|
18
|
+
"ag-ui"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@ag-ui/client": "0.0.57",
|
|
37
|
+
"discord.js": "^14.16.0",
|
|
38
|
+
"zod": "^3.25.76",
|
|
39
|
+
"@copilotkit/channels": "0.1.0",
|
|
40
|
+
"@copilotkit/channels-ui": "0.1.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^22.10.0",
|
|
44
|
+
"typescript": "^5.6.3",
|
|
45
|
+
"vitest": "^4.1.3",
|
|
46
|
+
"@copilotkit/typescript-config": "^1.55.0-next.8"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsc -p tsconfig.json",
|
|
50
|
+
"check-types": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.check.json",
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"test:watch": "vitest",
|
|
53
|
+
"publint": "publint .",
|
|
54
|
+
"attw": "attw --pack . --profile esm-only"
|
|
55
|
+
}
|
|
56
|
+
}
|