@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,160 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { createRunRenderer } from "./event-renderer.js";
3
+ function fakeChannel() {
4
+ const sent = [];
5
+ let n = 0;
6
+ return {
7
+ sendTyping: vi.fn(async () => { }),
8
+ send: vi.fn(async (payload) => {
9
+ const id = `m${++n}`;
10
+ const msg = {
11
+ id,
12
+ content: typeof payload === "string" ? payload : (payload.content ?? ""),
13
+ edit: vi.fn(async (p) => {
14
+ msg.content = typeof p === "string" ? p : (p.content ?? msg.content);
15
+ }),
16
+ };
17
+ sent.push(msg);
18
+ return msg;
19
+ }),
20
+ _sent: sent,
21
+ };
22
+ }
23
+ describe("createRunRenderer", () => {
24
+ it("fires a typing indicator on run start", () => {
25
+ const channel = fakeChannel();
26
+ const r = createRunRenderer({ channel: channel });
27
+ r.subscriber.onRunStartedEvent?.({ event: {} });
28
+ expect(channel.sendTyping).toHaveBeenCalledTimes(1);
29
+ // Stop the heartbeat so the interval doesn't dangle past the test.
30
+ r.subscriber.onRunFinishedEvent?.({ event: {} });
31
+ });
32
+ it("keeps the typing indicator alive across a long tool call, then stops on finish", () => {
33
+ vi.useFakeTimers();
34
+ try {
35
+ const channel = fakeChannel();
36
+ const r = createRunRenderer({ channel: channel });
37
+ r.subscriber.onRunStartedEvent?.({ event: {} });
38
+ // Immediate ping on start.
39
+ expect(channel.sendTyping).toHaveBeenCalledTimes(1);
40
+ // Discord typing expires ~10 s; the heartbeat refreshes every 8 s so a
41
+ // 40 s tool call never goes dead.
42
+ vi.advanceTimersByTime(8000);
43
+ expect(channel.sendTyping).toHaveBeenCalledTimes(2);
44
+ vi.advanceTimersByTime(8000 * 4); // ~40 s total elapsed
45
+ expect(channel.sendTyping).toHaveBeenCalledTimes(6);
46
+ // Once the run finishes, the heartbeat stops — no lingering "typing".
47
+ r.subscriber.onRunFinishedEvent?.({ event: {} });
48
+ vi.advanceTimersByTime(8000 * 5);
49
+ expect(channel.sendTyping).toHaveBeenCalledTimes(6);
50
+ }
51
+ finally {
52
+ vi.useRealTimers();
53
+ }
54
+ });
55
+ it("streams text deltas into a single edited message", async () => {
56
+ const channel = fakeChannel();
57
+ const r = createRunRenderer({ channel: channel });
58
+ r.subscriber.onTextMessageStartEvent?.({
59
+ event: { messageId: "a" },
60
+ });
61
+ r.subscriber.onTextMessageContentEvent?.({
62
+ event: { messageId: "a", delta: "Hel" },
63
+ });
64
+ r.subscriber.onTextMessageContentEvent?.({
65
+ event: { messageId: "a", delta: "lo" },
66
+ });
67
+ await r.subscriber.onTextMessageEndEvent?.({
68
+ event: { messageId: "a" },
69
+ });
70
+ expect(channel.send).toHaveBeenCalledTimes(1);
71
+ expect(channel._sent[0].content).toContain("Hello");
72
+ });
73
+ it("routes each chunk's edits to its own Discord message", async () => {
74
+ // A reply longer than the 2000-char Discord limit is split by
75
+ // ChunkedMessageStream into multiple Discord messages. Each posted
76
+ // message must receive ONLY its own slice via `edit()` — a regression
77
+ // here (a single per-AG-UI-message handle) routes every edit to the
78
+ // last-posted message, freezing earlier chunks on their placeholder.
79
+ const channel = fakeChannel();
80
+ const r = createRunRenderer({ channel: channel });
81
+ // Two distinct, newline-delimited blocks. Total > 2000 so the chunker
82
+ // freezes a boundary at the newline near the limit, producing exactly
83
+ // two Discord messages.
84
+ const first = "A".repeat(1500);
85
+ const second = "B".repeat(600);
86
+ const full = `${first}\n${second}`;
87
+ r.subscriber.onTextMessageStartEvent?.({
88
+ event: { messageId: "a" },
89
+ });
90
+ r.subscriber.onTextMessageContentEvent?.({
91
+ event: { messageId: "a", delta: full },
92
+ });
93
+ await r.subscriber.onTextMessageEndEvent?.({
94
+ event: { messageId: "a" },
95
+ });
96
+ // Two Discord messages posted (one per chunk).
97
+ expect(channel.send).toHaveBeenCalledTimes(2);
98
+ const [msg1, msg2] = channel._sent;
99
+ // Concatenate every edit each message received. The discriminator is
100
+ // that NO edit for a given message may carry the *other* chunk's
101
+ // content. With the regression (a single per-AG-UI-message handle keyed
102
+ // by messageId), chunk 0's final flush is minted after chunk 1's
103
+ // placeholder, so it overwrites the shared handle and msg2 receives an
104
+ // "A"-bearing edit — exactly what this asserts against.
105
+ const editsOf = (m) => m.edit.mock.calls
106
+ .map(([p]) => (typeof p === "string" ? p : (p?.content ?? "")))
107
+ .join(" ");
108
+ const msg1Edits = editsOf(msg1);
109
+ const msg2Edits = editsOf(msg2);
110
+ // Both messages were actually edited (not left on their placeholder).
111
+ expect(msg1.edit).toHaveBeenCalled();
112
+ expect(msg2.edit).toHaveBeenCalled();
113
+ // Each message only ever sees its OWN chunk's content.
114
+ expect(msg1Edits).toContain("A");
115
+ expect(msg1Edits).not.toContain("B");
116
+ expect(msg2Edits).toContain("B");
117
+ expect(msg2Edits).not.toContain("A");
118
+ });
119
+ it("captures a tool call for the run loop", async () => {
120
+ const channel = fakeChannel();
121
+ const r = createRunRenderer({ channel: channel });
122
+ await r.subscriber.onToolCallEndEvent?.({
123
+ event: { toolCallId: "t1" },
124
+ toolCallName: "render_card",
125
+ toolCallArgs: { id: "x" },
126
+ });
127
+ expect(r.getCapturedToolCalls()).toEqual([
128
+ {
129
+ toolCallId: "t1",
130
+ toolCallName: "render_card",
131
+ toolCallArgs: { id: "x" },
132
+ },
133
+ ]);
134
+ });
135
+ it("captures an on_interrupt custom event", () => {
136
+ const channel = fakeChannel();
137
+ const r = createRunRenderer({ channel: channel });
138
+ r.subscriber.onCustomEvent?.({
139
+ event: { name: "on_interrupt", value: { q: 1 } },
140
+ });
141
+ expect(r.getPendingInterrupt()).toEqual({
142
+ eventName: "on_interrupt",
143
+ value: { q: 1 },
144
+ });
145
+ });
146
+ it("posts a visible warning when an agent run errors", async () => {
147
+ const channel = fakeChannel();
148
+ const r = createRunRenderer({ channel: channel });
149
+ await r.subscriber.onRunErrorEvent?.({ event: { message: "boom" } });
150
+ expect(channel.send).toHaveBeenCalledTimes(1);
151
+ expect(channel._sent[0].content).toContain("boom");
152
+ });
153
+ it("does not post an error notice when the run was self-aborted", async () => {
154
+ const channel = fakeChannel();
155
+ const r = createRunRenderer({ channel: channel });
156
+ await r.markInterrupted();
157
+ await r.subscriber.onRunErrorEvent?.({ event: { message: "boom" } });
158
+ expect(channel.send).not.toHaveBeenCalled();
159
+ });
160
+ });
@@ -0,0 +1,24 @@
1
+ export { discord, DiscordAdapter } from "./adapter.js";
2
+ export type { DiscordAdapterOptions } from "./adapter.js";
3
+ export { DiscordConversationStore } from "./conversation-store.js";
4
+ export { attachDiscordListener } from "./discord-listener.js";
5
+ export type { ListenerConfig, ClientLike, IncomingCommandRaw, } from "./discord-listener.js";
6
+ export { createRunRenderer } from "./event-renderer.js";
7
+ export type { ChannelLike } from "./event-renderer.js";
8
+ export { decodeInteraction } from "./interaction.js";
9
+ export { conversationKeyOf } from "./types.js";
10
+ export type { ReplyTarget, IncomingTurn } from "./types.js";
11
+ export { renderComponents, renderDiscordMessage, } from "./render/components-v2.js";
12
+ export { DISCORD_LIMITS } from "./render/budget.js";
13
+ export { discordMarkdown } from "./markdown.js";
14
+ export { MessageStream } from "./message-stream.js";
15
+ export type { MessageStreamConfig } from "./message-stream.js";
16
+ export { ChunkedMessageStream } from "./chunked-message-stream.js";
17
+ export type { ChunkedMessageStreamConfig } from "./chunked-message-stream.js";
18
+ export { autoCloseOpenMarkdown } from "./auto-close-streaming.js";
19
+ export { registerCommands, jsonSchemaToDiscordOptions } from "./commands.js";
20
+ export { buildFileContentParts } from "./download-files.js";
21
+ export type { DiscordAttachmentRef, AgentContentPart, FileDeliveryConfig, MediaDataSource, } from "./download-files.js";
22
+ export { defaultDiscordContext, discordTaggingContext, discordFormattingContext, discordConversationModelContext, } from "./built-in-context.js";
23
+ export { lookupDiscordUserTool, defaultDiscordTools, } from "./built-in-tools.js";
24
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACvD,YAAY,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAE1D,OAAO,EAAE,wBAAwB,EAAE,MAAM,yBAAyB,CAAC;AAEnE,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,YAAY,EACV,cAAc,EACd,UAAU,EACV,kBAAkB,GACnB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAErD,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAC/C,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE5D,OAAO,EACL,gBAAgB,EAChB,oBAAoB,GACrB,MAAM,2BAA2B,CAAC;AAEnC,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAEpD,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAEhD,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,YAAY,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAE/D,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AACnE,YAAY,EAAE,0BAA0B,EAAE,MAAM,6BAA6B,CAAC;AAE9E,OAAO,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAElE,OAAO,EAAE,gBAAgB,EAAE,0BAA0B,EAAE,MAAM,eAAe,CAAC;AAE7E,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,YAAY,EACV,oBAAoB,EACpB,gBAAgB,EAChB,kBAAkB,EAClB,eAAe,GAChB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,wBAAwB,EACxB,+BAA+B,GAChC,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EACL,qBAAqB,EACrB,mBAAmB,GACpB,MAAM,qBAAqB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ // Public API for @copilotkit/channels-discord.
2
+ export { discord, DiscordAdapter } from "./adapter.js";
3
+ export { DiscordConversationStore } from "./conversation-store.js";
4
+ export { attachDiscordListener } from "./discord-listener.js";
5
+ export { createRunRenderer } from "./event-renderer.js";
6
+ export { decodeInteraction } from "./interaction.js";
7
+ export { conversationKeyOf } from "./types.js";
8
+ export { renderComponents, renderDiscordMessage, } from "./render/components-v2.js";
9
+ export { DISCORD_LIMITS } from "./render/budget.js";
10
+ export { discordMarkdown } from "./markdown.js";
11
+ export { MessageStream } from "./message-stream.js";
12
+ export { ChunkedMessageStream } from "./chunked-message-stream.js";
13
+ export { autoCloseOpenMarkdown } from "./auto-close-streaming.js";
14
+ export { registerCommands, jsonSchemaToDiscordOptions } from "./commands.js";
15
+ export { buildFileContentParts } from "./download-files.js";
16
+ export { defaultDiscordContext, discordTaggingContext, discordFormattingContext, discordConversationModelContext, } from "./built-in-context.js";
17
+ export { lookupDiscordUserTool, defaultDiscordTools, } from "./built-in-tools.js";
@@ -0,0 +1,11 @@
1
+ import type { InteractionEvent, IncomingReaction, IncomingModalSubmit } from "@copilotkit/channels";
2
+ /** Decode a discord.js component interaction into the engine's opaque InteractionEvent. */
3
+ export declare function decodeInteraction(raw: unknown): InteractionEvent | undefined;
4
+ /** Decode a discord.js `ModalSubmitInteraction` into an `IncomingModalSubmit`. */
5
+ export declare function decodeModalSubmit(interaction: unknown): IncomingModalSubmit;
6
+ /**
7
+ * Decode a discord.js `MessageReaction` + `User` pair into an `IncomingReaction`.
8
+ * Returns `undefined` when required fields (emoji token, channelId, messageId) are missing.
9
+ */
10
+ export declare function decodeReaction(reaction: unknown, user: unknown, added: boolean): IncomingReaction | undefined;
11
+ //# sourceMappingURL=interaction.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"interaction.d.ts","sourceRoot":"","sources":["../src/interaction.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,gBAAgB,EAChB,gBAAgB,EAChB,mBAAmB,EACpB,MAAM,sBAAsB,CAAC;AAqB9B,2FAA2F;AAC3F,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,gBAAgB,GAAG,SAAS,CAkD5E;AA4DD,kFAAkF;AAClF,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,OAAO,GAAG,mBAAmB,CAmB3E;AAMD;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,OAAO,EACjB,IAAI,EAAE,OAAO,EACb,KAAK,EAAE,OAAO,GACb,gBAAgB,GAAG,SAAS,CAsB9B"}
@@ -0,0 +1,138 @@
1
+ /** Decode a discord.js component interaction into the engine's opaque InteractionEvent. */
2
+ export function decodeInteraction(raw) {
3
+ const i = raw;
4
+ if (typeof i?.isButton !== "function")
5
+ return undefined;
6
+ const isButton = i.isButton();
7
+ const isSelect = i.isStringSelectMenu?.() ?? false;
8
+ if (!isButton && !isSelect)
9
+ return undefined;
10
+ const customId = i.customId ?? "";
11
+ const channelId = i.channelId ?? "";
12
+ const user = toUser(i.user);
13
+ // A button custom_id may be a handler id ("ck:…"), a packed value
14
+ // ("v:<json>"), or BOTH ("ck:…;v:<json>") when a button carries an onClick AND
15
+ // a value (e.g. the HITL confirm gate). Split the combined form so the onClick
16
+ // still dispatches by the bare id AND awaitChoice receives the bound value.
17
+ // For a select, JSON-parse the chosen value so a non-string option value
18
+ // (number/boolean/object) round-trips to its original type — mirroring bot-slack.
19
+ let id = customId;
20
+ let value;
21
+ if (isSelect) {
22
+ // Discord sends `values: string[]` for both single and multi selects; the
23
+ // unambiguous signal is the component's value bounds (the renderer sets
24
+ // maxValues > 1 and minValues 0 for multi). Multi → a string[] of all chosen
25
+ // values; single → the one value (mirrors bot-slack).
26
+ const c = i.component;
27
+ const multi = (c?.maxValues ?? 1) > 1 || c?.minValues === 0;
28
+ value = multi
29
+ ? (i.values ?? []).map(parseSelectValue)
30
+ : parseSelectValue(i.values?.[0]);
31
+ }
32
+ else {
33
+ const sep = customId.startsWith("ck:") ? customId.indexOf(";v:") : -1;
34
+ if (sep !== -1) {
35
+ id = customId.slice(0, sep);
36
+ value = unpackValue(customId.slice(sep + 1));
37
+ }
38
+ else {
39
+ value = unpackValue(customId);
40
+ }
41
+ }
42
+ return {
43
+ id,
44
+ conversationKey: channelId,
45
+ replyTarget: { channelId, ...(i.guildId ? { guildId: i.guildId } : {}) },
46
+ value,
47
+ user,
48
+ messageRef: i.message ? { id: i.message.id, channelId } : undefined,
49
+ // Filled by the adapter from the pending-interaction registry; the bare
50
+ // decode has no live trigger to attach.
51
+ triggerId: undefined,
52
+ };
53
+ }
54
+ /** JSON-parse a chosen select value so non-string option values round-trip; else keep the raw string. */
55
+ function parseSelectValue(raw) {
56
+ if (typeof raw !== "string")
57
+ return raw;
58
+ try {
59
+ return JSON.parse(raw);
60
+ }
61
+ catch {
62
+ return raw;
63
+ }
64
+ }
65
+ /** A `v:<json>` custom_id carries a small bound value; anything else has none. */
66
+ function unpackValue(customId) {
67
+ if (!customId.startsWith("v:"))
68
+ return undefined;
69
+ try {
70
+ return JSON.parse(customId.slice(2));
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ }
76
+ function toUser(u) {
77
+ if (!u?.id)
78
+ return undefined;
79
+ return { id: u.id, name: u.globalName ?? u.username, handle: u.username };
80
+ }
81
+ /** custom emoji → "name:id"; unicode → the char. */
82
+ function emojiToken(e) {
83
+ if (!e?.name)
84
+ return undefined;
85
+ return e.id ? `${e.name}:${e.id}` : e.name;
86
+ }
87
+ /** Decode a discord.js `ModalSubmitInteraction` into an `IncomingModalSubmit`. */
88
+ export function decodeModalSubmit(interaction) {
89
+ const i = interaction;
90
+ const values = {};
91
+ for (const [key, comp] of i.fields?.fields ?? new Map()) {
92
+ values[comp?.customId ?? key] = comp?.value;
93
+ }
94
+ return {
95
+ callbackId: i.customId ?? "",
96
+ values,
97
+ user: i.user?.id
98
+ ? { id: i.user.id, name: i.user.globalName ?? i.user.username }
99
+ : undefined,
100
+ conversationKey: i.channelId,
101
+ replyTarget: i.channelId
102
+ ? { channelId: i.channelId, ...(i.guildId ? { guildId: i.guildId } : {}) }
103
+ : undefined,
104
+ platform: "discord",
105
+ raw: interaction,
106
+ };
107
+ }
108
+ // ---------------------------------------------------------------------------
109
+ // Reaction decode
110
+ // ---------------------------------------------------------------------------
111
+ /**
112
+ * Decode a discord.js `MessageReaction` + `User` pair into an `IncomingReaction`.
113
+ * Returns `undefined` when required fields (emoji token, channelId, messageId) are missing.
114
+ */
115
+ export function decodeReaction(reaction, user, added) {
116
+ const r = reaction;
117
+ const u = user;
118
+ const token = emojiToken(r.emoji);
119
+ const channelId = r.message?.channelId;
120
+ const messageId = r.message?.id;
121
+ if (!token || !channelId || !messageId)
122
+ return undefined;
123
+ return {
124
+ rawEmoji: token,
125
+ added,
126
+ user: u.id ? { id: u.id, name: u.globalName ?? u.username } : undefined,
127
+ conversationKey: channelId,
128
+ replyTarget: {
129
+ channelId,
130
+ ...(r.message?.guildId ? { guildId: r.message.guildId } : {}),
131
+ },
132
+ messageId,
133
+ // Update-capable ref (channelId + message id) so an onReaction handler can
134
+ // edit the reacted message in place via thread.update.
135
+ messageRef: { id: messageId, channelId },
136
+ raw: reaction,
137
+ };
138
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=interaction.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"interaction.test.d.ts","sourceRoot":"","sources":["../src/interaction.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { decodeInteraction } from "./interaction.js";
3
+ const baseMsg = { id: "m1" };
4
+ const channel = { id: "c1", guildId: "g1" };
5
+ describe("decodeInteraction", () => {
6
+ it("decodes a button click into an opaque InteractionEvent", () => {
7
+ const evt = decodeInteraction({
8
+ isButton: () => true,
9
+ isStringSelectMenu: () => false,
10
+ customId: "ck:abc123",
11
+ message: baseMsg,
12
+ channelId: "c1",
13
+ guildId: "g1",
14
+ user: { id: "u1", username: "ann", globalName: "Ann" },
15
+ });
16
+ expect(evt).toMatchObject({
17
+ id: "ck:abc123",
18
+ conversationKey: "c1",
19
+ replyTarget: { channelId: "c1", guildId: "g1" },
20
+ user: { id: "u1", name: "Ann", handle: "ann" },
21
+ messageRef: { id: "m1", channelId: "c1" },
22
+ });
23
+ });
24
+ it("unpacks a value-only custom_id (v:<json>)", () => {
25
+ const evt = decodeInteraction({
26
+ isButton: () => true,
27
+ isStringSelectMenu: () => false,
28
+ customId: 'v:{"confirmed":true}',
29
+ message: baseMsg,
30
+ channelId: "c1",
31
+ user: { id: "u1" },
32
+ });
33
+ expect(evt?.value).toEqual({ confirmed: true });
34
+ });
35
+ it("splits a combined handler+value custom_id (ck:<id>;v:<json>)", () => {
36
+ // A button with BOTH an onClick and a value (the HITL confirm gate). The
37
+ // bare id must still dispatch the onClick, AND the value must reach the
38
+ // awaitChoice waiter — otherwise an approval is read as "declined".
39
+ const evt = decodeInteraction({
40
+ isButton: () => true,
41
+ isStringSelectMenu: () => false,
42
+ customId: 'ck:abc123;v:{"confirmed":true}',
43
+ message: baseMsg,
44
+ channelId: "c1",
45
+ user: { id: "u1" },
46
+ });
47
+ expect(evt?.id).toBe("ck:abc123");
48
+ expect(evt?.value).toEqual({ confirmed: true });
49
+ });
50
+ it("decodes a string-select with its chosen values", () => {
51
+ const evt = decodeInteraction({
52
+ isButton: () => false,
53
+ isStringSelectMenu: () => true,
54
+ customId: "ck:sel1",
55
+ values: ["opt-b"],
56
+ message: baseMsg,
57
+ channelId: "c1",
58
+ user: { id: "u1" },
59
+ });
60
+ expect(evt).toMatchObject({ id: "ck:sel1", value: "opt-b" });
61
+ });
62
+ it("JSON-parses a string-select value that round-trips (object)", () => {
63
+ const evt = decodeInteraction({
64
+ isButton: () => false,
65
+ isStringSelectMenu: () => true,
66
+ customId: "ck:sel2",
67
+ values: ['{"k":1}'],
68
+ message: baseMsg,
69
+ channelId: "c1",
70
+ user: { id: "u1" },
71
+ });
72
+ expect(evt?.value).toEqual({ k: 1 });
73
+ });
74
+ it("JSON-parses a string-select value that round-trips (number)", () => {
75
+ const evt = decodeInteraction({
76
+ isButton: () => false,
77
+ isStringSelectMenu: () => true,
78
+ customId: "ck:sel3",
79
+ values: ["42"],
80
+ message: baseMsg,
81
+ channelId: "c1",
82
+ user: { id: "u1" },
83
+ });
84
+ expect(evt?.value).toBe(42);
85
+ });
86
+ it("keeps a plain non-JSON string-select value as the raw string", () => {
87
+ const evt = decodeInteraction({
88
+ isButton: () => false,
89
+ isStringSelectMenu: () => true,
90
+ customId: "ck:sel4",
91
+ values: ["opt-a"],
92
+ message: baseMsg,
93
+ channelId: "c1",
94
+ user: { id: "u1" },
95
+ });
96
+ expect(evt?.value).toBe("opt-a");
97
+ });
98
+ it("decodes a multi-select (maxValues > 1) into a string[] of chosen values", () => {
99
+ const evt = decodeInteraction({
100
+ isButton: () => false,
101
+ isStringSelectMenu: () => true,
102
+ customId: "ck:ms",
103
+ component: { maxValues: 5 },
104
+ values: ["core", "infra"],
105
+ message: baseMsg,
106
+ channelId: "c1",
107
+ user: { id: "u1" },
108
+ });
109
+ expect(evt?.value).toEqual(["core", "infra"]);
110
+ });
111
+ it("returns undefined for a non-component interaction", () => {
112
+ expect(decodeInteraction({
113
+ isButton: () => false,
114
+ isStringSelectMenu: () => false,
115
+ })).toBeUndefined();
116
+ });
117
+ it("returns undefined (not throws) when isStringSelectMenu is absent", () => {
118
+ expect(decodeInteraction({ isButton: () => false })).toBeUndefined();
119
+ });
120
+ });
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Discord's markdown is close to GFM, so this is mostly identity. The single
3
+ * real gap is tables: Discord renders none, so a contiguous GFM pipe-table
4
+ * block is wrapped in a code fence (monospace keeps the columns aligned).
5
+ */
6
+ export declare function discordMarkdown(text: string): string;
7
+ //# sourceMappingURL=markdown.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../src/markdown.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAGpD"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Discord's markdown is close to GFM, so this is mostly identity. The single
3
+ * real gap is tables: Discord renders none, so a contiguous GFM pipe-table
4
+ * block is wrapped in a code fence (monospace keeps the columns aligned).
5
+ */
6
+ export function discordMarkdown(text) {
7
+ if (!text.includes("|"))
8
+ return text;
9
+ return wrapPipeTables(text);
10
+ }
11
+ /** A GFM table row: starts/ends with optional pipes and contains at least one `|`. */
12
+ const TABLE_ROW = /^\s*\|?.*\|.*$/;
13
+ /** The separator row under the header: cells of dashes/colons (with optional outer pipes). */
14
+ const TABLE_SEP = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
15
+ /** A fence delimiter line: ``` or ~~~ optionally followed by a language tag. */
16
+ const FENCE = /^\s*(`{3,}|~{3,})/;
17
+ /** Count GFM cells in a row: split on unescaped pipes, drop empty leading/trailing cells. */
18
+ function cellCount(line) {
19
+ // Split on pipes that are NOT escaped (`\|` is a literal pipe inside a cell,
20
+ // not a column separator). Negative lookbehind needs Node 18+ (supported).
21
+ const cells = line.trim().split(/(?<!\\)\|/);
22
+ // A leading/trailing pipe produces an empty edge cell — ignore those.
23
+ if (cells.length && cells[0].trim() === "")
24
+ cells.shift();
25
+ if (cells.length && cells[cells.length - 1].trim() === "")
26
+ cells.pop();
27
+ return cells.length;
28
+ }
29
+ function wrapPipeTables(text) {
30
+ const lines = text.split("\n");
31
+ const out = [];
32
+ let i = 0;
33
+ let inFence = false;
34
+ while (i < lines.length) {
35
+ // Track existing fenced blocks so a model-fenced table is not double-wrapped.
36
+ if (FENCE.test(lines[i])) {
37
+ inFence = !inFence;
38
+ out.push(lines[i]);
39
+ i += 1;
40
+ continue;
41
+ }
42
+ // A table is a header row, a separator row whose column count matches the
43
+ // header, then >=1 body rows. Only detect outside an existing fence so prose
44
+ // with stray `|` (one row, no matching separator) is left alone.
45
+ if (!inFence &&
46
+ i + 1 < lines.length &&
47
+ TABLE_ROW.test(lines[i]) &&
48
+ TABLE_SEP.test(lines[i + 1]) &&
49
+ cellCount(lines[i + 1]) === cellCount(lines[i])) {
50
+ const block = [lines[i], lines[i + 1]];
51
+ i += 2;
52
+ while (i < lines.length &&
53
+ !FENCE.test(lines[i]) &&
54
+ TABLE_ROW.test(lines[i]) &&
55
+ lines[i].includes("|")) {
56
+ block.push(lines[i]);
57
+ i += 1;
58
+ }
59
+ out.push("```", ...block, "```");
60
+ continue;
61
+ }
62
+ out.push(lines[i]);
63
+ i += 1;
64
+ }
65
+ return out.join("\n");
66
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=markdown.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"markdown.test.d.ts","sourceRoot":"","sources":["../src/markdown.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,47 @@
1
+ // packages/channels-discord/src/markdown.test.ts
2
+ import { describe, it, expect } from "vitest";
3
+ import { discordMarkdown } from "./markdown.js";
4
+ describe("discordMarkdown", () => {
5
+ it("passes ordinary markdown through unchanged", () => {
6
+ const md = "**bold** _italic_ `code`\n# Heading\n- a\n- b";
7
+ expect(discordMarkdown(md)).toBe(md);
8
+ });
9
+ it("wraps a GFM table in a code fence (Discord can't render tables)", () => {
10
+ const md = ["| A | B |", "| - | - |", "| 1 | 2 |"].join("\n");
11
+ const out = discordMarkdown(md);
12
+ expect(out).toContain("```");
13
+ expect(out).toContain("A");
14
+ expect(out).toContain("1");
15
+ // The original pipe-table syntax must be inside a fence, not left raw.
16
+ expect(out.startsWith("```")).toBe(true);
17
+ });
18
+ it("leaves prose containing a stray pipe alone (no false-positive fence)", () => {
19
+ const md = "Use the `a | b` operator.\nIt is a bitwise or.";
20
+ expect(discordMarkdown(md)).toBe(md);
21
+ });
22
+ it("does not fence a one-row pseudo-table without a matching separator", () => {
23
+ const md = "| just | one | row |\nsome following prose";
24
+ expect(discordMarkdown(md)).toBe(md);
25
+ });
26
+ it("does not wrap when the separator column count mismatches the header", () => {
27
+ // Header has 2 columns, separator has 3 — not a real GFM table.
28
+ const md = "| A | B |\n| - | - | - |\n| 1 | 2 |";
29
+ expect(discordMarkdown(md)).toBe(md);
30
+ });
31
+ it("detects and wraps a table whose cell contains an escaped pipe", () => {
32
+ // Header has two cells: "A \| B" (an escaped pipe inside one cell) and "C".
33
+ // A naive split on every `|` would count three cells and fail to match the
34
+ // two-column separator, leaving the table unwrapped.
35
+ const md = ["| A \\| B | C |", "| - | - |", "| 1 | 2 |"].join("\n");
36
+ const out = discordMarkdown(md);
37
+ expect(out.startsWith("```")).toBe(true);
38
+ expect((out.match(/```/g) ?? []).length).toBe(2);
39
+ });
40
+ it("does not double-fence a table already inside a model fence", () => {
41
+ const md = ["```", "| A | B |", "| - | - |", "| 1 | 2 |", "```"].join("\n");
42
+ const out = discordMarkdown(md);
43
+ // Exactly the opening + closing fence — no extra inner pair.
44
+ expect((out.match(/```/g) ?? []).length).toBe(2);
45
+ expect(out).toBe(md);
46
+ });
47
+ });