@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.
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Discord rich-UX streaming renderer (design §4.1, §4.2) — Stage 4.
3
+ *
4
+ * Consumes the guardian's filtered native /event stream (OcClient.events) and
5
+ * renders a single Discord turn live:
6
+ * - persistent, pre-opened filtered /event subscription (opened BEFORE the
7
+ * first prompt_async so no frame can arrive before a subscriber exists —
8
+ * §4.2);
9
+ * - the channel generates a msg_… messageID for prompt_async (the assistant
10
+ * reply carries its OWN server id, so frames are correlated by sessionID,
11
+ * not messageID — live-verified §4.2);
12
+ * - renders until turn-end = `session.status` reaching an idle state, with
13
+ * `session.idle` as a fallback (§1.1);
14
+ * - prefers the fine-grained `session.next.*` delta family where present,
15
+ * falling back to `message.part.delta`/`message.part.updated` (§1.1);
16
+ * - throttled placeholder edits (~1 edit / EDIT_THROTTLE_MS), rolling to a new
17
+ * message past Discord's 2000-char limit via splitMessage (§4.1);
18
+ * - tool-call embeds colored by `state.status`;
19
+ * - on `permission.asked`, an ActionRow (Approve / Always / Deny) restricted to
20
+ * the requesting user.id → POST /permission/{requestID}/reply (signed,
21
+ * ownership-checked by the guardian); "Always" maps to reply:"always";
22
+ * - a Stop button → POST /session/{id}/abort.
23
+ *
24
+ * The OpenCode wire types come from `@opencode-ai/sdk`; we narrow defensively by
25
+ * `event.type` and tolerate unknown event shapes (graceful degrade, §5). This
26
+ * file holds NO guardian state — it is pure Discord rendering over OcClient.
27
+ */
28
+
29
+ import {
30
+ ActionRowBuilder,
31
+ ButtonBuilder,
32
+ ButtonStyle,
33
+ ComponentType,
34
+ MessageFlags,
35
+ type ButtonInteraction,
36
+ type Message,
37
+ type ThreadChannel,
38
+ } from "discord.js";
39
+ import {
40
+ OcClient,
41
+ asRaw,
42
+ createLogger,
43
+ extractPermissionAsk,
44
+ extractQuestionAsk,
45
+ extractTextDelta,
46
+ extractToolUpdate,
47
+ isSessionError,
48
+ isTurnEnd,
49
+ partSnapshotType,
50
+ splitMessage,
51
+ type PermissionAsk,
52
+ type QuestionAsk,
53
+ } from './runtime.ts';
54
+
55
+ /** Adapter-owned hook so a pending question can also be answered by the user
56
+ * typing a normal message in the thread. `resolve(answer)` is the SINGLE
57
+ * idempotent answer path shared by the buttons and the free-text reply — calling
58
+ * it twice (e.g. a stale button click after a typed answer) is a safe no-op, so
59
+ * the second answer never 404s. Set when asked, null when resolved/turn-ends. */
60
+ export type PendingQuestion = {
61
+ requestID: string;
62
+ requestingUserId: string;
63
+ resolve: (answer: string) => Promise<void>;
64
+ };
65
+
66
+ const log = createLogger("channel-discord:stream");
67
+
68
+ /**
69
+ * Channel-level guidance prepended ONCE to the first prompt of a Discord session.
70
+ * Discord renders the `question` tool as interactive buttons (and free-text thread
71
+ * replies), so we nudge the model to USE that tool when it wants the user to pick
72
+ * between options instead of listing the choices as plain text. This lives in the
73
+ * Discord package, so non-interactive channels (e.g. the API channel) never inject
74
+ * it. Set DISCORD_SESSION_PREAMBLE="" to disable, or to your own text to override.
75
+ */
76
+ export const DISCORD_SESSION_PREAMBLE =
77
+ Bun.env.DISCORD_SESSION_PREAMBLE ??
78
+ "[channel:discord] CRITICAL OUTPUT RULE for this Discord conversation: whenever you ask the user anything that has a discrete set of answers — a yes/no, a confirmation, or a choice between options — you MUST call the `question` tool to ask it. NEVER write the question and its options as plain text in your reply. Listing choices as text (e.g. \"1. … 2. … or let me know\") is WRONG here; the user cannot click text. The `question` tool renders real clickable buttons in Discord (and the user may also type a free-text answer). If you find yourself about to write options as a numbered or bulleted list for the user to pick from, STOP and call the `question` tool instead. Only use plain text for open-ended questions that have no enumerable options.";
79
+
80
+ // ── Named tunables (design §4.1, §3.6 edit-throttle) ───────────────────────
81
+
82
+ /** Discord hard message length. */
83
+ const MAX_MESSAGE_LENGTH = 2000;
84
+ /** Edit throttle — ~1 edit / 1.25s to stay under Discord edit rate limits (§4.1, ~750–1500ms). */
85
+ const EDIT_THROTTLE_MS = Number(Bun.env.DISCORD_EDIT_THROTTLE_MS) || 1250;
86
+ /** Hard ceiling on a single rendered turn so a stuck stream can't render forever. */
87
+ const TURN_RENDER_TIMEOUT_MS = Number(Bun.env.DISCORD_TURN_RENDER_TIMEOUT_MS) || 10 * 60_000;
88
+ /** Discord interaction collector lifetime for permission/stop buttons. */
89
+ const BUTTON_COLLECTOR_MS = Number(Bun.env.DISCORD_BUTTON_COLLECTOR_MS) || 5 * 60_000;
90
+
91
+ // ── Discord-specific render helpers ─────────────────────────────────────────
92
+ // Pure event interpreters (extractTextDelta/isTurnEnd/extractToolUpdate/
93
+ // extractPermissionAsk/asRaw) are shared via the portal runtime — every
94
+ // rich-UX renderer reads the same native OpenCode frames identically (§4.2).
95
+
96
+ // ── Public entry: render one streamed turn into a Discord thread ───────────
97
+
98
+ export interface StreamTurnArgs {
99
+ client: OcClient;
100
+ /** The principal userId (e.g. "discord:123") — signs every /oc call. */
101
+ userId: string;
102
+ /** The Discord user.id allowed to click permission/stop buttons (§4.1). */
103
+ requestingUserId: string;
104
+ /** The thread to render into. */
105
+ thread: ThreadChannel;
106
+ /** The session key for guardian grouping (e.g. "discord:thread:42"). */
107
+ sessionKey: string;
108
+ /** The user's prompt text. */
109
+ text: string;
110
+ /**
111
+ * Optional one-time preamble prepended to THIS turn's prompt (e.g. the
112
+ * Discord `question`-tool nudge on a session's first turn). Empty/undefined =
113
+ * no preamble. The caller is responsible for sending it only when wanted (once
114
+ * per session) so it doesn't repeat every turn.
115
+ */
116
+ sessionPreamble?: string;
117
+ /**
118
+ * Open the /event subscription for this turn. Defaults to a fresh
119
+ * `client.events(userId)` stream, but the adapter passes a SHARED per-principal
120
+ * subscription (OcEventHub) so concurrent threads don't each open a redundant
121
+ * stream and trip the guardian's per-principal concurrent-stream cap. Returns
122
+ * an async iterable with a `close()` the render loop calls on turn-end.
123
+ */
124
+ subscribeEvents?: () => AsyncIterable<unknown> & { close?: () => void };
125
+ /** The user's triggering Discord message — tool use is shown as emoji reactions on it. */
126
+ triggerMessage: Message;
127
+ /**
128
+ * Called when an interactive `question` is pending (so the adapter can also
129
+ * accept a free-text answer typed in the thread) and with null when it
130
+ * resolves / the turn ends.
131
+ */
132
+ setPendingQuestion?: (pending: PendingQuestion | null) => void;
133
+ }
134
+
135
+ /**
136
+ * Run ONE streamed turn end-to-end. Creates the session (the GUARDIAN dedupes
137
+ * per (channel, sessionKey), so multi-turn context is preserved and one thread
138
+ * maps to one OpenCode session), subscribes to the filtered /event stream FIRST,
139
+ * then prompt_asyncs with a generated messageID, and renders
140
+ * deltas/tools/permissions/questions until turn-end. Resolves when the turn
141
+ * reaches idle (so the conversation queue's run() promise settles per sessionKey)
142
+ * or on timeout/abort.
143
+ */
144
+ export async function streamTurn(args: StreamTurnArgs): Promise<void> {
145
+ const { client, userId, requestingUserId, thread, sessionKey, text, sessionPreamble, subscribeEvents, triggerMessage, setPendingQuestion } = args;
146
+ // Prepend the one-time channel preamble (question-tool nudge) to the user's
147
+ // text. The caller passes it only on a session's first turn, so it never
148
+ // repeats. The user's actual message stays last so it reads naturally.
149
+ const promptText = sessionPreamble?.trim() ? `${sessionPreamble.trim()}\n\n${text}` : text;
150
+
151
+ // Native Discord "typing…" indicator while the turn works — signals activity
152
+ // during the pre-text phase (reasoning + tool steps) WITHOUT posting any
153
+ // placeholder/Stop clutter. Cleared in finally (and auto-expires on Discord).
154
+ const stopTyping = startTyping(thread);
155
+ const ac = new AbortController();
156
+ let active: ActiveMessage | null = null; // the currently-streaming assistant message
157
+ // Shared per-principal /event subscription (OcEventHub) when provided, else a
158
+ // dedicated stream. close()d in finally so the hub can refcount/idle-close.
159
+ const subscription = subscribeEvents ? subscribeEvents() : null;
160
+
161
+ try {
162
+ // Guardian dedupes create per (channel, sessionKey) → one thread, one session.
163
+ const sessionId = (await client.createSession(userId, sessionKey)).id;
164
+ const eventsIter = subscription ?? client.events(userId, ac.signal); // subscribe BEFORE prompting (§4.2)
165
+ // Fire the turn but DON'T await — /message resolves only at turn-end, and the
166
+ // render loop drives off /event. If it errors, abort so the loop ends.
167
+ void client.prompt(userId, sessionId, promptText).catch((err) => {
168
+ log.warn("prompt_failed", { error: String(err), sessionId });
169
+ ac.abort();
170
+ });
171
+
172
+ const reasoningParts = new Set<string>(); // partIDs typed "reasoning" → never shown
173
+ const reactedEmojis = new Set<string>(); // tool emojis already reacted on the trigger message
174
+ const deadline = Date.now() + TURN_RENDER_TIMEOUT_MS;
175
+ for await (const ev of eventsIter) {
176
+ if (Date.now() > deadline) {
177
+ log.warn("turn_render_timeout", { sessionId });
178
+ break;
179
+ }
180
+ const e = asRaw(ev);
181
+
182
+ // Learn part types from snapshots so reasoning is filtered (a delta alone
183
+ // can't be told apart — both stream field:"text").
184
+ const snap = partSnapshotType(e);
185
+ if (snap && snap.type === "reasoning") reasoningParts.add(snap.partID);
186
+
187
+ if (isTurnEnd(e, sessionId)) break;
188
+ if (isSessionError(e, sessionId)) {
189
+ await thread.send("⚠️ The assistant connection reset. Please try again.").catch(() => {});
190
+ break;
191
+ }
192
+
193
+ // Per-frame rendering is RESILIENT: one malformed frame must not abort the turn.
194
+ try {
195
+ const delta = extractTextDelta(e, sessionId, reasoningParts);
196
+ if (delta) {
197
+ // Each assistant message in the agent's sequence becomes its OWN Discord
198
+ // message (sent when its first useful text arrives) — NOT one edited
199
+ // placeholder, so the conversation reads naturally.
200
+ const mid = typeof e.properties?.messageID === "string" ? e.properties.messageID : "";
201
+ if (!active || (mid && active.messageId !== mid)) {
202
+ await active?.finalize();
203
+ active = new ActiveMessage(thread, mid);
204
+ }
205
+ await active.append(delta);
206
+ continue;
207
+ }
208
+
209
+ const tool = extractToolUpdate(e, sessionId);
210
+ if (tool && tool.callID) {
211
+ // Tool use shows as a lightweight EMOJI REACTION on the user's message
212
+ // (one per distinct tool kind) — no noisy embeds. The `question` tool
213
+ // renders its own interactive prompt below.
214
+ if (tool.tool !== "question") {
215
+ const emoji = toolEmoji(tool.tool);
216
+ if (!reactedEmojis.has(emoji)) {
217
+ reactedEmojis.add(emoji);
218
+ await triggerMessage.react(emoji).catch(() => {});
219
+ }
220
+ }
221
+ continue;
222
+ }
223
+
224
+ const ask = extractPermissionAsk(e, sessionId);
225
+ if (ask) {
226
+ await renderPermissionPrompt(thread, client, userId, requestingUserId, ask);
227
+ continue;
228
+ }
229
+
230
+ const question = extractQuestionAsk(e, sessionId);
231
+ if (question) {
232
+ await renderQuestionPrompt(thread, client, userId, requestingUserId, question, setPendingQuestion);
233
+ continue;
234
+ }
235
+ } catch (err) {
236
+ log.warn("frame_render_failed", { error: String(err), type: e.type, sessionId });
237
+ }
238
+ }
239
+ } finally {
240
+ stopTyping();
241
+ setPendingQuestion?.(null); // clear any unanswered pending question for this thread
242
+ subscription?.close?.(); // decref the shared /event stream (hub idle-closes)
243
+ ac.abort();
244
+ await active?.finalize().catch(() => {});
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Show Discord's native "typing…" indicator for the thread until the returned
250
+ * stop() is called. Re-fired every 8s (the indicator lasts ~10s). Best-effort.
251
+ */
252
+ function startTyping(thread: ThreadChannel): () => void {
253
+ const tick = () => void thread.sendTyping().catch(() => {});
254
+ tick();
255
+ const interval = setInterval(tick, 8000);
256
+ return () => clearInterval(interval);
257
+ }
258
+
259
+ /** Tool kind → a compact reaction emoji (one per distinct kind per turn). */
260
+ function toolEmoji(tool: string): string {
261
+ if (tool.startsWith("akm_")) {
262
+ if (tool.includes("search") || tool.includes("curate")) return "🔎";
263
+ if (tool.includes("memory") || tool.includes("remember")) return "🧠";
264
+ return "📚";
265
+ }
266
+ switch (tool) {
267
+ case "bash": return "🐚";
268
+ case "read": case "glob": case "grep": case "list": return "📄";
269
+ case "write": case "edit": case "patch": return "✏️";
270
+ case "webfetch": case "websearch": return "🌐";
271
+ case "task": return "🤖";
272
+ default: return "🔧";
273
+ }
274
+ }
275
+
276
+ // ── One streamed assistant message (sent, then throttle-edited) ─────────────
277
+ //
278
+ // Each assistant message in the agent's sequence is its OWN Discord message: the
279
+ // first useful text SENDS a message, later deltas EDIT it (throttled). A message
280
+ // with no text never sends (no empty "…" placeholders). Over 2000 chars splits
281
+ // into follow-up messages on finalize.
282
+ class ActiveMessage {
283
+ readonly messageId: string;
284
+ private readonly thread: ThreadChannel;
285
+ private msg: Message | null = null;
286
+ private buffer = "";
287
+ private lastEdit = 0;
288
+ private pendingTimer: ReturnType<typeof setTimeout> | null = null;
289
+
290
+ constructor(thread: ThreadChannel, messageId: string) {
291
+ this.thread = thread;
292
+ this.messageId = messageId;
293
+ }
294
+
295
+ async append(delta: string): Promise<void> {
296
+ this.buffer += delta;
297
+ const now = Date.now();
298
+ if (now - this.lastEdit >= EDIT_THROTTLE_MS) {
299
+ this.lastEdit = now;
300
+ await this.flush();
301
+ } else if (!this.pendingTimer) {
302
+ this.pendingTimer = setTimeout(() => {
303
+ this.pendingTimer = null;
304
+ this.lastEdit = Date.now();
305
+ void this.flush();
306
+ }, EDIT_THROTTLE_MS);
307
+ }
308
+ }
309
+
310
+ private async flush(): Promise<void> {
311
+ const head = splitMessage(this.buffer, MAX_MESSAGE_LENGTH)[0] ?? "";
312
+ if (!head) return; // nothing renderable yet
313
+ try {
314
+ if (!this.msg) this.msg = await this.thread.send(head);
315
+ else await this.msg.edit(head);
316
+ } catch (err) {
317
+ log.warn("edit_failed", { error: String(err) });
318
+ }
319
+ }
320
+
321
+ async finalize(): Promise<void> {
322
+ if (this.pendingTimer) {
323
+ clearTimeout(this.pendingTimer);
324
+ this.pendingTimer = null;
325
+ }
326
+ if (!this.buffer.trim()) return; // produced no text → no message
327
+ const chunks = splitMessage(this.buffer, MAX_MESSAGE_LENGTH);
328
+ try {
329
+ if (!this.msg) this.msg = await this.thread.send(chunks[0] ?? "");
330
+ else await this.msg.edit(chunks[0] ?? "");
331
+ } catch {
332
+ // ignore
333
+ }
334
+ for (let i = 1; i < chunks.length; i++) await this.thread.send(chunks[i]).catch(() => {});
335
+ }
336
+ }
337
+
338
+ // ── Permission ActionRow (Approve / Always / Deny) ─────────────────────────
339
+
340
+ async function renderPermissionPrompt(
341
+ thread: ThreadChannel,
342
+ client: OcClient,
343
+ userId: string,
344
+ requestingUserId: string,
345
+ ask: PermissionAsk,
346
+ ): Promise<void> {
347
+ const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
348
+ new ButtonBuilder().setCustomId(`oc_perm_once:${ask.requestID}`).setLabel("Approve").setStyle(ButtonStyle.Success),
349
+ new ButtonBuilder().setCustomId(`oc_perm_always:${ask.requestID}`).setLabel("Always").setStyle(ButtonStyle.Primary),
350
+ new ButtonBuilder().setCustomId(`oc_perm_deny:${ask.requestID}`).setLabel("Deny").setStyle(ButtonStyle.Danger),
351
+ );
352
+ const patterns = ask.patterns.length ? `\n\`${ask.patterns.join("`, `")}\`` : "";
353
+ const prompt = await thread.send({
354
+ content: `Permission requested: **${ask.permission}**${patterns}`,
355
+ components: [row],
356
+ });
357
+
358
+ const collector = prompt.createMessageComponentCollector({
359
+ componentType: ComponentType.Button,
360
+ time: BUTTON_COLLECTOR_MS,
361
+ max: 1,
362
+ });
363
+
364
+ collector.on("collect", async (i: ButtonInteraction) => {
365
+ // Interaction identity: only the requesting Discord user may decide (§4.1).
366
+ if (i.user.id !== requestingUserId) {
367
+ await i.reply({ content: "Only the requester can answer this permission prompt.", flags: MessageFlags.Ephemeral });
368
+ return;
369
+ }
370
+ const [action] = i.customId.split(":");
371
+ const reply = action === "oc_perm_once" ? "once" : action === "oc_perm_always" ? "always" : "reject";
372
+ try {
373
+ await client.replyPermission(userId, ask.requestID, reply);
374
+ await i.update({
375
+ content: `Permission **${ask.permission}** → ${reply}.`,
376
+ components: [],
377
+ });
378
+ } catch (err) {
379
+ log.warn("permission_reply_failed", { error: String(err), requestID: ask.requestID });
380
+ await i.update({ content: "Could not record that decision (it may have expired).", components: [] });
381
+ }
382
+ });
383
+
384
+ collector.on("end", (collected) => {
385
+ if (collected.size === 0) {
386
+ void prompt.edit({ content: `Permission **${ask.permission}** timed out.`, components: [] }).catch(() => {});
387
+ }
388
+ });
389
+ }
390
+
391
+ // ── Interactive question (the `question` tool) ─────────────────────────────
392
+ //
393
+ // Renders the FIRST question's options as buttons (chunked into rows of 5), and
394
+ // registers a pending question so the user can ALSO answer by typing a normal
395
+ // message in the thread (the adapter routes that to replyQuestion). Answering by
396
+ // either path replies once and clears the pending state.
397
+ async function renderQuestionPrompt(
398
+ thread: ThreadChannel,
399
+ client: OcClient,
400
+ userId: string,
401
+ requestingUserId: string,
402
+ ask: QuestionAsk,
403
+ setPendingQuestion?: (pending: PendingQuestion | null) => void,
404
+ ): Promise<void> {
405
+ const q = ask.questions[0]; // v1: answer the first question (the common case)
406
+ if (!q) return;
407
+
408
+ const rows: ActionRowBuilder<ButtonBuilder>[] = [];
409
+ const options = q.options.slice(0, 25); // Discord cap: 25 buttons (5 rows × 5)
410
+ for (let i = 0; i < options.length; i += 5) {
411
+ const row = new ActionRowBuilder<ButtonBuilder>();
412
+ for (const [j, opt] of options.slice(i, i + 5).entries()) {
413
+ const idx = i + j;
414
+ row.addComponents(
415
+ new ButtonBuilder()
416
+ .setCustomId(`oc_q:${ask.requestID}:${idx}`)
417
+ .setLabel((opt.label || `Option ${idx + 1}`).slice(0, 80))
418
+ .setStyle(ButtonStyle.Primary),
419
+ );
420
+ }
421
+ rows.push(row);
422
+ }
423
+
424
+ const header = q.header ? `**${q.header}**\n` : "";
425
+ const optionLines = options.map((o) => `• **${o.label}**${o.description ? ` — ${o.description}` : ""}`).join("\n");
426
+ const content = `${header}${q.question}${optionLines ? `\n${optionLines}` : ""}\n_Click an option below, or reply in this thread with your own answer._`;
427
+
428
+ const prompt = await thread.send({ content: content.slice(0, 2000), components: rows });
429
+ let collector: ReturnType<Message["createMessageComponentCollector"]> | undefined;
430
+
431
+ // The SINGLE idempotent answer path. Both the buttons and the free-text reply
432
+ // call this; the first answer wins, any later one is a safe no-op (no 404).
433
+ let resolved = false;
434
+ const resolveOnce = async (answer: string, interaction?: ButtonInteraction): Promise<void> => {
435
+ if (resolved) {
436
+ if (interaction) await interaction.reply({ content: "That question was already answered.", flags: MessageFlags.Ephemeral }).catch(() => {});
437
+ return;
438
+ }
439
+ resolved = true;
440
+ setPendingQuestion?.(null);
441
+ try { collector?.stop(); } catch { /* not started */ }
442
+ let outcome: string;
443
+ try {
444
+ await client.replyQuestion(userId, ask.requestID, [[answer]]);
445
+ outcome = `${header}${q.question}\n✅ ${answer}`.slice(0, 2000);
446
+ } catch (err) {
447
+ log.warn("question_reply_failed", { error: String(err), requestID: ask.requestID });
448
+ outcome = `${header}${q.question}\n_(could not record that answer)_`.slice(0, 2000);
449
+ }
450
+ if (interaction) await interaction.update({ content: outcome, components: [] }).catch(() => {});
451
+ else await prompt.edit({ content: outcome, components: [] }).catch(() => {});
452
+ };
453
+
454
+ // Free-text reply path (adapter routes a typed thread message here).
455
+ setPendingQuestion?.({ requestID: ask.requestID, requestingUserId, resolve: (a) => resolveOnce(a) });
456
+
457
+ if (rows.length === 0) return; // no options → free-text only (resolved via thread reply)
458
+
459
+ collector = prompt.createMessageComponentCollector({ componentType: ComponentType.Button, time: BUTTON_COLLECTOR_MS });
460
+ collector.on("collect", async (i: ButtonInteraction) => {
461
+ if (i.user.id !== requestingUserId) {
462
+ await i.reply({ content: "Only the requester can answer this question.", flags: MessageFlags.Ephemeral }).catch(() => {});
463
+ return;
464
+ }
465
+ const idx = Number(i.customId.split(":")[2]);
466
+ await resolveOnce(options[idx]?.label ?? "", i);
467
+ });
468
+ collector.on("end", (collected) => {
469
+ if (collected.size === 0 && !resolved) void prompt.edit({ components: [] }).catch(() => {}); // drop dead buttons
470
+ });
471
+ }
472
+
473
+ // ── Exported pure helpers for unit tests ───────────────────────────────────
474
+
475
+ export const _internal = {
476
+ extractTextDelta,
477
+ isTurnEnd,
478
+ extractToolUpdate,
479
+ extractPermissionAsk,
480
+ toolEmoji,
481
+ asRaw,
482
+ };
package/src/types.ts ADDED
@@ -0,0 +1,49 @@
1
+ export const CommandOptionType = {
2
+ SUB_COMMAND: 1,
3
+ SUB_COMMAND_GROUP: 2,
4
+ STRING: 3,
5
+ INTEGER: 4,
6
+ BOOLEAN: 5,
7
+ USER: 6,
8
+ CHANNEL: 7,
9
+ ROLE: 8,
10
+ MENTIONABLE: 9,
11
+ NUMBER: 10,
12
+ ATTACHMENT: 11,
13
+ } as const;
14
+
15
+ export type CustomCommandOption = {
16
+ name: string;
17
+ description: string;
18
+ type: number;
19
+ required?: boolean;
20
+ choices?: Array<{ name: string; value: string }>;
21
+ };
22
+
23
+ export type CustomCommandDef = {
24
+ name: string;
25
+ description: string;
26
+ options?: CustomCommandOption[];
27
+ promptTemplate?: string;
28
+ ephemeral?: boolean;
29
+ };
30
+
31
+ export type PermissionConfig = {
32
+ allowedGuilds: Set<string>;
33
+ allowedRoles: Set<string>;
34
+ allowedUsers: Set<string>;
35
+ blockedUsers: Set<string>;
36
+ };
37
+
38
+ export type PermissionResult = {
39
+ allowed: boolean;
40
+ reason?: string;
41
+ };
42
+
43
+ /** Simple user info extracted from discord.js Message or Interaction objects. */
44
+ export type UserInfo = {
45
+ userId: string;
46
+ guildId: string;
47
+ roles: string[];
48
+ username: string;
49
+ };