@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,100 @@
1
+ /**
2
+ * OcEventHub — one shared /event stream per principal, fanned out to turns.
3
+ *
4
+ * The bug this fixes: concurrent Discord threads from one user each opened a
5
+ * redundant principal-scoped /event stream and tripped the guardian's
6
+ * concurrent-stream cap (429 too_many_event_streams). The hub guarantees exactly
7
+ * ONE upstream stream per principal regardless of concurrent turns, and delivers
8
+ * every frame to every subscriber (each filters by its own sessionId).
9
+ *
10
+ * We stub OcClient.events with a controllable async generator so the test never
11
+ * needs a live guardian.
12
+ */
13
+ import { describe, test, expect } from "bun:test";
14
+ import { OcEventHub } from "./oc-event-hub.ts";
15
+
16
+ /** A fake OcClient whose events() yields from a manually-fed queue per call. */
17
+ function fakeClient() {
18
+ let opens = 0;
19
+ const feeders: Array<(frame: unknown) => void> = [];
20
+ const closers: Array<() => void> = [];
21
+ const client = {
22
+ events(_userId: string, signal: AbortSignal) {
23
+ opens++;
24
+ const queue: unknown[] = [];
25
+ let waiting: ((r: IteratorResult<unknown>) => void) | null = null;
26
+ let done = false;
27
+ const push = (frame: unknown) => {
28
+ if (waiting) { const w = waiting; waiting = null; w({ value: frame, done: false }); }
29
+ else queue.push(frame);
30
+ };
31
+ const finish = () => { done = true; if (waiting) { const w = waiting; waiting = null; w({ value: undefined, done: true }); } };
32
+ feeders.push(push);
33
+ closers.push(finish);
34
+ signal.addEventListener("abort", finish, { once: true });
35
+ return {
36
+ [Symbol.asyncIterator]() {
37
+ return {
38
+ next(): Promise<IteratorResult<unknown>> {
39
+ if (queue.length) return Promise.resolve({ value: queue.shift(), done: false });
40
+ if (done) return Promise.resolve({ value: undefined, done: true });
41
+ return new Promise((res) => { waiting = res; });
42
+ },
43
+ };
44
+ },
45
+ };
46
+ },
47
+ };
48
+ return { client, get opens() { return opens; }, feed: (i: number, f: unknown) => feeders[i]?.(f), closeUpstream: (i: number) => closers[i]?.() };
49
+ }
50
+
51
+ async function take(iter: AsyncIterable<unknown>, n: number): Promise<unknown[]> {
52
+ const out: unknown[] = [];
53
+ for await (const ev of iter) { out.push(ev); if (out.length >= n) break; }
54
+ return out;
55
+ }
56
+
57
+ describe("OcEventHub", () => {
58
+ test("two concurrent subscribers for one principal share ONE upstream stream", async () => {
59
+ const f = fakeClient();
60
+ const hub = new OcEventHub(f.client as never);
61
+
62
+ const subA = hub.subscribe("discord:u1");
63
+ const subB = hub.subscribe("discord:u1");
64
+ expect(f.opens).toBe(1); // a single upstream open
65
+ expect(hub.openStreamCount).toBe(1);
66
+
67
+ // One upstream frame reaches BOTH subscribers (each filters by sessionId itself).
68
+ const gotA = take(subA, 1);
69
+ const gotB = take(subB, 1);
70
+ f.feed(0, { type: "message.part.delta", properties: { sessionID: "s1", delta: "hi" } });
71
+ expect(await gotA).toEqual([{ type: "message.part.delta", properties: { sessionID: "s1", delta: "hi" } }]);
72
+ expect(await gotB).toEqual([{ type: "message.part.delta", properties: { sessionID: "s1", delta: "hi" } }]);
73
+
74
+ subA.close();
75
+ subB.close();
76
+ });
77
+
78
+ test("separate principals get separate upstream streams", () => {
79
+ const f = fakeClient();
80
+ const hub = new OcEventHub(f.client as never);
81
+ hub.subscribe("discord:u1");
82
+ hub.subscribe("discord:u2");
83
+ expect(f.opens).toBe(2);
84
+ expect(hub.openStreamCount).toBe(2);
85
+ });
86
+
87
+ test("an upstream close/error ends all subscribers so their turns finalize", async () => {
88
+ const f = fakeClient();
89
+ const hub = new OcEventHub(f.client as never);
90
+ const sub = hub.subscribe("discord:u1");
91
+
92
+ // Drain to completion: when the upstream finishes, the subscriber iterator ends.
93
+ const drained = (async () => { for await (const _ of sub) { /* ignore */ } return "ended"; })();
94
+ f.closeUpstream(0);
95
+ expect(await drained).toBe("ended");
96
+ // Stream is forgotten → a new subscribe reopens a fresh upstream.
97
+ hub.subscribe("discord:u1");
98
+ expect(f.opens).toBe(2);
99
+ });
100
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Per-process shared OpenCode /event subscription (fixes guardian 429
3
+ * `too_many_event_streams`).
4
+ *
5
+ * The guardian's /event stream is PRINCIPAL-scoped: a single subscription already
6
+ * carries every event for every session the principal owns (ownership-filtered
7
+ * fan-out). The guardian therefore caps concurrent /event streams per principal
8
+ * at 1 (oc-bounds.ts) — a second open is a leaked/duplicated stream.
9
+ *
10
+ * But a Discord user can run several threads at once (the ConversationQueue
11
+ * serializes turns PER sessionKey, so different threads stream concurrently). If
12
+ * each turn opened its OWN /event stream, the 2nd concurrent thread would 429.
13
+ *
14
+ * This hub opens exactly ONE upstream /event stream per principal (userId) and
15
+ * BROADCASTS every frame to all active per-turn subscribers, which each already
16
+ * filter by their own sessionId (extractTextDelta/isTurnEnd/... are sessionID-
17
+ * scoped). Refcounted: the upstream stays open while ≥1 turn is subscribed and
18
+ * idle-closes a short grace after the last unsubscribes (absorbing between-turn
19
+ * gaps without churning opens). If the upstream errors/closes, all subscribers
20
+ * are ended so their turns finalize, and the next subscribe reopens.
21
+ */
22
+ import { OcClient } from './runtime.ts';
23
+
24
+ /** Grace period to keep the upstream open after the last turn unsubscribes, so
25
+ * back-to-back turns in a thread don't churn open/close (and re-pay the 429
26
+ * reconnect budget). Short — just bridges the gap between turns. */
27
+ const IDLE_CLOSE_GRACE_MS = Number(Bun.env.DISCORD_EVENT_HUB_IDLE_MS) || 30_000;
28
+
29
+ /** A single turn's view of the shared stream: a push-driven async iterator. */
30
+ class Subscriber implements AsyncIterable<unknown> {
31
+ private queue: unknown[] = [];
32
+ private waiting: ((r: IteratorResult<unknown>) => void) | null = null;
33
+ private done = false;
34
+
35
+ push(frame: unknown): void {
36
+ if (this.done) return;
37
+ if (this.waiting) {
38
+ const resolve = this.waiting;
39
+ this.waiting = null;
40
+ resolve({ value: frame, done: false });
41
+ } else {
42
+ this.queue.push(frame);
43
+ }
44
+ }
45
+
46
+ end(): void {
47
+ if (this.done) return;
48
+ this.done = true;
49
+ if (this.waiting) {
50
+ const resolve = this.waiting;
51
+ this.waiting = null;
52
+ resolve({ value: undefined, done: true });
53
+ }
54
+ }
55
+
56
+ [Symbol.asyncIterator](): AsyncIterator<unknown> {
57
+ return {
58
+ next: () => {
59
+ if (this.queue.length) return Promise.resolve({ value: this.queue.shift(), done: false });
60
+ if (this.done) return Promise.resolve({ value: undefined, done: true });
61
+ return new Promise<IteratorResult<unknown>>((resolve) => {
62
+ this.waiting = resolve;
63
+ });
64
+ },
65
+ return: () => {
66
+ this.end();
67
+ return Promise.resolve({ value: undefined, done: true });
68
+ },
69
+ };
70
+ }
71
+ }
72
+
73
+ /** The single upstream /event stream for one principal, fanned out to turns. */
74
+ class SharedStream {
75
+ readonly subscribers = new Set<Subscriber>();
76
+ private readonly ac = new AbortController();
77
+ private idleTimer: ReturnType<typeof setTimeout> | null = null;
78
+
79
+ constructor(
80
+ private readonly client: OcClient,
81
+ private readonly userId: string,
82
+ private readonly onClosed: () => void,
83
+ ) {}
84
+
85
+ start(): void {
86
+ void this.pump();
87
+ }
88
+
89
+ private async pump(): Promise<void> {
90
+ try {
91
+ for await (const ev of this.client.events(this.userId, this.ac.signal)) {
92
+ for (const sub of this.subscribers) sub.push(ev);
93
+ }
94
+ } catch {
95
+ // aborted (idle close) or upstream error — fall through to teardown
96
+ } finally {
97
+ for (const sub of this.subscribers) sub.end();
98
+ this.subscribers.clear();
99
+ this.onClosed();
100
+ }
101
+ }
102
+
103
+ add(sub: Subscriber): void {
104
+ if (this.idleTimer) {
105
+ clearTimeout(this.idleTimer);
106
+ this.idleTimer = null;
107
+ }
108
+ this.subscribers.add(sub);
109
+ }
110
+
111
+ remove(sub: Subscriber): void {
112
+ if (!this.subscribers.delete(sub)) return;
113
+ sub.end();
114
+ if (this.subscribers.size === 0 && !this.idleTimer) {
115
+ this.idleTimer = setTimeout(() => this.ac.abort(), IDLE_CLOSE_GRACE_MS);
116
+ this.idleTimer.unref?.();
117
+ }
118
+ }
119
+ }
120
+
121
+ /** A turn's subscription handle: iterate it for frames, close() when done. */
122
+ export interface EventSubscription extends AsyncIterable<unknown> {
123
+ close(): void;
124
+ }
125
+
126
+ /** Opens at most one upstream /event stream per principal and fans it out. */
127
+ export class OcEventHub {
128
+ private readonly streams = new Map<string, SharedStream>();
129
+
130
+ constructor(private readonly client: OcClient) {}
131
+
132
+ /**
133
+ * Subscribe a turn to its principal's shared /event stream. Every frame is
134
+ * delivered; the caller filters by sessionId (as the renderers already do).
135
+ * MUST call close() when the turn ends (the render loop's finally does).
136
+ */
137
+ subscribe(userId: string): EventSubscription {
138
+ let shared = this.streams.get(userId);
139
+ if (!shared) {
140
+ shared = new SharedStream(this.client, userId, () => {
141
+ // Only forget this stream if it's still the current one (a fresh
142
+ // subscribe during teardown may have already replaced it).
143
+ if (this.streams.get(userId) === shared) this.streams.delete(userId);
144
+ });
145
+ this.streams.set(userId, shared);
146
+ shared.start();
147
+ }
148
+ const sub = new Subscriber();
149
+ shared.add(sub);
150
+ const owner = shared;
151
+ return {
152
+ [Symbol.asyncIterator]: () => sub[Symbol.asyncIterator](),
153
+ close: () => owner.remove(sub),
154
+ };
155
+ }
156
+
157
+ /** Number of open upstream streams (for tests / introspection). */
158
+ get openStreamCount(): number {
159
+ return this.streams.size;
160
+ }
161
+ }
@@ -0,0 +1,157 @@
1
+ export interface RawEvent {
2
+ type: string;
3
+ properties?: Record<string, unknown>;
4
+ }
5
+
6
+ export function asRaw(ev: unknown): RawEvent {
7
+ const e = ev as RawEvent;
8
+ return {
9
+ type: typeof e?.type === 'string' ? e.type : '',
10
+ properties: (e?.properties ?? {}) as Record<string, unknown>,
11
+ };
12
+ }
13
+
14
+ function propStr(props: Record<string, unknown> | undefined, key: string): string | undefined {
15
+ const value = props?.[key];
16
+ return typeof value === 'string' ? value : undefined;
17
+ }
18
+
19
+ export function partSnapshotType(e: RawEvent): { partID: string; type: string } | null {
20
+ if (e.type !== 'message.part.updated') return null;
21
+ const part = e.properties?.part as { id?: unknown; type?: unknown } | undefined;
22
+ if (part && typeof part.id === 'string' && typeof part.type === 'string') {
23
+ return { partID: part.id, type: part.type };
24
+ }
25
+ return null;
26
+ }
27
+
28
+ export function extractTextDelta(e: RawEvent, sessionId: string, reasoningPartIds?: ReadonlySet<string>): string | null {
29
+ const props = e.properties ?? {};
30
+ if (propStr(props, 'sessionID') !== sessionId) return null;
31
+
32
+ if (e.type === 'session.next.text.delta') {
33
+ return propStr(props, 'delta') ?? propStr(props, 'text') ?? null;
34
+ }
35
+ if (e.type === 'message.part.delta') {
36
+ if (propStr(props, 'field') && propStr(props, 'field') !== 'text') return null;
37
+ const partID = propStr(props, 'partID');
38
+ if (partID && reasoningPartIds?.has(partID)) return null;
39
+ return propStr(props, 'delta') ?? null;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ export const TURN_IDLE_STATUSES: ReadonlySet<string> = new Set(['idle']);
45
+
46
+ export function statusName(status: unknown): string | undefined {
47
+ if (typeof status === 'string') return status;
48
+ if (status && typeof status === 'object' && typeof (status as { type?: unknown }).type === 'string') {
49
+ return (status as { type: string }).type;
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+ export function isTurnEnd(e: RawEvent, sessionId: string): boolean {
55
+ if (propStr(e.properties, 'sessionID') !== sessionId) return false;
56
+ if (e.type === 'session.idle') return true;
57
+ if (e.type === 'session.status') {
58
+ const name = statusName(e.properties?.status);
59
+ return name !== undefined && TURN_IDLE_STATUSES.has(name);
60
+ }
61
+ return false;
62
+ }
63
+
64
+ export interface ToolUpdate {
65
+ callID: string;
66
+ tool: string;
67
+ status: string;
68
+ title?: string;
69
+ error?: string;
70
+ }
71
+
72
+ export function extractToolUpdate(e: RawEvent, sessionId: string): ToolUpdate | null {
73
+ if (propStr(e.properties, 'sessionID') !== sessionId) return null;
74
+ const part = (e.properties?.part ?? e.properties?.tool) as Record<string, unknown> | undefined;
75
+ if (e.type === 'message.part.updated' && part && (part.type === 'tool' || part.state)) {
76
+ const state = (part.state ?? {}) as Record<string, unknown>;
77
+ return {
78
+ callID: String(part.callID ?? part.id ?? ''),
79
+ tool: String(part.tool ?? 'tool'),
80
+ status: String(state.status ?? 'running'),
81
+ title: typeof state.title === 'string' ? state.title : undefined,
82
+ error: typeof state.error === 'string' ? state.error : undefined,
83
+ };
84
+ }
85
+ if (e.type.startsWith('session.next.tool.')) {
86
+ return {
87
+ callID: propStr(e.properties, 'callID') ?? '',
88
+ tool: propStr(e.properties, 'tool') ?? 'tool',
89
+ status: e.type === 'session.next.tool.called' ? 'running' : (propStr(e.properties, 'status') ?? 'running'),
90
+ title: propStr(e.properties, 'title'),
91
+ };
92
+ }
93
+ return null;
94
+ }
95
+
96
+ export interface PermissionAsk {
97
+ requestID: string;
98
+ permission: string;
99
+ patterns: string[];
100
+ }
101
+
102
+ export function extractPermissionAsk(e: RawEvent, sessionId: string): PermissionAsk | null {
103
+ if (e.type !== 'permission.asked') return null;
104
+ if (propStr(e.properties, 'sessionID') !== sessionId) return null;
105
+ const id = propStr(e.properties, 'id');
106
+ if (!id) return null;
107
+ const patterns = Array.isArray(e.properties?.patterns)
108
+ ? (e.properties.patterns as unknown[]).filter((pattern): pattern is string => typeof pattern === 'string')
109
+ : [];
110
+ return { requestID: id, permission: propStr(e.properties, 'permission') ?? 'tool', patterns };
111
+ }
112
+
113
+ export function isSessionError(e: RawEvent, sessionId: string): boolean {
114
+ return e.type === 'session.error' && propStr(e.properties, 'sessionID') === sessionId;
115
+ }
116
+
117
+ export interface QuestionOption {
118
+ label: string;
119
+ description: string;
120
+ }
121
+
122
+ export interface QuestionInfo {
123
+ question: string;
124
+ header: string;
125
+ options: QuestionOption[];
126
+ }
127
+
128
+ export interface QuestionAsk {
129
+ requestID: string;
130
+ questions: QuestionInfo[];
131
+ }
132
+
133
+ export function extractQuestionAsk(e: RawEvent, sessionId: string): QuestionAsk | null {
134
+ if (e.type !== 'question.asked') return null;
135
+ if (propStr(e.properties, 'sessionID') !== sessionId) return null;
136
+ const id = propStr(e.properties, 'id');
137
+ if (!id) return null;
138
+ const rawQuestions = Array.isArray(e.properties?.questions) ? (e.properties.questions as unknown[]) : [];
139
+ const questions: QuestionInfo[] = [];
140
+ for (const rawQuestion of rawQuestions) {
141
+ const questionData = rawQuestion as { question?: unknown; header?: unknown; options?: unknown };
142
+ const question = typeof questionData.question === 'string' ? questionData.question : '';
143
+ const header = typeof questionData.header === 'string' ? questionData.header : '';
144
+ const options: QuestionOption[] = Array.isArray(questionData.options)
145
+ ? (questionData.options as unknown[])
146
+ .map((option) => option as { label?: unknown; description?: unknown })
147
+ .filter((option) => typeof option.label === 'string')
148
+ .map((option) => ({
149
+ label: option.label as string,
150
+ description: typeof option.description === 'string' ? option.description : '',
151
+ }))
152
+ : [];
153
+ questions.push({ question, header, options });
154
+ }
155
+ if (questions.length === 0) return null;
156
+ return { requestID: id, questions };
157
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Regression test for the 0.12.0 "Discord portal stopped working" bug.
3
+ *
4
+ * The @opencode-ai/sdk client (1.17.x) resolves every session.* call to a
5
+ * { data, error } envelope. The adapter used to read the session object off the
6
+ * envelope directly, so `createSession().id` was `undefined`; the subsequent
7
+ * `prompt({ path: { id: undefined } })` left the path template un-substituted and
8
+ * sent the LITERAL `/session/{id}/message`. The guardian denied it with
9
+ * `no_route` (403), so every Discord message silently failed.
10
+ *
11
+ * This test drives the REAL SDK through a fake transport (the `fetch` the adapter
12
+ * accepts) and asserts the prompt request carries the actual session id — i.e.
13
+ * the path is `/session/<real-id>/message`, never the literal `{id}` / `%7Bid%7D`.
14
+ */
15
+ import { describe, it, expect } from 'bun:test';
16
+ import { OcClient } from './opencode.js';
17
+
18
+ const REAL_SESSION_ID = 'ses_real_abc123';
19
+
20
+ /** A fake transport standing in for guardian → OpenCode. Records every request
21
+ * URL and answers session.create / session.prompt with realistic envelopes. */
22
+ function makeFakeTransport() {
23
+ const urls: string[] = [];
24
+ const fetchFn = (async (input: Request | string | URL): Promise<Response> => {
25
+ const url = input instanceof Request ? input.url : String(input);
26
+ urls.push(url);
27
+ if (/\/session$/.test(new URL(url).pathname) || /\/session$/.test(url)) {
28
+ // POST /session → the created session lives in the JSON body.
29
+ return new Response(JSON.stringify({ id: REAL_SESSION_ID, title: 'chat' }), {
30
+ status: 200,
31
+ headers: { 'content-type': 'application/json' },
32
+ });
33
+ }
34
+ // POST /session/<id>/message → accept and echo an empty assistant message.
35
+ return new Response(JSON.stringify({ info: {}, parts: [] }), {
36
+ status: 200,
37
+ headers: { 'content-type': 'application/json' },
38
+ });
39
+ }) as unknown as typeof fetch;
40
+ return { urls, fetchFn };
41
+ }
42
+
43
+ describe('OcClient (discord) — SDK envelope handling', () => {
44
+ it('createSession returns the real session id from the { data } envelope', async () => {
45
+ const { fetchFn } = makeFakeTransport();
46
+ const client = new OcClient({ principalId: 'discord', secret: 's', baseUrl: 'http://guardian:8080/oc', fetch: fetchFn });
47
+
48
+ const session = await client.createSession('discord:123');
49
+ // Pre-fix this was `undefined` (read off the envelope instead of `.data`).
50
+ expect(session.id).toBe(REAL_SESSION_ID);
51
+ });
52
+
53
+ it('prompt substitutes the session id into the path (never the literal {id})', async () => {
54
+ const { urls, fetchFn } = makeFakeTransport();
55
+ const client = new OcClient({ principalId: 'discord', secret: 's', baseUrl: 'http://guardian:8080/oc', fetch: fetchFn });
56
+
57
+ const session = await client.createSession('discord:123');
58
+ await client.prompt('discord:123', session.id, 'hello');
59
+
60
+ const promptUrl = urls.find((u) => u.includes('/message'));
61
+ expect(promptUrl, 'a /message request must have been made').toBeDefined();
62
+ // The exact prod failure: the un-substituted template reaching the guardian.
63
+ expect(promptUrl).not.toContain('%7Bid%7D');
64
+ expect(promptUrl).not.toContain('{id}');
65
+ expect(promptUrl).toContain(`/session/${REAL_SESSION_ID}/message`);
66
+ });
67
+
68
+ it('createSession throws when the SDK returns an error envelope', async () => {
69
+ const fetchFn = (async () =>
70
+ new Response(JSON.stringify({ message: 'denied' }), {
71
+ status: 403,
72
+ headers: { 'content-type': 'application/json' },
73
+ })) as unknown as typeof fetch;
74
+ const client = new OcClient({ principalId: 'discord', secret: 's', baseUrl: 'http://guardian:8080/oc', fetch: fetchFn });
75
+
76
+ await expect(client.createSession('discord:123')).rejects.toThrow(/createSession failed/);
77
+ });
78
+ });
@@ -0,0 +1,130 @@
1
+ import type { Event } from '@opencode-ai/sdk';
2
+ import { createOpencodeClient } from '@opencode-ai/sdk';
3
+
4
+ const DEFAULT_OPENCODE_BASE_URL = 'http://guardian:8080/oc';
5
+ const H_USER = 'x-openpalm-user';
6
+ const H_SESSION_KEY = 'x-openpalm-session-key';
7
+
8
+ export interface OcClientOptions {
9
+ principalId: string;
10
+ secret: string;
11
+ baseUrl?: string;
12
+ fetch?: typeof fetch;
13
+ }
14
+
15
+ export interface OcSession {
16
+ id: string;
17
+ title?: string;
18
+ }
19
+
20
+ export class OcClient {
21
+ private readonly principalId: string;
22
+ private readonly secret: string;
23
+ private readonly base: string;
24
+ private readonly fetchFn: typeof fetch;
25
+ private readonly client: ReturnType<typeof createOpencodeClient>;
26
+
27
+ constructor(opts: OcClientOptions) {
28
+ this.principalId = opts.principalId;
29
+ this.secret = opts.secret;
30
+ this.base = opts.baseUrl ?? Bun.env.OPENCODE_BASE_URL ?? DEFAULT_OPENCODE_BASE_URL;
31
+ this.fetchFn = opts.fetch ?? globalThis.fetch;
32
+ this.client = createOpencodeClient({ baseUrl: this.base, fetch: this.fetchFn });
33
+ }
34
+
35
+ private headers(userId: string, extra?: Record<string, string>): Record<string, string> {
36
+ const credentials = Buffer.from(`${this.principalId}:${this.secret}`, 'utf-8').toString('base64');
37
+ return {
38
+ [H_USER]: userId,
39
+ authorization: `Basic ${credentials}`,
40
+ ...(extra ?? {}),
41
+ };
42
+ }
43
+
44
+ async createSession(userId: string, sessionKey?: string): Promise<OcSession> {
45
+ // The @opencode-ai/sdk client resolves to a { data, error } envelope
46
+ // (ThrowOnError defaults to false). The session lives in `.data` — reading
47
+ // the envelope directly yields an undefined id, which then renders the
48
+ // prompt path as the literal `/session/{id}/message`, and the guardian
49
+ // denies it with no_route. Always pull the session out of `.data`.
50
+ const { data, error } = await this.client.session.create({
51
+ body: {},
52
+ headers: this.headers(userId, sessionKey ? { [H_SESSION_KEY]: sessionKey } : undefined),
53
+ });
54
+ if (error || !data?.id) {
55
+ throw new Error(`createSession failed: ${error ? JSON.stringify(error) : 'no session id in response'}`);
56
+ }
57
+ return data as OcSession;
58
+ }
59
+
60
+ async prompt(userId: string, sessionId: string, text: string): Promise<void> {
61
+ // ThrowOnError is false, so a denied/failed prompt surfaces as `.error`
62
+ // rather than a throw — check it so failures aren't silently swallowed.
63
+ const { error } = await this.client.session.prompt({
64
+ path: { id: sessionId },
65
+ body: { parts: [{ type: 'text', text }] },
66
+ headers: this.headers(userId),
67
+ });
68
+ if (error) throw new Error(`prompt failed: ${JSON.stringify(error)}`);
69
+ }
70
+
71
+ async replyPermission(userId: string, requestID: string, reply: 'once' | 'always' | 'reject', message?: string): Promise<boolean> {
72
+ const body: Record<string, unknown> = { reply };
73
+ if (message) body.message = message;
74
+ const response = await this.fetchFn(`${this.base}/permission/${requestID}/reply`, {
75
+ method: 'POST',
76
+ headers: {
77
+ ...this.headers(userId),
78
+ 'content-type': 'application/json',
79
+ },
80
+ body: JSON.stringify(body),
81
+ });
82
+ if (!response.ok) throw new Error(`replyPermission failed: ${response.status}`);
83
+ return true;
84
+ }
85
+
86
+ async replyQuestion(userId: string, requestID: string, answers: string[][]): Promise<boolean> {
87
+ const response = await this.fetchFn(`${this.base}/question/${requestID}/reply`, {
88
+ method: 'POST',
89
+ headers: {
90
+ ...this.headers(userId),
91
+ 'content-type': 'application/json',
92
+ },
93
+ body: JSON.stringify({ answers }),
94
+ });
95
+ if (!response.ok) throw new Error(`replyQuestion failed: ${response.status}`);
96
+ return true;
97
+ }
98
+
99
+ async rejectQuestion(userId: string, requestID: string): Promise<void> {
100
+ const response = await this.fetchFn(`${this.base}/question/${requestID}/reject`, {
101
+ method: 'POST',
102
+ headers: {
103
+ ...this.headers(userId),
104
+ 'content-type': 'application/json',
105
+ },
106
+ body: '{}',
107
+ });
108
+ if (!response.ok) throw new Error(`rejectQuestion failed: ${response.status}`);
109
+ }
110
+
111
+ async abort(userId: string, sessionId: string): Promise<void> {
112
+ await this.client.session.abort({
113
+ path: { id: sessionId },
114
+ headers: this.headers(userId),
115
+ });
116
+ }
117
+
118
+ async *events(userId: string, signal: AbortSignal): AsyncGenerator<Event> {
119
+ const subscription = await this.client.event.subscribe({
120
+ headers: {
121
+ ...this.headers(userId),
122
+ accept: 'text/event-stream',
123
+ },
124
+ signal,
125
+ });
126
+ for await (const event of subscription.stream as AsyncIterable<Event>) {
127
+ yield event;
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,55 @@
1
+ import { createLogger, parseIdList } from './runtime.ts';
2
+ import type { PermissionResult } from './types.ts';
3
+ import type { PermissionConfig, UserInfo } from "./types.ts";
4
+
5
+ export { parseIdList };
6
+
7
+ const log = createLogger("channel-discord");
8
+
9
+ export function loadPermissionConfig(env: Record<string, string | undefined> = Bun.env): PermissionConfig {
10
+ const config: PermissionConfig = {
11
+ allowedGuilds: parseIdList(env.DISCORD_ALLOWED_GUILDS),
12
+ allowedRoles: parseIdList(env.DISCORD_ALLOWED_ROLES),
13
+ allowedUsers: parseIdList(env.DISCORD_ALLOWED_USERS),
14
+ blockedUsers: parseIdList(env.DISCORD_BLOCKED_USERS),
15
+ };
16
+
17
+ log.info("permissions_loaded", {
18
+ allowedGuilds: config.allowedGuilds.size || "unrestricted",
19
+ allowedRoles: config.allowedRoles.size || "unrestricted",
20
+ allowedUsers: config.allowedUsers.size || "unrestricted",
21
+ blockedUsers: config.blockedUsers.size || "none",
22
+ });
23
+
24
+ return config;
25
+ }
26
+
27
+ export function checkPermissions(config: PermissionConfig, user: UserInfo): PermissionResult {
28
+ const { userId, guildId, roles, username } = user;
29
+
30
+ if (userId && config.blockedUsers.has(userId)) {
31
+ log.warn("permission_denied", { userId, username, reason: "blocked_user" });
32
+ return { allowed: false, reason: "user_blocked" };
33
+ }
34
+
35
+ if (config.allowedUsers.size > 0) {
36
+ if (!userId || !config.allowedUsers.has(userId)) {
37
+ return { allowed: false, reason: "user_not_allowed" };
38
+ }
39
+ }
40
+
41
+ if (config.allowedGuilds.size > 0) {
42
+ if (!guildId || !config.allowedGuilds.has(guildId)) {
43
+ return { allowed: false, reason: "guild_not_allowed" };
44
+ }
45
+ }
46
+
47
+ if (config.allowedRoles.size > 0) {
48
+ const hasMatchingRole = roles.some((r) => config.allowedRoles.has(r));
49
+ if (!hasMatchingRole) {
50
+ return { allowed: false, reason: "role_not_allowed" };
51
+ }
52
+ }
53
+
54
+ return { allowed: true };
55
+ }