@openpalm/slack-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 +95 -0
- package/package.json +24 -0
- package/src/index.test.ts +1580 -0
- package/src/index.ts +1177 -0
- package/src/oc-events.ts +157 -0
- package/src/opencode.test.ts +79 -0
- package/src/opencode.ts +130 -0
- package/src/permissions.ts +46 -0
- package/src/runtime.ts +211 -0
- package/src/stream-render.test.ts +182 -0
- package/src/stream-render.ts +517 -0
- package/src/types.ts +17 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack rich-UX streaming renderer (design §4.3, §4.2) — Stage 5.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the Discord renderer (Stage 4) but renders with Block Kit and Slack's
|
|
5
|
+
* `chat.update`-style streaming instead of Discord message edits:
|
|
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 `chat.update` edits (~1 edit / EDIT_THROTTLE_MS), rolling to a
|
|
17
|
+
* new threaded message past Slack's 4000-char limit via splitMessage (§4.3);
|
|
18
|
+
* - tool-call status posted as a Block Kit context block, updated in place;
|
|
19
|
+
* - on `permission.asked`, Block Kit Approve / Always / Deny buttons restricted
|
|
20
|
+
* to the requesting Slack user → 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
|
+
* ALL native-OpenCode-event interpretation (delta / tool / permission / turn-end)
|
|
25
|
+
* is the SAME shared pure logic Discord uses, imported from
|
|
26
|
+
* the shared portal runtime (oc-events) — no per-channel duplication (§2.2). Only
|
|
27
|
+
* the Block Kit rendering and the Slack interaction wiring live here.
|
|
28
|
+
*
|
|
29
|
+
* Slack button clicks arrive centrally via `app.action(...)`, not per-message
|
|
30
|
+
* collectors, so this file exposes a `SlackPermissionRegistry` the adapter wires
|
|
31
|
+
* to its single action handler; the renderer records the principal + requesting
|
|
32
|
+
* user for each pending `requestID`/stop so the handler can authorize + relay.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
OcClient,
|
|
37
|
+
asRaw,
|
|
38
|
+
createLogger,
|
|
39
|
+
extractPermissionAsk,
|
|
40
|
+
extractQuestionAsk,
|
|
41
|
+
extractTextDelta,
|
|
42
|
+
extractToolUpdate,
|
|
43
|
+
isSessionError,
|
|
44
|
+
isTurnEnd,
|
|
45
|
+
partSnapshotType,
|
|
46
|
+
splitMessage,
|
|
47
|
+
type PermissionAsk,
|
|
48
|
+
type ToolUpdate,
|
|
49
|
+
} from './runtime.ts';
|
|
50
|
+
|
|
51
|
+
const log = createLogger("channel-slack:stream");
|
|
52
|
+
|
|
53
|
+
// ── Named tunables (design §4.3, §3.6 edit-throttle) ───────────────────────
|
|
54
|
+
|
|
55
|
+
/** Slack hard message length. */
|
|
56
|
+
const MAX_MESSAGE_LENGTH = 4000;
|
|
57
|
+
/** Edit throttle — ~1 update / 1.25s to stay under Slack chat.update rate limits (§4.3, ~750–1500ms). */
|
|
58
|
+
const EDIT_THROTTLE_MS = Number(Bun.env.SLACK_EDIT_THROTTLE_MS) || 1250;
|
|
59
|
+
/** Hard ceiling on a single rendered turn so a stuck stream can't render forever. */
|
|
60
|
+
const TURN_RENDER_TIMEOUT_MS = Number(Bun.env.SLACK_TURN_RENDER_TIMEOUT_MS) || 10 * 60_000;
|
|
61
|
+
|
|
62
|
+
// ── Block Kit action_ids (shared with the adapter's central action handler) ──
|
|
63
|
+
|
|
64
|
+
export const ACTION_PERM_ONCE = "oc_perm_once";
|
|
65
|
+
export const ACTION_PERM_ALWAYS = "oc_perm_always";
|
|
66
|
+
export const ACTION_PERM_DENY = "oc_perm_deny";
|
|
67
|
+
export const ACTION_STOP = "oc_stop";
|
|
68
|
+
|
|
69
|
+
// ── Minimal Slack WebClient subset this renderer uses ──────────────────────
|
|
70
|
+
|
|
71
|
+
export type StreamSlackClient = {
|
|
72
|
+
chat: {
|
|
73
|
+
postMessage: (args: {
|
|
74
|
+
channel: string;
|
|
75
|
+
text: string;
|
|
76
|
+
thread_ts?: string;
|
|
77
|
+
blocks?: unknown[];
|
|
78
|
+
}) => Promise<{ ts?: string }>;
|
|
79
|
+
update: (args: {
|
|
80
|
+
channel: string;
|
|
81
|
+
ts: string;
|
|
82
|
+
text: string;
|
|
83
|
+
blocks?: unknown[];
|
|
84
|
+
}) => Promise<unknown>;
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ── Permission / stop interaction registry (wired to app.action) ───────────
|
|
89
|
+
|
|
90
|
+
/** A pending permission prompt awaiting a Block Kit button click. */
|
|
91
|
+
export interface PendingPermission {
|
|
92
|
+
/** Principal userId that signs the /oc reply (e.g. "slack:U123"). */
|
|
93
|
+
userId: string;
|
|
94
|
+
/** The Slack user.id allowed to click (§4.3 interaction identity). */
|
|
95
|
+
requestingUserId: string;
|
|
96
|
+
permission: string;
|
|
97
|
+
channel: string;
|
|
98
|
+
/** ts of the message carrying the buttons (so the handler can update it). */
|
|
99
|
+
ts: string;
|
|
100
|
+
/** When the entry was registered — set by the registry for TTL pruning. */
|
|
101
|
+
createdAt?: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A pending stop control for an in-flight turn. */
|
|
105
|
+
export interface PendingStop {
|
|
106
|
+
userId: string;
|
|
107
|
+
requestingUserId: string;
|
|
108
|
+
sessionId: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Adapter-owned registry the single `app.action` handler consults. The renderer
|
|
113
|
+
* records entries; the handler authorizes (interaction identity), relays the
|
|
114
|
+
* signed /oc call, and clears the entry. Guardian-state stays in the guardian;
|
|
115
|
+
* this is purely local Slack interaction bookkeeping.
|
|
116
|
+
*/
|
|
117
|
+
/**
|
|
118
|
+
* How long an unclicked permission entry lingers before it is pruned. A prompt
|
|
119
|
+
* the user never clicks (ignored, or the turn ended) would otherwise leak its
|
|
120
|
+
* registry entry forever. The guardian also forgets the requestID on TTL, so a
|
|
121
|
+
* reply after this window would 403 anyway — keep the two roughly aligned.
|
|
122
|
+
*/
|
|
123
|
+
const PERMISSION_ENTRY_TTL_MS = Number(Bun.env.SLACK_PERMISSION_ENTRY_TTL_MS ?? 15 * 60_000);
|
|
124
|
+
/** Hard cap so a flood of unclicked prompts cannot grow the map without bound. */
|
|
125
|
+
const PERMISSION_ENTRIES_MAX = 10_000;
|
|
126
|
+
|
|
127
|
+
export class SlackPermissionRegistry {
|
|
128
|
+
private readonly client: OcClient;
|
|
129
|
+
private readonly permissions = new Map<string, PendingPermission>();
|
|
130
|
+
private readonly stops = new Map<string, PendingStop>();
|
|
131
|
+
|
|
132
|
+
constructor(client: OcClient) {
|
|
133
|
+
this.client = client;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
registerPermission(requestID: string, p: PendingPermission): void {
|
|
137
|
+
this.pruneExpiredPermissions();
|
|
138
|
+
this.permissions.set(requestID, { ...p, createdAt: p.createdAt ?? Date.now() });
|
|
139
|
+
if (this.permissions.size > PERMISSION_ENTRIES_MAX) this.pruneExpiredPermissions(true);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Drop permission entries past their TTL (lazy — called on register). When
|
|
143
|
+
* `force`, also evict oldest-first if still over the hard cap. */
|
|
144
|
+
private pruneExpiredPermissions(force = false): void {
|
|
145
|
+
const cutoff = Date.now() - PERMISSION_ENTRY_TTL_MS;
|
|
146
|
+
for (const [id, p] of this.permissions) {
|
|
147
|
+
if ((p.createdAt ?? 0) < cutoff) this.permissions.delete(id);
|
|
148
|
+
}
|
|
149
|
+
if (force && this.permissions.size > PERMISSION_ENTRIES_MAX) {
|
|
150
|
+
const sorted = [...this.permissions.entries()].sort((a, b) => (a[1].createdAt ?? 0) - (b[1].createdAt ?? 0));
|
|
151
|
+
for (const [id] of sorted.slice(0, sorted.length - PERMISSION_ENTRIES_MAX)) this.permissions.delete(id);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Active pending-permission count (for parity with guardian /stats discipline + tests). */
|
|
156
|
+
pendingPermissionCount(): number {
|
|
157
|
+
return this.permissions.size;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
registerStop(sessionId: string, s: PendingStop): void {
|
|
161
|
+
this.stops.set(sessionId, s);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
clearStop(sessionId: string): void {
|
|
165
|
+
this.stops.delete(sessionId);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Handle a permission button click. Returns the human-readable outcome to
|
|
170
|
+
* render back into the message, or null if the click is unauthorized/unknown
|
|
171
|
+
* (the caller renders nothing/an ephemeral warning). PURE w.r.t. Slack — it
|
|
172
|
+
* only touches the OcClient + the local map.
|
|
173
|
+
*/
|
|
174
|
+
async handlePermissionClick(
|
|
175
|
+
requestID: string,
|
|
176
|
+
action: string,
|
|
177
|
+
clickerUserId: string,
|
|
178
|
+
): Promise<{ text: string; channel: string; ts: string } | null> {
|
|
179
|
+
const pending = this.permissions.get(requestID);
|
|
180
|
+
if (!pending) return null;
|
|
181
|
+
// Interaction identity: only the requesting Slack user may decide (§4.3).
|
|
182
|
+
if (clickerUserId !== pending.requestingUserId) return null;
|
|
183
|
+
|
|
184
|
+
const reply = action === ACTION_PERM_ONCE ? "once" : action === ACTION_PERM_ALWAYS ? "always" : "reject";
|
|
185
|
+
try {
|
|
186
|
+
await this.client.replyPermission(pending.userId, requestID, reply);
|
|
187
|
+
this.permissions.delete(requestID);
|
|
188
|
+
return {
|
|
189
|
+
text: `Permission *${pending.permission}* → ${reply}.`,
|
|
190
|
+
channel: pending.channel,
|
|
191
|
+
ts: pending.ts,
|
|
192
|
+
};
|
|
193
|
+
} catch (err) {
|
|
194
|
+
log.warn("permission_reply_failed", { error: String(err), requestID });
|
|
195
|
+
this.permissions.delete(requestID);
|
|
196
|
+
return {
|
|
197
|
+
text: "Could not record that decision (it may have expired).",
|
|
198
|
+
channel: pending.channel,
|
|
199
|
+
ts: pending.ts,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Handle a Stop button click. Returns true if it authorized + issued the abort. */
|
|
205
|
+
async handleStopClick(sessionId: string, clickerUserId: string): Promise<boolean> {
|
|
206
|
+
const pending = this.stops.get(sessionId);
|
|
207
|
+
if (!pending) return false;
|
|
208
|
+
if (clickerUserId !== pending.requestingUserId) return false;
|
|
209
|
+
try {
|
|
210
|
+
await this.client.abort(pending.userId, sessionId);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
log.warn("abort_failed", { error: String(err), sessionId });
|
|
213
|
+
}
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Block Kit builders (pure) ───────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/** Buttons carry their target id in `value` so the central handler can route them. */
|
|
221
|
+
export function buildPermissionBlocks(ask: PermissionAsk): unknown[] {
|
|
222
|
+
const patterns = ask.patterns.length ? `\n\`${ask.patterns.join("`, `")}\`` : "";
|
|
223
|
+
return [
|
|
224
|
+
{
|
|
225
|
+
type: "section",
|
|
226
|
+
text: { type: "mrkdwn", text: `Permission requested: *${ask.permission}*${patterns}` },
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
type: "actions",
|
|
230
|
+
elements: [
|
|
231
|
+
{ type: "button", action_id: ACTION_PERM_ONCE, value: ask.requestID, style: "primary", text: { type: "plain_text", text: "Approve" } },
|
|
232
|
+
{ type: "button", action_id: ACTION_PERM_ALWAYS, value: ask.requestID, text: { type: "plain_text", text: "Always" } },
|
|
233
|
+
{ type: "button", action_id: ACTION_PERM_DENY, value: ask.requestID, style: "danger", text: { type: "plain_text", text: "Deny" } },
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Streaming-answer blocks: the live text plus a Stop button (value = sessionId). */
|
|
240
|
+
export function buildAnswerBlocks(text: string, sessionId: string): unknown[] {
|
|
241
|
+
return [
|
|
242
|
+
{ type: "section", text: { type: "mrkdwn", text: text || "…" } },
|
|
243
|
+
{
|
|
244
|
+
type: "actions",
|
|
245
|
+
elements: [
|
|
246
|
+
{ type: "button", action_id: ACTION_STOP, value: sessionId, text: { type: "plain_text", text: "Stop" } },
|
|
247
|
+
],
|
|
248
|
+
},
|
|
249
|
+
];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** A tool-call status as a context block (no interactivity). */
|
|
253
|
+
export function buildToolBlocks(tool: ToolUpdate): unknown[] {
|
|
254
|
+
// `||` (not `??`) so EMPTY strings fall back too — Block Kit rejects empty text.
|
|
255
|
+
const status = tool.status || "running";
|
|
256
|
+
const name = tool.tool || "tool";
|
|
257
|
+
const detail = tool.title || `status: ${status}`;
|
|
258
|
+
const err = tool.error ? ` — ${tool.error}` : "";
|
|
259
|
+
return [
|
|
260
|
+
{
|
|
261
|
+
type: "context",
|
|
262
|
+
elements: [{ type: "mrkdwn", text: `:wrench: *${name}* — ${detail} (${status}${err})` }],
|
|
263
|
+
},
|
|
264
|
+
];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Public entry: render one streamed turn into a Slack thread ──────────────
|
|
268
|
+
|
|
269
|
+
export interface SlackStreamTurnArgs {
|
|
270
|
+
client: OcClient;
|
|
271
|
+
registry: SlackPermissionRegistry;
|
|
272
|
+
slack: StreamSlackClient;
|
|
273
|
+
/** The principal userId (e.g. "slack:U123") — signs every /oc call. */
|
|
274
|
+
userId: string;
|
|
275
|
+
/** The Slack user.id allowed to click permission/stop buttons (§4.3). */
|
|
276
|
+
requestingUserId: string;
|
|
277
|
+
/** The Slack channel id to post into. */
|
|
278
|
+
channel: string;
|
|
279
|
+
/** The thread_ts to render under (the user's message / thread root). */
|
|
280
|
+
threadTs: string;
|
|
281
|
+
/** The session key for guardian grouping (e.g. "slack:thread:C:1.2"). */
|
|
282
|
+
sessionKey: string;
|
|
283
|
+
/** The user's prompt text. */
|
|
284
|
+
text: string;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Run ONE streamed turn end-to-end. Opens (or reuses) a session, subscribes to
|
|
289
|
+
* the filtered /event stream FIRST, then prompt_asyncs with a generated
|
|
290
|
+
* messageID, and renders deltas/tools/permissions until turn-end. Resolves when
|
|
291
|
+
* the turn reaches idle (so the conversation queue's run() promise settles per
|
|
292
|
+
* sessionKey), or on timeout/abort.
|
|
293
|
+
*/
|
|
294
|
+
export async function streamTurn(args: SlackStreamTurnArgs): Promise<void> {
|
|
295
|
+
const { client, registry, slack, userId, requestingUserId, channel, threadTs, sessionKey, text } = args;
|
|
296
|
+
|
|
297
|
+
const session = await client.createSession(userId, sessionKey);
|
|
298
|
+
const sessionId = session.id;
|
|
299
|
+
|
|
300
|
+
// Subscribe BEFORE prompting (§4.2) so no frame is missed.
|
|
301
|
+
const ac = new AbortController();
|
|
302
|
+
const eventsIter = client.events(userId, ac.signal);
|
|
303
|
+
|
|
304
|
+
// Post the live placeholder (carries the Stop button).
|
|
305
|
+
const placeholder = await slack.chat.postMessage({
|
|
306
|
+
channel,
|
|
307
|
+
thread_ts: threadTs,
|
|
308
|
+
text: "…",
|
|
309
|
+
blocks: buildAnswerBlocks("…", sessionId),
|
|
310
|
+
});
|
|
311
|
+
const answerTs = placeholder.ts;
|
|
312
|
+
if (answerTs) {
|
|
313
|
+
registry.registerStop(sessionId, { userId, requestingUserId, sessionId });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Fire the turn but DON'T await — /message resolves only at turn-end and the
|
|
317
|
+
// render loop drives off /event (prompt_async no-ops on follow-up turns).
|
|
318
|
+
void client.prompt(userId, sessionId, text).catch((err) => {
|
|
319
|
+
log.warn("prompt_failed", { error: String(err), sessionId });
|
|
320
|
+
ac.abort();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const renderer = new TurnRenderer(slack, channel, threadTs, answerTs, sessionId);
|
|
324
|
+
const toolTs = new Map<string, string>(); // callID → message ts
|
|
325
|
+
const reasoningParts = new Set<string>(); // partIDs typed "reasoning" → never rendered
|
|
326
|
+
|
|
327
|
+
const deadline = Date.now() + TURN_RENDER_TIMEOUT_MS;
|
|
328
|
+
try {
|
|
329
|
+
for await (const ev of eventsIter) {
|
|
330
|
+
if (Date.now() > deadline) {
|
|
331
|
+
log.warn("turn_render_timeout", { sessionId });
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
const e = asRaw(ev);
|
|
335
|
+
const snap = partSnapshotType(e);
|
|
336
|
+
if (snap && snap.type === "reasoning") reasoningParts.add(snap.partID);
|
|
337
|
+
|
|
338
|
+
const delta = extractTextDelta(e, sessionId, reasoningParts);
|
|
339
|
+
if (delta) {
|
|
340
|
+
await renderer.appendText(delta);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const tool = extractToolUpdate(e, sessionId);
|
|
345
|
+
if (tool && tool.callID) {
|
|
346
|
+
await renderToolMessage(slack, channel, threadTs, toolTs, tool);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const ask = extractPermissionAsk(e, sessionId);
|
|
351
|
+
if (ask) {
|
|
352
|
+
await renderPermissionPrompt(slack, registry, channel, threadTs, userId, requestingUserId, ask);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const question = extractQuestionAsk(e, sessionId);
|
|
357
|
+
if (question) {
|
|
358
|
+
// Interactive question UI for Slack is not implemented yet (Block Kit
|
|
359
|
+
// select TODO); reject so the turn doesn't hang awaiting an answer.
|
|
360
|
+
log.info("question_rejected_unsupported", { requestID: question.requestID });
|
|
361
|
+
await client.rejectQuestion(userId, question.requestID).catch((err) =>
|
|
362
|
+
log.warn("question_reject_failed", { error: String(err), requestID: question.requestID }),
|
|
363
|
+
);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (isTurnEnd(e, sessionId)) break;
|
|
368
|
+
|
|
369
|
+
// Upstream reset (guardian synthetic session.error) → surface + stop.
|
|
370
|
+
if (isSessionError(e, sessionId)) {
|
|
371
|
+
await slack.chat.postMessage({ channel, thread_ts: threadTs, text: "The assistant connection reset. Please try again." });
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
} finally {
|
|
376
|
+
ac.abort();
|
|
377
|
+
registry.clearStop(sessionId);
|
|
378
|
+
await renderer.finalize().catch(() => {});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ── Incremental text renderer (throttled chat.update + 4000-char roll) ──────
|
|
383
|
+
|
|
384
|
+
class TurnRenderer {
|
|
385
|
+
private buffer = "";
|
|
386
|
+
private lastEdit = 0;
|
|
387
|
+
private pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
388
|
+
|
|
389
|
+
constructor(
|
|
390
|
+
private readonly slack: StreamSlackClient,
|
|
391
|
+
private readonly channel: string,
|
|
392
|
+
private readonly threadTs: string,
|
|
393
|
+
private readonly answerTs: string | undefined,
|
|
394
|
+
private readonly sessionId: string,
|
|
395
|
+
) {}
|
|
396
|
+
|
|
397
|
+
async appendText(delta: string): Promise<void> {
|
|
398
|
+
this.buffer += delta;
|
|
399
|
+
const now = Date.now();
|
|
400
|
+
if (now - this.lastEdit >= EDIT_THROTTLE_MS) {
|
|
401
|
+
this.lastEdit = now;
|
|
402
|
+
await this.flush();
|
|
403
|
+
} else if (!this.pendingTimer) {
|
|
404
|
+
// Schedule a trailing flush so the final partial chunk isn't dropped.
|
|
405
|
+
this.pendingTimer = setTimeout(() => {
|
|
406
|
+
this.pendingTimer = null;
|
|
407
|
+
this.lastEdit = Date.now();
|
|
408
|
+
void this.flush();
|
|
409
|
+
}, EDIT_THROTTLE_MS);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Update the placeholder with the head chunk; never exceed the Slack limit. */
|
|
414
|
+
private async flush(): Promise<void> {
|
|
415
|
+
if (!this.answerTs) return;
|
|
416
|
+
const chunks = splitMessage(this.buffer, MAX_MESSAGE_LENGTH);
|
|
417
|
+
const head = chunks[0] ?? "…";
|
|
418
|
+
try {
|
|
419
|
+
await this.slack.chat.update({
|
|
420
|
+
channel: this.channel,
|
|
421
|
+
ts: this.answerTs,
|
|
422
|
+
text: head || "…",
|
|
423
|
+
blocks: buildAnswerBlocks(head || "…", this.sessionId),
|
|
424
|
+
});
|
|
425
|
+
} catch (err) {
|
|
426
|
+
log.warn("update_failed", { error: String(err) });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** On turn-end: write the final head chunk (drop the Stop button) + thread the rest. */
|
|
431
|
+
async finalize(): Promise<void> {
|
|
432
|
+
if (this.pendingTimer) {
|
|
433
|
+
clearTimeout(this.pendingTimer);
|
|
434
|
+
this.pendingTimer = null;
|
|
435
|
+
}
|
|
436
|
+
const chunks = splitMessage(this.buffer || "No response received.", MAX_MESSAGE_LENGTH);
|
|
437
|
+
const head = chunks[0] ?? "No response received.";
|
|
438
|
+
if (this.answerTs) {
|
|
439
|
+
try {
|
|
440
|
+
// Final update drops the Stop button (no blocks → plain text).
|
|
441
|
+
await this.slack.chat.update({ channel: this.channel, ts: this.answerTs, text: head });
|
|
442
|
+
} catch {
|
|
443
|
+
// ignore
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
await this.slack.chat.postMessage({ channel: this.channel, thread_ts: this.threadTs, text: head }).catch(() => {});
|
|
447
|
+
}
|
|
448
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
449
|
+
await this.slack.chat.postMessage({ channel: this.channel, thread_ts: this.threadTs, text: chunks[i] }).catch(() => {});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ── Tool status messages (updated in place by callID) ──────────────────────
|
|
455
|
+
|
|
456
|
+
async function renderToolMessage(
|
|
457
|
+
slack: StreamSlackClient,
|
|
458
|
+
channel: string,
|
|
459
|
+
threadTs: string,
|
|
460
|
+
toolTs: Map<string, string>,
|
|
461
|
+
tool: ToolUpdate,
|
|
462
|
+
): Promise<void> {
|
|
463
|
+
const blocks = buildToolBlocks(tool);
|
|
464
|
+
const fallback = `${tool.tool}: ${tool.status}`;
|
|
465
|
+
const existing = toolTs.get(tool.callID);
|
|
466
|
+
try {
|
|
467
|
+
if (existing) {
|
|
468
|
+
await slack.chat.update({ channel, ts: existing, text: fallback, blocks });
|
|
469
|
+
} else {
|
|
470
|
+
const msg = await slack.chat.postMessage({ channel, thread_ts: threadTs, text: fallback, blocks });
|
|
471
|
+
if (msg.ts) toolTs.set(tool.callID, msg.ts);
|
|
472
|
+
}
|
|
473
|
+
} catch (err) {
|
|
474
|
+
log.warn("tool_message_failed", { error: String(err) });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ── Permission prompt (Block Kit buttons → central app.action handler) ──────
|
|
479
|
+
|
|
480
|
+
async function renderPermissionPrompt(
|
|
481
|
+
slack: StreamSlackClient,
|
|
482
|
+
registry: SlackPermissionRegistry,
|
|
483
|
+
channel: string,
|
|
484
|
+
threadTs: string,
|
|
485
|
+
userId: string,
|
|
486
|
+
requestingUserId: string,
|
|
487
|
+
ask: PermissionAsk,
|
|
488
|
+
): Promise<void> {
|
|
489
|
+
const blocks = buildPermissionBlocks(ask);
|
|
490
|
+
try {
|
|
491
|
+
const msg = await slack.chat.postMessage({
|
|
492
|
+
channel,
|
|
493
|
+
thread_ts: threadTs,
|
|
494
|
+
text: `Permission requested: ${ask.permission}`,
|
|
495
|
+
blocks,
|
|
496
|
+
});
|
|
497
|
+
if (msg.ts) {
|
|
498
|
+
registry.registerPermission(ask.requestID, {
|
|
499
|
+
userId,
|
|
500
|
+
requestingUserId,
|
|
501
|
+
permission: ask.permission,
|
|
502
|
+
channel,
|
|
503
|
+
ts: msg.ts,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
} catch (err) {
|
|
507
|
+
log.warn("permission_prompt_failed", { error: String(err), requestID: ask.requestID });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ── Exported pure helpers for unit tests ───────────────────────────────────
|
|
512
|
+
|
|
513
|
+
export const _internal = {
|
|
514
|
+
buildPermissionBlocks,
|
|
515
|
+
buildAnswerBlocks,
|
|
516
|
+
buildToolBlocks,
|
|
517
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type PermissionConfig = {
|
|
2
|
+
allowedChannels: Set<string>;
|
|
3
|
+
allowedUsers: Set<string>;
|
|
4
|
+
blockedUsers: Set<string>;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type PermissionResult = {
|
|
8
|
+
allowed: boolean;
|
|
9
|
+
reason?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type UserInfo = {
|
|
13
|
+
userId: string;
|
|
14
|
+
teamId: string;
|
|
15
|
+
channelId: string;
|
|
16
|
+
username?: string;
|
|
17
|
+
};
|