@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/README.md +64 -0
- package/package.json +24 -0
- package/src/commands.ts +202 -0
- package/src/index.test.ts +1154 -0
- package/src/index.ts +814 -0
- package/src/oc-event-hub.test.ts +100 -0
- package/src/oc-event-hub.ts +161 -0
- package/src/oc-events.ts +157 -0
- package/src/opencode.test.ts +78 -0
- package/src/opencode.ts +130 -0
- package/src/permissions.ts +55 -0
- package/src/runtime.ts +211 -0
- package/src/session.test.ts +74 -0
- package/src/stream-render.test.ts +127 -0
- package/src/stream-render.ts +482 -0
- package/src/types.ts +49 -0
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
|
+
}
|