@openpalm/discord-portal 0.12.7

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/src/index.ts ADDED
@@ -0,0 +1,814 @@
1
+ import {
2
+ asRaw,
3
+ ConversationQueue,
4
+ extractTextDelta,
5
+ isTurnEnd,
6
+ OcClient,
7
+ partSnapshotType,
8
+ SecretFileError,
9
+ createLogger,
10
+ readRequiredSecretFile,
11
+ splitMessage,
12
+ } from './runtime.ts';
13
+ import {
14
+ Client,
15
+ Events,
16
+ GatewayIntentBits,
17
+ MessageFlags,
18
+ Partials,
19
+ REST,
20
+ Routes,
21
+ ThreadAutoArchiveDuration,
22
+ type ChatInputCommandInteraction,
23
+ type GuildMember,
24
+ type Message,
25
+ type ThreadChannel,
26
+ } from "discord.js";
27
+ import { buildCommandRegistry, parseCustomCommands, resolvePromptTemplate } from "./commands.ts";
28
+ import { checkPermissions, loadPermissionConfig } from "./permissions.ts";
29
+ import { streamTurn, DISCORD_SESSION_PREAMBLE, type PendingQuestion } from "./stream-render.ts";
30
+ import { OcEventHub } from "./oc-event-hub.ts";
31
+ import type { PermissionConfig, UserInfo } from "./types.ts";
32
+
33
+ const log = createLogger("channel-discord");
34
+
35
+ const MAX_MESSAGE_LENGTH = 2000;
36
+
37
+ type ForwardResult = {
38
+ userId: string;
39
+ text: string;
40
+ metadata?: Record<string, unknown>;
41
+ };
42
+
43
+ function json(status: number, data: unknown): Response {
44
+ return new Response(JSON.stringify(data), {
45
+ status,
46
+ headers: { 'content-type': 'application/json' },
47
+ });
48
+ }
49
+
50
+ export default class DiscordChannel {
51
+ name = "discord";
52
+ port: number = Number(Bun.env.PORT) || 8080;
53
+ guardianUrl = 'http://guardian:8080';
54
+ private _fetchFn: typeof fetch = fetch;
55
+
56
+ private client: Client | null = null;
57
+ private permissions: PermissionConfig = loadPermissionConfig();
58
+ private commandRegistry = buildCommandRegistry(
59
+ parseCustomCommands(Bun.env.DISCORD_CUSTOM_COMMANDS),
60
+ );
61
+ private conversationQueue = new ConversationQueue();
62
+
63
+ /**
64
+ * Thread IDs the bot is actively participating in.
65
+ * Map of threadId → last activity timestamp (ms).
66
+ * Threads expire after threadTtlMs of inactivity.
67
+ */
68
+ private activeThreads = new Map<string, number>();
69
+
70
+ /** Thread inactivity TTL in ms. Default: 24 hours. */
71
+ private threadTtlMs = (Number(Bun.env.DISCORD_THREAD_TTL_HOURS) || 24) * 3_600_000;
72
+
73
+ /**
74
+ * Forward timeout in ms. Default: 0 (no timeout).
75
+ * When set, applied to guardian forwarding requests.
76
+ */
77
+ private forwardTimeoutMs = Number(Bun.env.DISCORD_FORWARD_TIMEOUT_MS) || 0;
78
+
79
+ /**
80
+ * Opt-in rich-UX streaming. When false (default), turns are buffered and the
81
+ * full assistant reply is posted once complete. When true, thread turns render
82
+ * live via the guardian /oc/* proxy with tool embeds + interactive permission
83
+ * prompts.
84
+ */
85
+ private streamingEnabled = Bun.env.DISCORD_STREAMING === "true";
86
+
87
+ /** Lazily-built native OpenCode client through the guardian /oc/* proxy. */
88
+ private ocClientInstance: OcClient | null = null;
89
+
90
+ /**
91
+ * One shared /event subscription per principal. Concurrent threads from the
92
+ * same user fan out from a SINGLE upstream stream, so we never trip the
93
+ * guardian's per-principal concurrent-stream cap (the /event stream is already
94
+ * principal-scoped — opening one per thread was redundant).
95
+ */
96
+ private ocEventHubInstance: OcEventHub | null = null;
97
+
98
+ /**
99
+ * Pending interactive `question` per thread, so the user can answer by typing a
100
+ * normal message in the thread (not only by clicking a button). Set by the
101
+ * streaming renderer when a question is asked, cleared when answered/turn-ends.
102
+ * (Session→thread mapping is now the GUARDIAN's job — it dedupes create per
103
+ * sessionKey — so no client-side session cache is needed.)
104
+ */
105
+ private pendingQuestions = new Map<string, PendingQuestion>();
106
+
107
+ /**
108
+ * Session keys that have already received the one-time channel preamble (the
109
+ * `question`-tool nudge prepended to the first prompt of a session). In-memory
110
+ * only: a restart re-primes each live session once, which is harmless. Cleared
111
+ * for a session on /clear so a fresh OpenCode session gets primed again.
112
+ */
113
+ private primedSessions = new Set<string>();
114
+
115
+ private get ocClient(): OcClient {
116
+ if (!this.ocClientInstance) {
117
+ this.ocClientInstance = new OcClient({
118
+ principalId: this.name,
119
+ secret: this.secret,
120
+ baseUrl: `${this.guardianUrl}/oc`,
121
+ });
122
+ }
123
+ return this.ocClientInstance;
124
+ }
125
+
126
+ private get ocEventHub(): OcEventHub {
127
+ if (!this.ocEventHubInstance) {
128
+ this.ocEventHubInstance = new OcEventHub(this.ocClient);
129
+ }
130
+ return this.ocEventHubInstance;
131
+ }
132
+
133
+ get botToken(): string {
134
+ return readRequiredSecretFile("DISCORD_BOT_TOKEN_FILE");
135
+ }
136
+
137
+ get applicationId(): string {
138
+ return Bun.env.DISCORD_APPLICATION_ID ?? "";
139
+ }
140
+
141
+ get secret(): string {
142
+ return readRequiredSecretFile('PRINCIPAL_SECRET_FILE');
143
+ }
144
+
145
+ async handleRequest(_req: Request): Promise<null> {
146
+ return null;
147
+ }
148
+
149
+ private async forward(result: ForwardResult, fetchFn?: typeof fetch, timeoutMs?: number): Promise<Response> {
150
+ const fn = fetchFn ?? this._fetchFn;
151
+ const controller = timeoutMs && timeoutMs > 0 ? new AbortController() : null;
152
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
153
+ try {
154
+ const client = new OcClient({
155
+ principalId: Bun.env.PRINCIPAL_ID ?? this.name,
156
+ secret: this.secret,
157
+ baseUrl: `${this.guardianUrl}/oc`,
158
+ fetch: fn,
159
+ });
160
+ const sessionKey = typeof result.metadata?.sessionKey === 'string' ? result.metadata.sessionKey : result.userId;
161
+ const session = await client.createSession(result.userId, sessionKey);
162
+ const answerPromise = collectTurnAnswer(client, result.userId, session.id, controller?.signal ?? new AbortController().signal);
163
+ await client.prompt(result.userId, session.id, result.text);
164
+ const answer = await answerPromise;
165
+ return json(200, { userId: result.userId, sessionId: session.id, answer });
166
+ } finally {
167
+ if (timer) clearTimeout(timer);
168
+ controller?.abort();
169
+ }
170
+ }
171
+
172
+ createFetch(fetchFn: typeof fetch = fetch): (req: Request) => Promise<Response> {
173
+ this._fetchFn = fetchFn;
174
+ return async (req: Request): Promise<Response> => {
175
+ const url = new URL(req.url);
176
+ if (url.pathname === '/health') {
177
+ return json(200, { ok: true, service: `channel-${this.name}` });
178
+ }
179
+ return json(404, { error: 'not_found' });
180
+ };
181
+ }
182
+
183
+ start(): void {
184
+ try {
185
+ this.secret;
186
+ } catch (err) {
187
+ log.error('startup_error', {
188
+ reason: err instanceof SecretFileError ? err.message : 'PRINCIPAL_SECRET_FILE could not be read',
189
+ });
190
+ process.exit(1);
191
+ }
192
+
193
+ try {
194
+ Bun.serve({ port: this.port, fetch: this.createFetch() });
195
+ log.info('started', { port: this.port });
196
+ } catch (err) {
197
+ log.error('failed to start server', {
198
+ port: this.port,
199
+ error: err instanceof Error ? err.message : String(err),
200
+ });
201
+ process.exit(1);
202
+ }
203
+
204
+ // Connect to Discord Gateway
205
+ void this.connectGateway();
206
+ }
207
+
208
+ // ── Gateway Connection ──────────────────────────────────────────────────
209
+
210
+ private async connectGateway(): Promise<void> {
211
+ let botToken: string;
212
+ try {
213
+ botToken = this.botToken;
214
+ } catch (err) {
215
+ log.error("startup_error", { reason: err instanceof Error ? err.message : "DISCORD_BOT_TOKEN_FILE could not be read" });
216
+ process.exit(1);
217
+ }
218
+
219
+ this.client = new Client({
220
+ intents: [
221
+ GatewayIntentBits.Guilds,
222
+ GatewayIntentBits.GuildMessages,
223
+ GatewayIntentBits.MessageContent,
224
+ GatewayIntentBits.DirectMessages,
225
+ ],
226
+ partials: [Partials.Message, Partials.Channel],
227
+ });
228
+
229
+ // discord.js emits Events.Error for WebSocket/shard errors. Without a
230
+ // listener, the EventEmitter rethrows as an uncaught exception and kills the
231
+ // process — log and keep the gateway alive so discord.js can auto-reconnect.
232
+ this.client.on(Events.Error, (err) => {
233
+ log.error("discord_client_error", { error: err instanceof Error ? err.message : String(err) });
234
+ });
235
+ this.client.once(Events.ClientReady, (c) => this.onReady(c));
236
+ this.client.on(Events.MessageCreate, (msg) => void this.onMessage(msg));
237
+ this.client.on(Events.InteractionCreate, (interaction) => {
238
+ if (interaction.isChatInputCommand()) {
239
+ void this.onSlashCommand(interaction);
240
+ }
241
+ });
242
+
243
+ await this.client.login(botToken);
244
+ }
245
+
246
+ private onReady(client: Client<true>): void {
247
+ log.info("gateway_connected", {
248
+ tag: client.user.tag,
249
+ guilds: client.guilds.cache.size,
250
+ });
251
+
252
+ if (this.applicationId && Bun.env.DISCORD_REGISTER_COMMANDS !== "false") {
253
+ void this.registerSlashCommands();
254
+ }
255
+ }
256
+
257
+ // ── Slash Command Registration ──────────────────────────────────────────
258
+
259
+ private async registerSlashCommands(): Promise<void> {
260
+ const rest = new REST().setToken(this.botToken);
261
+ const payload = this.commandRegistry.registrationPayload;
262
+ const allowedGuilds = this.permissions.allowedGuilds;
263
+
264
+ try {
265
+ if (allowedGuilds.size > 0) {
266
+ for (const guildId of allowedGuilds) {
267
+ await rest.put(
268
+ Routes.applicationGuildCommands(this.applicationId, guildId),
269
+ { body: payload },
270
+ );
271
+ log.info("commands_registered", {
272
+ scope: `guild:${guildId}`,
273
+ count: payload.length,
274
+ commands: payload.map((c) => c.name),
275
+ });
276
+ }
277
+ } else {
278
+ await rest.put(
279
+ Routes.applicationCommands(this.applicationId),
280
+ { body: payload },
281
+ );
282
+ log.info("commands_registered", {
283
+ scope: "global",
284
+ count: payload.length,
285
+ commands: payload.map((c) => c.name),
286
+ });
287
+ }
288
+ } catch (error) {
289
+ log.error("command_registration_failed", {
290
+ error: error instanceof Error ? error.message : String(error),
291
+ });
292
+ }
293
+ }
294
+
295
+ // ── Thread Tracking ────────────────────────────────────────────────────
296
+
297
+ /** Check if a thread has recent activity (within TTL). */
298
+ private isThreadActive(threadId: string): boolean {
299
+ const lastActivity = this.activeThreads.get(threadId);
300
+ if (lastActivity === undefined) return false;
301
+ if (Date.now() - lastActivity > this.threadTtlMs) {
302
+ this.activeThreads.delete(threadId);
303
+ return false;
304
+ }
305
+ return true;
306
+ }
307
+
308
+ /** Mark a thread as active (update timestamp). Prunes stale entries. */
309
+ private touchThread(threadId: string): void {
310
+ this.activeThreads.set(threadId, Date.now());
311
+ // Prune stale entries when map grows large
312
+ if (this.activeThreads.size > 100) {
313
+ const now = Date.now();
314
+ for (const [id, ts] of this.activeThreads) {
315
+ if (now - ts > this.threadTtlMs) {
316
+ this.activeThreads.delete(id);
317
+ }
318
+ }
319
+ }
320
+ }
321
+
322
+ /** Stop tracking a thread (used by /clear). */
323
+ private forgetThread(threadId: string): void {
324
+ this.activeThreads.delete(threadId);
325
+ this.primedSessions.delete(`discord:thread:${threadId}`);
326
+ }
327
+
328
+ // ── Message Handling ────────────────────────────────────────────────────
329
+
330
+ private shouldRespond(message: Message): boolean {
331
+ if (!this.client?.user) return false;
332
+ const botId = this.client.user.id;
333
+
334
+ // In a tracked thread with recent activity: always respond
335
+ if (message.channel.isThread() && this.isThreadActive(message.channel.id)) {
336
+ return true;
337
+ }
338
+
339
+ // Otherwise: only when mentioned
340
+ return message.mentions.has(botId);
341
+ }
342
+
343
+ private cleanContent(message: Message): string {
344
+ if (!this.client?.user) return message.content;
345
+ const botId = this.client.user.id;
346
+ return message.content
347
+ .replace(new RegExp(`<@!?${botId}>`, "g"), "")
348
+ .trim();
349
+ }
350
+
351
+ private extractUserInfo(message: Message): UserInfo {
352
+ return {
353
+ userId: message.author.id,
354
+ guildId: message.guildId ?? "",
355
+ roles: message.member?.roles.cache.map((r) => r.id) ?? [],
356
+ username: message.author.username,
357
+ };
358
+ }
359
+
360
+ private async sendTypingLoop(channel: ThreadChannel): Promise<() => void> {
361
+ await channel.sendTyping();
362
+ const typingInterval = setInterval(() => {
363
+ channel.sendTyping().catch(() => {});
364
+ }, 5000);
365
+
366
+ return () => clearInterval(typingInterval);
367
+ }
368
+
369
+ private async runThreadConversation(
370
+ thread: ThreadChannel,
371
+ userInfo: UserInfo,
372
+ text: string,
373
+ metadata: Record<string, unknown>,
374
+ triggerMessage: Message,
375
+ ): Promise<void> {
376
+ // Rich-UX streaming path (opt-in, Stage 4). Renders deltas + tool embeds +
377
+ // interactive permission prompts live via the guardian /oc/* proxy. The
378
+ // conversationQueue's run() promise settles when streamTurn resolves at
379
+ // turn-end (session idle), keeping per-sessionKey serialization intact.
380
+ if (this.streamingEnabled) {
381
+ const sessionKey = String(metadata.sessionKey ?? `discord:thread:${thread.id}`);
382
+ // Prime the model with the question-tool nudge ONCE per session (first turn).
383
+ const sessionPreamble = this.primedSessions.has(sessionKey) ? undefined : DISCORD_SESSION_PREAMBLE;
384
+ this.primedSessions.add(sessionKey);
385
+ try {
386
+ await streamTurn({
387
+ client: this.ocClient,
388
+ userId: `discord:${userInfo.userId}`,
389
+ requestingUserId: userInfo.userId,
390
+ thread,
391
+ sessionKey,
392
+ text,
393
+ sessionPreamble,
394
+ subscribeEvents: () => this.ocEventHub.subscribe(`discord:${userInfo.userId}`),
395
+ triggerMessage,
396
+ setPendingQuestion: (pending) => {
397
+ if (pending) this.pendingQuestions.set(thread.id, pending);
398
+ else this.pendingQuestions.delete(thread.id);
399
+ },
400
+ });
401
+ log.info("stream_completed", { userId: userInfo.userId, threadId: thread.id, sessionKey });
402
+ } catch (error) {
403
+ this.pendingQuestions.delete(thread.id);
404
+ const errMsg = error instanceof Error ? error.message : String(error);
405
+ log.error("stream_error", { error: errMsg, userId: userInfo.userId, sessionKey });
406
+ await thread.send(`Error: ${errMsg}`).catch(() => {});
407
+ }
408
+ return;
409
+ }
410
+
411
+ const stopTyping = await this.sendTypingLoop(thread);
412
+
413
+ try {
414
+ const resp = await this.forward({ userId: `discord:${userInfo.userId}`, text, metadata }, undefined, this.forwardTimeoutMs || undefined);
415
+ if (!resp.ok) throw new Error(`Guardian returned status ${resp.status}`);
416
+ const { answer = "No response received." } = await resp.json() as { answer?: string };
417
+ stopTyping();
418
+ await this.sendSplitMessage(thread, answer);
419
+ log.info("message_completed", {
420
+ userId: userInfo.userId,
421
+ guildId: userInfo.guildId,
422
+ threadId: thread.id,
423
+ sessionKey: metadata.sessionKey,
424
+ });
425
+ } catch (error) {
426
+ stopTyping();
427
+ const errMsg = error instanceof Error ? error.message : String(error);
428
+ log.error("message_error", {
429
+ error: errMsg,
430
+ userId: userInfo.userId,
431
+ sessionKey: metadata.sessionKey,
432
+ });
433
+ await thread.send(`Error: ${errMsg}`);
434
+ }
435
+ }
436
+
437
+ /** Track processed message IDs to prevent duplicate processing from Discord gateway re-deliveries. */
438
+ private processedMessages = new Set<string>();
439
+ private readonly PROCESSED_MSG_TTL_MS = 60_000;
440
+
441
+ private markProcessed(messageId: string): boolean {
442
+ if (this.processedMessages.has(messageId)) return false;
443
+ this.processedMessages.add(messageId);
444
+ setTimeout(() => this.processedMessages.delete(messageId), this.PROCESSED_MSG_TTL_MS);
445
+ return true;
446
+ }
447
+
448
+ private async onMessage(message: Message): Promise<void> {
449
+ if (message.author.bot) return;
450
+ if (!message.content) return;
451
+ if (!this.shouldRespond(message)) return;
452
+ if (!this.markProcessed(message.id)) return;
453
+
454
+ const userInfo = this.extractUserInfo(message);
455
+ const permResult = checkPermissions(this.permissions, userInfo);
456
+ if (!permResult.allowed) {
457
+ await message.reply("You do not have permission to use this bot.");
458
+ return;
459
+ }
460
+
461
+ const text = this.cleanContent(message);
462
+ if (!text.trim()) {
463
+ await message.reply("Please provide a message.");
464
+ return;
465
+ }
466
+
467
+ // If an interactive `question` is pending for this thread, route a free-text
468
+ // reply to it (answer-by-typing) instead of starting a NEW turn — only the
469
+ // requester may answer (the in-flight turn then resumes and streams).
470
+ if (message.channel.isThread()) {
471
+ const pending = this.pendingQuestions.get(message.channel.id);
472
+ if (pending) {
473
+ if (message.author.id !== pending.requestingUserId) return;
474
+ // Route through the question's idempotent resolver (shared with the
475
+ // buttons) — it replies, updates the prompt message, and clears pending.
476
+ await pending.resolve(text).catch((err) =>
477
+ log.warn("question_text_reply_failed", { error: String(err), threadId: (message.channel as ThreadChannel).id }),
478
+ );
479
+ return;
480
+ }
481
+ }
482
+
483
+ try {
484
+ let thread: ThreadChannel;
485
+ if (message.channel.isThread()) {
486
+ thread = message.channel as ThreadChannel;
487
+ } else {
488
+ const threadName = text.split("\n")[0].slice(0, 100).trim() || "Conversation";
489
+ try {
490
+ thread = await message.startThread({
491
+ name: threadName,
492
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneHour,
493
+ });
494
+ } catch (threadErr) {
495
+ // Thread may already exist if Discord re-delivered the event
496
+ if (message.thread) {
497
+ thread = message.thread as ThreadChannel;
498
+ } else {
499
+ throw threadErr;
500
+ }
501
+ }
502
+ }
503
+
504
+ this.touchThread(thread.id);
505
+
506
+ const sessionKey = `discord:thread:${thread.id}`;
507
+ await this.conversationQueue.runOrQueue(sessionKey, {
508
+ onQueued: async () => {
509
+ if (message.channel.isThread()) {
510
+ await thread.send("Queued. I will pick this up next.");
511
+ }
512
+ },
513
+ run: async () => {
514
+ await this.runThreadConversation(thread, userInfo, text, {
515
+ guildId: userInfo.guildId,
516
+ username: userInfo.username,
517
+ channelId: message.channelId,
518
+ sessionKey,
519
+ }, message);
520
+ },
521
+ });
522
+ } catch (error) {
523
+ const errMsg = error instanceof Error ? error.message : String(error);
524
+ log.error("thread_error", { error: errMsg });
525
+ try {
526
+ await message.reply(`Error: ${errMsg}`);
527
+ } catch {
528
+ // ignore reply errors
529
+ }
530
+ }
531
+ }
532
+
533
+ // ── Slash Command Handling ──────────────────────────────────────────────
534
+
535
+ private extractInteractionUserInfo(interaction: ChatInputCommandInteraction): UserInfo {
536
+ const roles: string[] = [];
537
+ if (interaction.member) {
538
+ if (interaction.member instanceof Object && "cache" in (interaction.member as GuildMember).roles) {
539
+ roles.push(...(interaction.member as GuildMember).roles.cache.map((r) => r.id));
540
+ } else if (Array.isArray((interaction.member as Record<string, unknown>).roles)) {
541
+ roles.push(...((interaction.member as Record<string, unknown>).roles as string[]));
542
+ }
543
+ }
544
+ return {
545
+ userId: interaction.user.id,
546
+ guildId: interaction.guildId ?? "",
547
+ roles,
548
+ username: interaction.user.username,
549
+ };
550
+ }
551
+
552
+ private async onSlashCommand(interaction: ChatInputCommandInteraction): Promise<void> {
553
+ const commandName = interaction.commandName;
554
+ const userInfo = this.extractInteractionUserInfo(interaction);
555
+
556
+ log.info("command_received", {
557
+ command: commandName,
558
+ userId: userInfo.userId,
559
+ guildId: userInfo.guildId,
560
+ });
561
+
562
+ try {
563
+ const permResult = checkPermissions(this.permissions, userInfo);
564
+ if (!permResult.allowed) {
565
+ await interaction.reply({
566
+ content: "You do not have permission to use this bot.",
567
+ flags: MessageFlags.Ephemeral,
568
+ });
569
+ return;
570
+ }
571
+
572
+ switch (commandName) {
573
+ case "help":
574
+ await this.handleHelpCommand(interaction);
575
+ return;
576
+ case "clear":
577
+ await this.handleClearCommand(interaction, userInfo);
578
+ return;
579
+ case "queue":
580
+ await this.handleAskCommand(interaction, commandName, userInfo, true);
581
+ return;
582
+ case "health":
583
+ await this.handleHealthCommand(interaction, userInfo.userId);
584
+ return;
585
+ default:
586
+ await this.handleAskCommand(interaction, commandName, userInfo);
587
+ return;
588
+ }
589
+ } catch (error) {
590
+ // A throw here (e.g. an expired/already-acknowledged interaction:
591
+ // DiscordAPIError 10062/40060) would otherwise become an unhandled
592
+ // rejection — the InteractionCreate listener fires this fire-and-forget —
593
+ // and crash the Bun process. Log and best-effort notify the user instead.
594
+ const errMsg = error instanceof Error ? error.message : String(error);
595
+ log.error("slash_command_error", {
596
+ command: commandName,
597
+ userId: userInfo.userId,
598
+ guildId: userInfo.guildId,
599
+ error: errMsg,
600
+ });
601
+ if (!interaction.replied && !interaction.deferred) {
602
+ await interaction
603
+ .reply({ content: "An error occurred.", flags: MessageFlags.Ephemeral })
604
+ .catch(() => {});
605
+ }
606
+ }
607
+ }
608
+
609
+ private async handleHelpCommand(interaction: ChatInputCommandInteraction): Promise<void> {
610
+ const lines = ["**Available Commands:**\n"];
611
+ for (const cmd of this.commandRegistry.all) {
612
+ const opts = cmd.options
613
+ ?.map((o) => (o.required ? `<${o.name}>` : `[${o.name}]`))
614
+ .join(" ") ?? "";
615
+ lines.push(`\`/${cmd.name}${opts ? ` ${opts}` : ""}\` — ${cmd.description}`);
616
+ }
617
+ lines.push("\nYou can also mention me in any channel to start a conversation.");
618
+ await interaction.reply({ content: lines.join("\n"), flags: MessageFlags.Ephemeral });
619
+ }
620
+
621
+ private async handleHealthCommand(
622
+ interaction: ChatInputCommandInteraction,
623
+ userId: string,
624
+ ): Promise<void> {
625
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
626
+ try {
627
+ const resp = await this.forward({
628
+ userId: `discord:${userId}`,
629
+ text: "health check",
630
+ metadata: { command: "health" },
631
+ });
632
+ if (resp.ok) {
633
+ await interaction.editReply("All systems operational.");
634
+ } else {
635
+ await interaction.editReply(
636
+ `Assistant returned status ${resp.status}. It may be temporarily unavailable.`,
637
+ );
638
+ }
639
+ } catch {
640
+ await interaction.editReply("Unable to reach the assistant. Please try again later.");
641
+ }
642
+ }
643
+
644
+ private async handleAskCommand(
645
+ interaction: ChatInputCommandInteraction,
646
+ commandName: string,
647
+ userInfo: UserInfo,
648
+ forceQueue = false,
649
+ ): Promise<void> {
650
+ const commandDef = this.commandRegistry.all.find((c) => c.name === commandName);
651
+ const optionValues: Record<string, string> = {};
652
+ for (const opt of interaction.options.data) {
653
+ if (opt.value !== undefined) {
654
+ optionValues[opt.name] = String(opt.value);
655
+ }
656
+ }
657
+
658
+ let text: string;
659
+ if (commandDef?.promptTemplate) {
660
+ text = resolvePromptTemplate(commandDef.promptTemplate, optionValues);
661
+ } else {
662
+ text = optionValues.message ?? optionValues[Object.keys(optionValues)[0] ?? ""] ?? "";
663
+ }
664
+
665
+ if (!text.trim()) {
666
+ await interaction.reply({ content: "Please provide a message.", flags: MessageFlags.Ephemeral });
667
+ return;
668
+ }
669
+
670
+ const interactionThreadId = interaction.channel?.isThread() ? interaction.channel.id : null;
671
+ const sessionKey = interactionThreadId?.trim()
672
+ ? `discord:thread:${interactionThreadId}`
673
+ : `discord:channel:${interaction.channelId}:user:${userInfo.userId}`;
674
+ const isBusy = this.conversationQueue.isProcessing(sessionKey);
675
+ const shouldQueue = forceQueue && isBusy;
676
+
677
+ if (shouldQueue) {
678
+ await interaction.reply({ content: "Queued. I will run that next.", flags: MessageFlags.Ephemeral });
679
+ } else {
680
+ await interaction.deferReply();
681
+ }
682
+
683
+ await this.conversationQueue.runOrQueue(sessionKey, {
684
+ run: async () => {
685
+ try {
686
+ const resp = await this.forward({
687
+ userId: `discord:${userInfo.userId}`,
688
+ text,
689
+ metadata: {
690
+ guildId: userInfo.guildId,
691
+ username: userInfo.username,
692
+ command: commandName,
693
+ channelId: interaction.channelId,
694
+ sessionKey,
695
+ },
696
+ }, undefined, this.forwardTimeoutMs || undefined);
697
+ if (!resp.ok) throw new Error(`Guardian returned status ${resp.status}`);
698
+ const { answer = "No response received." } = await resp.json() as { answer?: string };
699
+
700
+ const chunks = splitMessage(answer, MAX_MESSAGE_LENGTH);
701
+ const firstChunk = chunks[0] ?? "No response received.";
702
+
703
+ if (shouldQueue) {
704
+ await interaction.followUp({ content: firstChunk, flags: MessageFlags.Ephemeral });
705
+ for (let i = 1; i < chunks.length; i++) {
706
+ await interaction.followUp({ content: chunks[i], flags: MessageFlags.Ephemeral });
707
+ }
708
+ } else {
709
+ await interaction.editReply(firstChunk);
710
+ for (let i = 1; i < chunks.length; i++) {
711
+ await interaction.followUp(chunks[i]);
712
+ }
713
+ }
714
+
715
+ log.info("command_completed", {
716
+ command: commandName,
717
+ userId: userInfo.userId,
718
+ guildId: userInfo.guildId,
719
+ sessionKey,
720
+ });
721
+ } catch (error) {
722
+ const errMsg = error instanceof Error ? error.message : String(error);
723
+ log.error("command_error", { command: commandName, error: errMsg, sessionKey });
724
+ if (shouldQueue) {
725
+ await interaction.followUp({ content: `Error: ${errMsg}`, flags: MessageFlags.Ephemeral });
726
+ } else {
727
+ await interaction.editReply(`Error: ${errMsg}`);
728
+ }
729
+ }
730
+ },
731
+ });
732
+ }
733
+
734
+ private async handleClearCommand(
735
+ interaction: ChatInputCommandInteraction,
736
+ userInfo: UserInfo,
737
+ ): Promise<void> {
738
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
739
+
740
+ const clearThreadId = interaction.channel?.isThread() ? interaction.channel.id : null;
741
+ const sessionKey = clearThreadId?.trim()
742
+ ? `discord:thread:${clearThreadId}`
743
+ : `discord:channel:${interaction.channelId}:user:${userInfo.userId}`;
744
+
745
+ try {
746
+ const resp = await this.forward({
747
+ userId: `discord:${userInfo.userId}`,
748
+ text: "clear session",
749
+ metadata: {
750
+ command: "clear",
751
+ channelId: interaction.channelId,
752
+ guildId: userInfo.guildId,
753
+ username: userInfo.username,
754
+ sessionKey,
755
+ clearSession: true,
756
+ },
757
+ });
758
+
759
+ if (!resp.ok) {
760
+ await interaction.editReply("Could not clear this conversation right now.");
761
+ return;
762
+ }
763
+
764
+ const droppedQueued = this.conversationQueue.clear(sessionKey);
765
+ // A cleared session becomes a fresh OpenCode session → re-prime next turn.
766
+ this.primedSessions.delete(sessionKey);
767
+
768
+ // Stop tracking this thread so the bot won't auto-respond anymore
769
+ if (interaction.channel?.isThread()) {
770
+ this.forgetThread(interaction.channel.id);
771
+ }
772
+
773
+ await interaction.editReply(
774
+ droppedQueued > 0 ? "Conversation cleared. Dropped queued follow-ups." : "Conversation cleared.",
775
+ );
776
+ } catch (error) {
777
+ const errMsg = error instanceof Error ? error.message : String(error);
778
+ log.error("clear_error", {
779
+ error: errMsg,
780
+ sessionKey,
781
+ userId: userInfo.userId,
782
+ guildId: userInfo.guildId,
783
+ channelId: interaction.channelId,
784
+ });
785
+ await interaction.editReply("Could not clear this conversation right now.");
786
+ }
787
+ }
788
+
789
+ // ── Discord Message Utilities ───────────────────────────────────────────
790
+
791
+ private async sendSplitMessage(channel: ThreadChannel, text: string): Promise<void> {
792
+ const chunks = splitMessage(text, MAX_MESSAGE_LENGTH);
793
+ for (const chunk of chunks) {
794
+ await channel.send(chunk);
795
+ if (chunks.length > 1) {
796
+ await new Promise((resolve) => setTimeout(resolve, 300));
797
+ }
798
+ }
799
+ }
800
+ }
801
+
802
+ async function collectTurnAnswer(client: OcClient, userId: string, sessionId: string, signal: AbortSignal): Promise<string> {
803
+ const reasoningPartIds = new Set<string>();
804
+ let answer = '';
805
+ for await (const event of client.events(userId, signal)) {
806
+ const raw = asRaw(event);
807
+ const snapshot = partSnapshotType(raw);
808
+ if (snapshot?.type === 'reasoning') reasoningPartIds.add(snapshot.partID);
809
+ const delta = extractTextDelta(raw, sessionId, reasoningPartIds);
810
+ if (delta) answer += delta;
811
+ if (isTurnEnd(raw, sessionId)) break;
812
+ }
813
+ return answer || '(no response)';
814
+ }