@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
package/dist/adapter.js
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import { Client, GatewayIntentBits, MessageFlags, Partials, REST, } from "discord.js";
|
|
2
|
+
import { toPlatformEmoji } from "@copilotkit/channels-ui";
|
|
3
|
+
import { DiscordConversationStore } from "./conversation-store.js";
|
|
4
|
+
import { attachDiscordListener } from "./discord-listener.js";
|
|
5
|
+
import { createRunRenderer } from "./event-renderer.js";
|
|
6
|
+
import { decodeInteraction, decodeModalSubmit } from "./interaction.js";
|
|
7
|
+
import { PendingInteractions } from "./pending-interactions.js";
|
|
8
|
+
import { renderComponents, renderDiscordMessage, } from "./render/components-v2.js";
|
|
9
|
+
import { renderDiscordModal } from "./render/modal.js";
|
|
10
|
+
import { registerCommands as putCommands } from "./commands.js";
|
|
11
|
+
import { ChunkedMessageStream, STREAM_PLACEHOLDERS, } from "./chunked-message-stream.js";
|
|
12
|
+
import { discordMarkdown } from "./markdown.js";
|
|
13
|
+
import { autoCloseOpenMarkdown } from "./auto-close-streaming.js";
|
|
14
|
+
/**
|
|
15
|
+
* Default Gateway intents for the bot.
|
|
16
|
+
*
|
|
17
|
+
* - `Guilds`, `GuildMessages`, `MessageContent`, `DirectMessages` — message ingress.
|
|
18
|
+
* - `GuildMembers` — privileged; backs `lookup_discord_user` / `thread.lookupUser`.
|
|
19
|
+
* Must be toggled on in the Discord Developer Portal (Bot → Privileged Gateway Intents).
|
|
20
|
+
* - `GuildMessageReactions` — non-privileged; no portal toggle needed.
|
|
21
|
+
* Required to receive reaction add/remove events in guild channels.
|
|
22
|
+
* - `DirectMessageReactions` — non-privileged; required to receive reaction
|
|
23
|
+
* add/remove events in DMs. Distinct from `GuildMessageReactions` in discord.js v14.
|
|
24
|
+
*/
|
|
25
|
+
export const DISCORD_DEFAULT_INTENTS = [
|
|
26
|
+
GatewayIntentBits.Guilds,
|
|
27
|
+
GatewayIntentBits.GuildMessages,
|
|
28
|
+
GatewayIntentBits.MessageContent,
|
|
29
|
+
GatewayIntentBits.DirectMessages,
|
|
30
|
+
// Privileged intent — required for `guild.members.search` in
|
|
31
|
+
// `lookupUser`. Like MessageContent, it must be enabled in the
|
|
32
|
+
// Discord Developer Portal for the bot application.
|
|
33
|
+
GatewayIntentBits.GuildMembers,
|
|
34
|
+
GatewayIntentBits.GuildMessageReactions, // reactions in guild channels — non-privileged
|
|
35
|
+
GatewayIntentBits.DirectMessageReactions, // reactions in DMs — non-privileged
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* Default partials for the bot.
|
|
39
|
+
*
|
|
40
|
+
* - `Channel` — needed to receive DMs.
|
|
41
|
+
* - `Message` + `Reaction` — required to receive reactions on uncached messages.
|
|
42
|
+
* Discord only delivers partial reaction events when the reacted-to message is not
|
|
43
|
+
* in the client's cache; without these partials those events are silently dropped.
|
|
44
|
+
*/
|
|
45
|
+
export const DISCORD_DEFAULT_PARTIALS = [
|
|
46
|
+
Partials.Channel, // needed to receive DMs
|
|
47
|
+
Partials.Message, // receive reactions on uncached messages
|
|
48
|
+
Partials.Reaction,
|
|
49
|
+
];
|
|
50
|
+
export class DiscordAdapter {
|
|
51
|
+
opts;
|
|
52
|
+
platform = "discord";
|
|
53
|
+
capabilities = {
|
|
54
|
+
supportsModals: true,
|
|
55
|
+
supportsTyping: true,
|
|
56
|
+
supportsReactions: true,
|
|
57
|
+
supportsEphemeral: false, // ephemeral only via interaction reply / DM fallback
|
|
58
|
+
supportsStreaming: true,
|
|
59
|
+
maxBlocksPerMessage: 40,
|
|
60
|
+
};
|
|
61
|
+
ackDeadlineMs = 3000;
|
|
62
|
+
client;
|
|
63
|
+
rest;
|
|
64
|
+
store;
|
|
65
|
+
botUserId = "";
|
|
66
|
+
isReady = false;
|
|
67
|
+
pendingCommands = [];
|
|
68
|
+
userCache = new Map();
|
|
69
|
+
/**
|
|
70
|
+
* Tracks live component interactions so a handler can open a modal (which
|
|
71
|
+
* must be the interaction's INITIAL response, within ~3s) before the adapter
|
|
72
|
+
* auto-defers. Constructed in `start()`. Used by `openModal` (Task D4).
|
|
73
|
+
*/
|
|
74
|
+
pending;
|
|
75
|
+
/**
|
|
76
|
+
* Tracks live slash-command interactions (a command's initial response is a
|
|
77
|
+
* reply, not a component update, so it needs its own registry). `openModal`
|
|
78
|
+
* consults this too, so a command handler can `showModal` before the adapter
|
|
79
|
+
* auto-defers. Constructed in `start()`.
|
|
80
|
+
*/
|
|
81
|
+
commandPending;
|
|
82
|
+
constructor(opts, injected) {
|
|
83
|
+
this.opts = opts;
|
|
84
|
+
this.client =
|
|
85
|
+
injected?.client ??
|
|
86
|
+
new Client({
|
|
87
|
+
intents: [...DISCORD_DEFAULT_INTENTS],
|
|
88
|
+
partials: [...DISCORD_DEFAULT_PARTIALS],
|
|
89
|
+
});
|
|
90
|
+
this.rest =
|
|
91
|
+
injected?.rest ??
|
|
92
|
+
new REST().setToken(opts.botToken);
|
|
93
|
+
// Reconstruct full channel history each turn (bot-slack parity). The store
|
|
94
|
+
// reads the Discord channel — the source of truth — so the inbound message
|
|
95
|
+
// and any earlier attachments are part of the rebuilt history.
|
|
96
|
+
this.store = new DiscordConversationStore({
|
|
97
|
+
fetchHistory: (channelId) => this.fetchHistory(channelId),
|
|
98
|
+
botUserId: () => this.botUserId,
|
|
99
|
+
filesConfig: undefined,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
registerCommands(commands) {
|
|
103
|
+
this.pendingCommands = commands;
|
|
104
|
+
// `ready` may have already fired (start() resolves before the gateway READY
|
|
105
|
+
// event, and the engine calls registerCommands AFTER start()). If so, the
|
|
106
|
+
// once("ready") publish already ran with an empty list — publish now.
|
|
107
|
+
if (this.isReady)
|
|
108
|
+
void this.publishCommands();
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Publish the registered commands. Guards against an empty list: an empty
|
|
112
|
+
* `setMyCommands`/`PUT` CLEARS all of the bot's commands, so a race where
|
|
113
|
+
* `ready` fires before commands are stashed must not wipe them.
|
|
114
|
+
*/
|
|
115
|
+
async publishCommands() {
|
|
116
|
+
if (this.pendingCommands.length === 0)
|
|
117
|
+
return;
|
|
118
|
+
try {
|
|
119
|
+
await putCommands(this.rest, this.opts.appId, this.opts.guildId, this.pendingCommands);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
console.error("[bot-discord] command registration failed:", err);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async start(sink) {
|
|
126
|
+
this.client.once("ready", async () => {
|
|
127
|
+
this.botUserId = this.client.user?.id ?? "";
|
|
128
|
+
this.isReady = true;
|
|
129
|
+
await this.publishCommands();
|
|
130
|
+
});
|
|
131
|
+
// Slash commands ack via `deferReply` (a command's initial response is a
|
|
132
|
+
// reply, not a component update), so they need a registry distinct from
|
|
133
|
+
// the component one above. A handler may `openModal` first; otherwise the
|
|
134
|
+
// auto-defer fires inside the 3s window.
|
|
135
|
+
this.commandPending = new PendingInteractions({
|
|
136
|
+
ackBufferMs: this.ackDeadlineMs - 500,
|
|
137
|
+
defer: (i) => i
|
|
138
|
+
.deferReply({ flags: MessageFlags.Ephemeral })
|
|
139
|
+
.then(() => undefined),
|
|
140
|
+
});
|
|
141
|
+
attachDiscordListener({
|
|
142
|
+
client: this.client,
|
|
143
|
+
botUserId: () => this.botUserId, // read lazily — only known after `ready`
|
|
144
|
+
commandPending: this.commandPending,
|
|
145
|
+
onTurn: async (turn) => {
|
|
146
|
+
// The conversation store reconstructs the full channel history each
|
|
147
|
+
// turn — including the triggering message and ALL its attachments — so
|
|
148
|
+
// the bridge passes context only; no per-turn content-part building.
|
|
149
|
+
await sink.onTurn({
|
|
150
|
+
conversationKey: turn.conversationKey,
|
|
151
|
+
replyTarget: turn.replyTarget,
|
|
152
|
+
userText: turn.userText,
|
|
153
|
+
user: turn.senderUserId
|
|
154
|
+
? await this.resolveUser(turn.senderUserId)
|
|
155
|
+
: undefined,
|
|
156
|
+
platform: "discord",
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
onCommand: async (cmd) => {
|
|
160
|
+
const user = cmd.senderUserId
|
|
161
|
+
? await this.resolveUser(cmd.senderUserId)
|
|
162
|
+
: undefined;
|
|
163
|
+
await sink.onCommand({
|
|
164
|
+
command: cmd.command,
|
|
165
|
+
text: cmd.text,
|
|
166
|
+
rawOptions: cmd.rawOptions,
|
|
167
|
+
conversationKey: cmd.conversationKey,
|
|
168
|
+
replyTarget: cmd.replyTarget,
|
|
169
|
+
user,
|
|
170
|
+
platform: "discord",
|
|
171
|
+
triggerId: cmd.triggerId,
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
onReaction: sink.onReaction ? sink.onReaction.bind(sink) : undefined,
|
|
175
|
+
});
|
|
176
|
+
// One registry per adapter. Auto-defer fires `ackBufferMs` after register,
|
|
177
|
+
// leaving a ~500ms cushion inside Discord's 3s window. A handler may
|
|
178
|
+
// `openModal` (Task D4) first, which calls `pending.respondWith` to win the
|
|
179
|
+
// race and cancel the auto-defer.
|
|
180
|
+
this.pending = new PendingInteractions({
|
|
181
|
+
ackBufferMs: this.ackDeadlineMs - 500,
|
|
182
|
+
defer: (i) => i.deferUpdate(),
|
|
183
|
+
});
|
|
184
|
+
// Component interactions: register with the timer-race registry, hand the
|
|
185
|
+
// decoded event to the sink (so a handler can open a modal first), then
|
|
186
|
+
// settle — acking with deferUpdate if the handler never responded.
|
|
187
|
+
this.client.on("interactionCreate", async (i) => {
|
|
188
|
+
if (typeof i?.isButton !== "function")
|
|
189
|
+
return;
|
|
190
|
+
if (i.isButton() || i.isStringSelectMenu?.()) {
|
|
191
|
+
const triggerId = this.pending.register(i);
|
|
192
|
+
try {
|
|
193
|
+
const evt = this.decodeInteraction(i);
|
|
194
|
+
if (evt) {
|
|
195
|
+
evt.triggerId = triggerId;
|
|
196
|
+
await sink.onInteraction(evt);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
// Mirror discord-listener's onTurn/onCommand guards: a malformed
|
|
201
|
+
// payload (decode throw) or a rejected sink dispatch must not become
|
|
202
|
+
// an unhandled promise rejection. `settle` below still acks the
|
|
203
|
+
// interaction, so logging here is sufficient.
|
|
204
|
+
console.error("[bot-discord] interaction dispatch failed:", err);
|
|
205
|
+
}
|
|
206
|
+
await this.pending.settle(triggerId);
|
|
207
|
+
}
|
|
208
|
+
else if (i.isModalSubmit?.()) {
|
|
209
|
+
try {
|
|
210
|
+
await sink.onModalSubmit(decodeModalSubmit(i)); // result ignored — Discord can't re-open with errors
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
console.error("[bot-discord] modal submit dispatch failed:", err);
|
|
214
|
+
}
|
|
215
|
+
// Ack must match the modal's origin: `deferUpdate` is only valid for a
|
|
216
|
+
// modal opened FROM a message component (button/select). A modal opened
|
|
217
|
+
// from a slash command has no originating message, so `deferUpdate`
|
|
218
|
+
// throws there — use `deferReply` (ephemeral) instead. Guard the ack so
|
|
219
|
+
// it can never become an unhandled rejection in the event listener.
|
|
220
|
+
try {
|
|
221
|
+
if (!i.replied && !i.deferred) {
|
|
222
|
+
if (i.isFromMessage?.())
|
|
223
|
+
await i.deferUpdate();
|
|
224
|
+
else
|
|
225
|
+
await i.deferReply({ flags: MessageFlags.Ephemeral });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
console.error("[bot-discord] modal submit ack failed:", err);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
await this.client.login(this.opts.botToken);
|
|
234
|
+
}
|
|
235
|
+
async stop() {
|
|
236
|
+
await this.client.destroy();
|
|
237
|
+
}
|
|
238
|
+
render(ir) {
|
|
239
|
+
return renderComponents(ir);
|
|
240
|
+
}
|
|
241
|
+
async post(target, ir) {
|
|
242
|
+
const t = target;
|
|
243
|
+
const channel = await this.fetchSendable(t.channelId);
|
|
244
|
+
const { components, flags } = renderDiscordMessage(ir);
|
|
245
|
+
const msg = await channel.send({ components, flags });
|
|
246
|
+
return { id: msg.id, channelId: t.channelId };
|
|
247
|
+
}
|
|
248
|
+
async update(ref, ir) {
|
|
249
|
+
// An empty stream returns a ref with id "" (no message was ever posted);
|
|
250
|
+
// a fetch on "" would throw, so treat update/delete on it as a no-op.
|
|
251
|
+
if (!ref.id)
|
|
252
|
+
return;
|
|
253
|
+
const channel = await this.fetchSendable(this.channelIdOf(ref));
|
|
254
|
+
const msg = await channel.messages.fetch(ref.id);
|
|
255
|
+
const { components, flags } = renderDiscordMessage(ir);
|
|
256
|
+
await msg.edit({ components, flags });
|
|
257
|
+
}
|
|
258
|
+
async stream(target, chunks) {
|
|
259
|
+
const t = target;
|
|
260
|
+
const channel = await this.fetchSendable(t.channelId);
|
|
261
|
+
let firstId = "";
|
|
262
|
+
// One handle per posted Discord message, keyed by the id returned from
|
|
263
|
+
// `channel.send`. Multi-chunk replies (>2000 chars) post several
|
|
264
|
+
// messages; `updateAt(id, …)` must edit the message that owns `id`,
|
|
265
|
+
// not always message #0. Mirrors the `messages` Map in event-renderer.ts.
|
|
266
|
+
const handles = new Map();
|
|
267
|
+
const stream = new ChunkedMessageStream({
|
|
268
|
+
postPlaceholder: async (text) => {
|
|
269
|
+
const m = await channel.send(text);
|
|
270
|
+
if (!firstId)
|
|
271
|
+
firstId = m.id;
|
|
272
|
+
handles.set(m.id, m);
|
|
273
|
+
return m.id;
|
|
274
|
+
},
|
|
275
|
+
updateAt: async (id, text) => {
|
|
276
|
+
await handles.get(id)?.edit(text);
|
|
277
|
+
},
|
|
278
|
+
transform: (s) => discordMarkdown(autoCloseOpenMarkdown(s)),
|
|
279
|
+
});
|
|
280
|
+
let acc = "";
|
|
281
|
+
// If the source iterable rejects partway, `finish()` must still run so the
|
|
282
|
+
// already-posted "_thinking…_" placeholder gets drained to its accumulated
|
|
283
|
+
// text instead of being frozen forever; then let the original error
|
|
284
|
+
// propagate.
|
|
285
|
+
try {
|
|
286
|
+
for await (const chunk of chunks) {
|
|
287
|
+
acc += chunk;
|
|
288
|
+
stream.append(acc);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
await stream.finish();
|
|
293
|
+
}
|
|
294
|
+
return { id: firstId, channelId: t.channelId };
|
|
295
|
+
}
|
|
296
|
+
async delete(ref) {
|
|
297
|
+
if (!ref.id)
|
|
298
|
+
return; // empty-stream ref — nothing was posted
|
|
299
|
+
const channel = await this.fetchSendable(this.channelIdOf(ref));
|
|
300
|
+
const msg = await channel.messages.fetch(ref.id);
|
|
301
|
+
await msg.delete();
|
|
302
|
+
}
|
|
303
|
+
createRunRenderer(target) {
|
|
304
|
+
const t = target;
|
|
305
|
+
// Resolve the channel lazily inside a thin wrapper so createRunRenderer stays sync.
|
|
306
|
+
const channelPromise = this.fetchSendable(t.channelId);
|
|
307
|
+
return createRunRenderer({
|
|
308
|
+
channel: {
|
|
309
|
+
async sendTyping() {
|
|
310
|
+
await (await channelPromise).sendTyping?.();
|
|
311
|
+
},
|
|
312
|
+
async send(payload) {
|
|
313
|
+
return (await channelPromise).send(payload);
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
interruptEventNames: this.opts.interruptEventNames,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
decodeInteraction(raw) {
|
|
320
|
+
return decodeInteraction(raw);
|
|
321
|
+
}
|
|
322
|
+
async lookupUser(q) {
|
|
323
|
+
const query = q.query.trim();
|
|
324
|
+
if (!query)
|
|
325
|
+
return undefined;
|
|
326
|
+
// Search guild members by username/displayName across cached guilds.
|
|
327
|
+
for (const guild of this.client.guilds.cache.values()) {
|
|
328
|
+
try {
|
|
329
|
+
const found = await guild.members.search({ query, limit: 1 });
|
|
330
|
+
const member = found.first();
|
|
331
|
+
if (member) {
|
|
332
|
+
const user = member.user;
|
|
333
|
+
return {
|
|
334
|
+
id: user.id,
|
|
335
|
+
name: user.globalName ?? user.username,
|
|
336
|
+
handle: user.username,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
console.error(`[bot-discord] member search failed (guild ${guild.id}, query "${query}"):`, err);
|
|
342
|
+
/* try the next guild */
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
get conversationStore() {
|
|
348
|
+
return this.store;
|
|
349
|
+
}
|
|
350
|
+
async getMessages(target) {
|
|
351
|
+
const t = target;
|
|
352
|
+
try {
|
|
353
|
+
const channel = await this.fetchSendable(t.channelId);
|
|
354
|
+
const fetched = await channel.messages.fetch({ limit: 100 });
|
|
355
|
+
return ([...fetched.values()]
|
|
356
|
+
.toReversed()
|
|
357
|
+
// Drop the bot's own streaming placeholders ("_thinking…_" /
|
|
358
|
+
// "_…(continued)_") so they don't pollute the read_thread history
|
|
359
|
+
// (bot-slack parity — it filters its own status/placeholders too).
|
|
360
|
+
.filter((m) => !(Boolean(m.author?.bot) &&
|
|
361
|
+
STREAM_PLACEHOLDERS.includes(m.content ?? "")))
|
|
362
|
+
.map((m) => ({
|
|
363
|
+
text: m.content ?? "",
|
|
364
|
+
ts: m.id,
|
|
365
|
+
isBot: Boolean(m.author?.bot),
|
|
366
|
+
user: m.author
|
|
367
|
+
? {
|
|
368
|
+
id: m.author.id,
|
|
369
|
+
name: m.author.globalName ?? m.author.username,
|
|
370
|
+
handle: m.author.username,
|
|
371
|
+
}
|
|
372
|
+
: undefined,
|
|
373
|
+
})));
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
console.error(`[bot-discord] getMessages failed (channel ${t.channelId}):`, err);
|
|
377
|
+
return [];
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async postFile(target, args) {
|
|
381
|
+
const t = target;
|
|
382
|
+
try {
|
|
383
|
+
const channel = await this.fetchSendable(t.channelId);
|
|
384
|
+
const msg = await channel.send({
|
|
385
|
+
files: [{ attachment: Buffer.from(args.bytes), name: args.filename }],
|
|
386
|
+
});
|
|
387
|
+
return { ok: true, fileId: msg.id };
|
|
388
|
+
}
|
|
389
|
+
catch (e) {
|
|
390
|
+
return { ok: false, error: e.message };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
async addReaction(target, messageRef, emoji) {
|
|
394
|
+
const token = toPlatformEmoji(emoji, "discord") ?? String(emoji);
|
|
395
|
+
try {
|
|
396
|
+
// Fall back to the conversation's target channel when the reacted ref
|
|
397
|
+
// carries no channelId — parity with Slack/Telegram, which the bot-ui
|
|
398
|
+
// contract and the example rely on (the reacted ref is often just `{ id }`).
|
|
399
|
+
const channel = await this.fetchSendable(this.channelIdOf(messageRef) || target.channelId);
|
|
400
|
+
const msg = await channel.messages.fetch(messageRef.id);
|
|
401
|
+
await msg.react(token);
|
|
402
|
+
return { ok: true };
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
return { ok: false, error: err.message };
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
async removeReaction(target, messageRef, emoji) {
|
|
409
|
+
const token = toPlatformEmoji(emoji, "discord") ?? String(emoji);
|
|
410
|
+
try {
|
|
411
|
+
// Fall back to the conversation's target channel when the reacted ref
|
|
412
|
+
// carries no channelId — parity with Slack/Telegram (see addReaction).
|
|
413
|
+
const channel = await this.fetchSendable(this.channelIdOf(messageRef) || target.channelId);
|
|
414
|
+
const msg = await channel.messages.fetch(messageRef.id);
|
|
415
|
+
// Discord may key the cache by the bare codepoint while `token` carries
|
|
416
|
+
// the table's trailing U+FE0F (e.g. "❤️" vs "❤"), so resolve tolerantly.
|
|
417
|
+
const cache = msg.reactions?.cache;
|
|
418
|
+
const reaction = cache?.get(token) ?? cache?.get(token.replace(/️/g, ""));
|
|
419
|
+
// Prefer the cached bot id (known after `ready`); fall back to the live
|
|
420
|
+
// client user without a non-null assertion that could throw pre-`ready`.
|
|
421
|
+
await reaction?.users?.remove(this.botUserId || this.client.user?.id);
|
|
422
|
+
return { ok: true };
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
return { ok: false, error: err.message };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
async postEphemeral(_target, user, ir, opts) {
|
|
429
|
+
// Native interaction-ephemeral is only reachable from within a live,
|
|
430
|
+
// unresponded interaction; that path is not plumbed into postEphemeral in
|
|
431
|
+
// this pass, so we use the DM fallback (or null).
|
|
432
|
+
if (!opts.fallbackToDM)
|
|
433
|
+
return null;
|
|
434
|
+
const userId = typeof user === "string" ? user : user.id;
|
|
435
|
+
try {
|
|
436
|
+
const u = await this.client.users.fetch(userId);
|
|
437
|
+
const dm = await u.createDM();
|
|
438
|
+
const { components, flags } = renderDiscordMessage(ir);
|
|
439
|
+
await dm.send({ components, flags });
|
|
440
|
+
return {
|
|
441
|
+
ok: true,
|
|
442
|
+
usedFallback: true,
|
|
443
|
+
ref: { id: "", channelId: dm.id },
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
return { ok: false, error: err.message };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
renderModal(ir) {
|
|
451
|
+
return renderDiscordModal(ir);
|
|
452
|
+
}
|
|
453
|
+
async openModal(_target, triggerId, ir) {
|
|
454
|
+
let modal;
|
|
455
|
+
try {
|
|
456
|
+
modal = renderDiscordModal(ir);
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
return { ok: false, error: err.message };
|
|
460
|
+
}
|
|
461
|
+
let shown;
|
|
462
|
+
try {
|
|
463
|
+
const show = (i) => i.showModal(modal);
|
|
464
|
+
// A triggerId belongs to exactly one registry: component interactions
|
|
465
|
+
// (buttons/selects) live in `pending`, slash commands in `commandPending`.
|
|
466
|
+
// `respondWith` returns false (no throw) when the id isn't in a registry,
|
|
467
|
+
// so try both — without this, opening a modal from a slash command (e.g.
|
|
468
|
+
// /file-issue) silently fails because the command isn't in `pending`.
|
|
469
|
+
shown =
|
|
470
|
+
(await this.pending.respondWith(triggerId, show)) ||
|
|
471
|
+
(await this.commandPending.respondWith(triggerId, show));
|
|
472
|
+
}
|
|
473
|
+
catch (err) {
|
|
474
|
+
return { ok: false, error: err.message };
|
|
475
|
+
}
|
|
476
|
+
return shown
|
|
477
|
+
? { ok: true }
|
|
478
|
+
: {
|
|
479
|
+
ok: false,
|
|
480
|
+
error: "interaction already acknowledged (open the modal before other work)",
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
async resolveUser(userId) {
|
|
484
|
+
const cached = this.userCache.get(userId);
|
|
485
|
+
if (cached)
|
|
486
|
+
return cached;
|
|
487
|
+
try {
|
|
488
|
+
const u = await this.client.users.fetch(userId);
|
|
489
|
+
const user = {
|
|
490
|
+
id: u.id,
|
|
491
|
+
name: u.globalName ?? u.username,
|
|
492
|
+
handle: u.username,
|
|
493
|
+
};
|
|
494
|
+
// Note: bots cannot read user email; PlatformUser.email stays undefined.
|
|
495
|
+
// Cache ONLY on success — a transient fetch failure must not pin the
|
|
496
|
+
// bare-id fallback forever; a later call should retry.
|
|
497
|
+
this.userCache.set(userId, user);
|
|
498
|
+
return user;
|
|
499
|
+
}
|
|
500
|
+
catch (err) {
|
|
501
|
+
console.error(`[bot-discord] resolveUser fetch failed (${userId}):`, err);
|
|
502
|
+
return { id: userId }; // bare-id fallback, intentionally not cached
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
channelIdOf(ref) {
|
|
506
|
+
return String(ref.channelId ?? "");
|
|
507
|
+
}
|
|
508
|
+
async fetchSendable(channelId) {
|
|
509
|
+
const ch = await this.client.channels.fetch(channelId);
|
|
510
|
+
if (!ch || !("send" in ch)) {
|
|
511
|
+
throw new Error(`channel ${channelId} is not sendable`);
|
|
512
|
+
}
|
|
513
|
+
return ch;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Fetch the channel's recent messages OLDEST→NEWEST, normalized for the
|
|
517
|
+
* conversation store's history reconstruction. Best-effort: on any fetch
|
|
518
|
+
* error returns [] (bot-slack's fetchHistory does the same).
|
|
519
|
+
*/
|
|
520
|
+
async fetchHistory(channelId) {
|
|
521
|
+
const mapMsg = (m) => ({
|
|
522
|
+
id: m.id,
|
|
523
|
+
content: m.content ?? "",
|
|
524
|
+
authorId: m.author?.id,
|
|
525
|
+
authorIsBot: Boolean(m.author?.bot),
|
|
526
|
+
attachments: m.attachments
|
|
527
|
+
? Array.from(m.attachments.values()).map((a) => ({
|
|
528
|
+
url: a.url,
|
|
529
|
+
name: a.name,
|
|
530
|
+
contentType: a.contentType,
|
|
531
|
+
size: a.size,
|
|
532
|
+
}))
|
|
533
|
+
: [],
|
|
534
|
+
});
|
|
535
|
+
try {
|
|
536
|
+
const channel = await this.fetchSendable(channelId);
|
|
537
|
+
const fetched = await channel.messages.fetch({ limit: 100 });
|
|
538
|
+
const msgs = [...fetched.values()].toReversed().map(mapMsg); // oldest→newest
|
|
539
|
+
// A thread's *starter* message (the one the thread was created from) lives
|
|
540
|
+
// in the PARENT channel and is NOT part of the thread's own message list,
|
|
541
|
+
// so `messages.fetch` above never returns it. Pull it in so an image/text
|
|
542
|
+
// on the message a thread was started from is part of the reconstructed
|
|
543
|
+
// history. (Slack's `conversations.replies` returns the parent
|
|
544
|
+
// automatically; Discord does not.) Best-effort: the starter may be
|
|
545
|
+
// deleted, or the thread may have none (e.g. forum/standalone threads).
|
|
546
|
+
const ch = channel;
|
|
547
|
+
if (typeof ch.isThread === "function" && ch.isThread()) {
|
|
548
|
+
try {
|
|
549
|
+
const starter = await ch.fetchStarterMessage?.();
|
|
550
|
+
if (starter)
|
|
551
|
+
msgs.unshift(mapMsg(starter));
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
/* starter deleted or unavailable — best-effort */
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return msgs;
|
|
558
|
+
}
|
|
559
|
+
catch (err) {
|
|
560
|
+
console.warn(`[bot-discord] fetchHistory failed (channel ${channelId}):`, err);
|
|
561
|
+
return [];
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
export function discord(opts) {
|
|
566
|
+
return new DiscordAdapter(opts);
|
|
567
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adapter.test.d.ts","sourceRoot":"","sources":["../src/adapter.test.ts"],"names":[],"mappings":""}
|