@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
|
@@ -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
|
+
};
|