@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/src/index.ts ADDED
@@ -0,0 +1,1177 @@
1
+ import {
2
+ asRaw,
3
+ ConversationQueue,
4
+ extractTextDelta,
5
+ isTurnEnd,
6
+ OcClient,
7
+ partSnapshotType,
8
+ SecretFileError,
9
+ createLogger,
10
+ readRequiredSecretFile,
11
+ splitMessage,
12
+ } from './runtime.ts';
13
+ import { App, type GenericMessageEvent, type KnownEventFromType } from "@slack/bolt";
14
+ import { checkPermissions, loadPermissionConfig } from "./permissions.ts";
15
+ import {
16
+ SlackPermissionRegistry,
17
+ streamTurn,
18
+ ACTION_PERM_ONCE,
19
+ ACTION_PERM_ALWAYS,
20
+ ACTION_PERM_DENY,
21
+ ACTION_STOP,
22
+ type StreamSlackClient,
23
+ } from "./stream-render.ts";
24
+ import type { PermissionConfig, UserInfo } from "./types.ts";
25
+
26
+ const log = createLogger("channel-slack");
27
+
28
+ const MAX_MESSAGE_LENGTH = 4000;
29
+ const DEFAULT_FORWARD_TIMEOUT_MS = 1_800_000;
30
+ const ASK_MODAL_CALLBACK_ID = "ask_openpalm_modal";
31
+ const ASK_MODAL_INPUT_BLOCK_ID = "ask_openpalm_prompt_block";
32
+ const ASK_MODAL_INPUT_ACTION_ID = "ask_openpalm_prompt_action";
33
+ const ASK_GLOBAL_SHORTCUT_ID = "ask_openpalm";
34
+ const ASK_MESSAGE_SHORTCUT_ID = "ask_openpalm_message";
35
+
36
+ function parseForwardTimeoutMs(value: string | undefined): number {
37
+ const parsed = Number(value);
38
+ if (!Number.isFinite(parsed) || parsed <= 0) {
39
+ return DEFAULT_FORWARD_TIMEOUT_MS;
40
+ }
41
+ return Math.floor(parsed);
42
+ }
43
+
44
+ type ForwardResult = {
45
+ userId: string;
46
+ text: string;
47
+ metadata?: Record<string, unknown>;
48
+ };
49
+
50
+ function json(status: number, data: unknown): Response {
51
+ return new Response(JSON.stringify(data), {
52
+ status,
53
+ headers: { 'content-type': 'application/json' },
54
+ });
55
+ }
56
+
57
+ export default class SlackChannel {
58
+ name = "slack";
59
+ port: number = Number(Bun.env.PORT) || 8080;
60
+ guardianUrl = 'http://guardian:8080';
61
+ private _fetchFn: typeof fetch = fetch;
62
+
63
+ private app: App | null = null;
64
+ private permissions: PermissionConfig = loadPermissionConfig();
65
+ private conversationQueue = new ConversationQueue();
66
+ private botUserId: string | null = null;
67
+ /** Cache of Slack user ID → display name to avoid repeated API calls. */
68
+ private usernameCache = new Map<string, string>();
69
+
70
+ /**
71
+ * Threads the bot is actively participating in.
72
+ * Map of "channel:thread_ts" → last activity timestamp (ms).
73
+ * Threads expire after threadTtlMs of inactivity.
74
+ */
75
+ private activeThreads = new Map<string, number>();
76
+
77
+ /** Thread inactivity TTL in ms. Default: 24 hours. */
78
+ private threadTtlMs = (Number(Bun.env.SLACK_THREAD_TTL_HOURS) || 24) * 3_600_000;
79
+
80
+ /** Forward timeout in ms. Default: 30 minutes. */
81
+ private forwardTimeoutMs = parseForwardTimeoutMs(Bun.env.SLACK_FORWARD_TIMEOUT_MS);
82
+
83
+ /**
84
+ * Opt-in rich-UX streaming. When false (default), turns are buffered and the
85
+ * full assistant reply is posted once complete. When true, thread turns render
86
+ * live via the guardian /oc/* proxy with Block Kit tool status + interactive
87
+ * permission prompts.
88
+ */
89
+ private streamingEnabled = Bun.env.SLACK_STREAMING === "true";
90
+
91
+ /** Lazily-built native OpenCode client through the guardian /oc/* proxy. */
92
+ private ocClientInstance: OcClient | null = null;
93
+ /** Lazily-built permission/stop interaction registry (wired to app.action). */
94
+ private permissionRegistryInstance: SlackPermissionRegistry | null = null;
95
+
96
+ private get ocClient(): OcClient {
97
+ if (!this.ocClientInstance) {
98
+ this.ocClientInstance = new OcClient({
99
+ principalId: this.name,
100
+ secret: this.secret,
101
+ baseUrl: `${this.guardianUrl}/oc`,
102
+ });
103
+ }
104
+ return this.ocClientInstance;
105
+ }
106
+
107
+ private get permissionRegistry(): SlackPermissionRegistry {
108
+ if (!this.permissionRegistryInstance) {
109
+ this.permissionRegistryInstance = new SlackPermissionRegistry(this.ocClient);
110
+ }
111
+ return this.permissionRegistryInstance;
112
+ }
113
+
114
+ get botToken(): string {
115
+ return readRequiredSecretFile("SLACK_BOT_TOKEN_FILE");
116
+ }
117
+
118
+ get appToken(): string {
119
+ return readRequiredSecretFile("SLACK_APP_TOKEN_FILE");
120
+ }
121
+
122
+ get secret(): string {
123
+ return readRequiredSecretFile('PRINCIPAL_SECRET_FILE');
124
+ }
125
+
126
+ async handleRequest(_req: Request): Promise<null> {
127
+ return null;
128
+ }
129
+
130
+ private async forward(result: ForwardResult, fetchFn?: typeof fetch, timeoutMs?: number): Promise<Response> {
131
+ const fn = fetchFn ?? this._fetchFn;
132
+ const controller = timeoutMs && timeoutMs > 0 ? new AbortController() : null;
133
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
134
+ try {
135
+ const client = new OcClient({
136
+ principalId: Bun.env.PRINCIPAL_ID ?? this.name,
137
+ secret: this.secret,
138
+ baseUrl: `${this.guardianUrl}/oc`,
139
+ fetch: fn,
140
+ });
141
+ const sessionKey = typeof result.metadata?.sessionKey === 'string' ? result.metadata.sessionKey : result.userId;
142
+ const session = await client.createSession(result.userId, sessionKey);
143
+ const answerPromise = collectTurnAnswer(client, result.userId, session.id, controller?.signal ?? new AbortController().signal);
144
+ await client.prompt(result.userId, session.id, result.text);
145
+ const answer = await answerPromise;
146
+ return json(200, { userId: result.userId, sessionId: session.id, answer });
147
+ } finally {
148
+ if (timer) clearTimeout(timer);
149
+ controller?.abort();
150
+ }
151
+ }
152
+
153
+ createFetch(fetchFn: typeof fetch = fetch): (req: Request) => Promise<Response> {
154
+ this._fetchFn = fetchFn;
155
+ return async (req: Request): Promise<Response> => {
156
+ const url = new URL(req.url);
157
+ if (url.pathname === '/health') {
158
+ return json(200, { ok: true, service: `channel-${this.name}` });
159
+ }
160
+ return json(404, { error: 'not_found' });
161
+ };
162
+ }
163
+
164
+ start(): void {
165
+ try {
166
+ this.secret;
167
+ } catch (err) {
168
+ log.error('startup_error', {
169
+ reason: err instanceof SecretFileError ? err.message : 'PRINCIPAL_SECRET_FILE could not be read',
170
+ });
171
+ process.exit(1);
172
+ }
173
+
174
+ try {
175
+ Bun.serve({ port: this.port, fetch: this.createFetch() });
176
+ log.info('started', { port: this.port });
177
+ } catch (err) {
178
+ log.error('failed to start server', {
179
+ port: this.port,
180
+ error: err instanceof Error ? err.message : String(err),
181
+ });
182
+ process.exit(1);
183
+ }
184
+
185
+ void this.connectSocketMode();
186
+ }
187
+
188
+ // ── Socket Mode Connection ────────────────────────────────────────────
189
+
190
+ private async connectSocketMode(): Promise<void> {
191
+ let botToken: string;
192
+ let appToken: string;
193
+ try {
194
+ botToken = this.botToken;
195
+ appToken = this.appToken;
196
+ } catch (err) {
197
+ log.error("startup_error", { reason: err instanceof Error ? err.message : "Slack secret file could not be read" });
198
+ process.exit(1);
199
+ }
200
+
201
+ this.app = new App({
202
+ token: botToken,
203
+ appToken,
204
+ socketMode: true,
205
+ });
206
+
207
+ // Register event handlers
208
+ this.app.event("message", async ({ event, say, client }) => {
209
+ await this.onMessage(event as GenericMessageEvent, say, client);
210
+ });
211
+
212
+ this.app.event("app_mention", async ({ event, say, client }) => {
213
+ await this.onAppMention(event, say, client);
214
+ });
215
+
216
+ this.app.command("/ask", async ({ command, ack, say, client }) => {
217
+ await ack();
218
+ await this.onAskCommand(command, say, client);
219
+ });
220
+
221
+ this.app.command("/clear", async ({ command, ack, say }) => {
222
+ await ack();
223
+ await this.onClearCommand(command, say);
224
+ });
225
+
226
+ this.app.command("/help", async ({ command, ack, say }) => {
227
+ await ack();
228
+ await this.onHelpCommand(command, say);
229
+ });
230
+
231
+ this.app.shortcut(ASK_GLOBAL_SHORTCUT_ID, async ({ shortcut, ack, client }) => {
232
+ await ack();
233
+ await this.onGlobalShortcut(shortcut as GlobalShortcut, client as SlackClient);
234
+ });
235
+
236
+ this.app.shortcut(ASK_MESSAGE_SHORTCUT_ID, async ({ shortcut, ack, client }) => {
237
+ await ack();
238
+ await this.onMessageShortcut(shortcut as MessageShortcut, client as SlackClient);
239
+ });
240
+
241
+ this.app.view(ASK_MODAL_CALLBACK_ID, async ({ body, view, ack, client }) => {
242
+ await ack();
243
+ await this.onAskModalSubmission(
244
+ body as ViewSubmissionBody,
245
+ view as ModalView,
246
+ client as SlackClient,
247
+ );
248
+ });
249
+
250
+ this.app.event("app_home_opened", async ({ event, client }) => {
251
+ await this.onAppHomeOpened(event as AppHomeOpenedEvent, client as SlackClient);
252
+ });
253
+
254
+ // Rich-UX (Stage 5) Block Kit interactions: permission decisions + Stop.
255
+ // ONE central handler per action_id routes the click to the registry, which
256
+ // authorizes (interaction identity) and relays the signed /oc reply (§4.3).
257
+ if (this.streamingEnabled) {
258
+ for (const actionId of [ACTION_PERM_ONCE, ACTION_PERM_ALWAYS, ACTION_PERM_DENY]) {
259
+ this.app.action(actionId, async ({ body, ack, client }) => {
260
+ await ack();
261
+ await this.onPermissionAction(actionId, body as BlockActionBody, client as SlackClient);
262
+ });
263
+ }
264
+ this.app.action(ACTION_STOP, async ({ body, ack, client }) => {
265
+ await ack();
266
+ await this.onStopAction(body as BlockActionBody, client as SlackClient);
267
+ });
268
+ }
269
+
270
+ this.app.error(async (error) => {
271
+ const errMsg = error instanceof Error ? error.message : String(error);
272
+ log.error("bolt_app_error", { error: errMsg });
273
+ });
274
+
275
+ await this.app.start();
276
+
277
+ // Resolve the bot's own user ID so we can strip self-mentions
278
+ try {
279
+ const authResult = await this.app.client.auth.test({ token: botToken });
280
+ this.botUserId = (authResult.user_id as string) ?? null;
281
+ } catch {
282
+ log.warn("auth_test_failed", { reason: "Could not resolve bot user ID" });
283
+ }
284
+
285
+ log.info("socket_mode_connected", {
286
+ botUserId: this.botUserId,
287
+ });
288
+ }
289
+
290
+ // ── Thread Tracking ─────────────────────────────────────────────────
291
+
292
+ private threadKey(channel: string, threadTs: string): string {
293
+ return `${channel}:${threadTs}`;
294
+ }
295
+
296
+ private isThreadActive(channel: string, threadTs: string): boolean {
297
+ const key = this.threadKey(channel, threadTs);
298
+ const lastActivity = this.activeThreads.get(key);
299
+ if (lastActivity === undefined) return false;
300
+ if (Date.now() - lastActivity > this.threadTtlMs) {
301
+ this.activeThreads.delete(key);
302
+ return false;
303
+ }
304
+ return true;
305
+ }
306
+
307
+ private touchThread(channel: string, threadTs: string): void {
308
+ this.activeThreads.set(this.threadKey(channel, threadTs), Date.now());
309
+ if (this.activeThreads.size > 100) {
310
+ const now = Date.now();
311
+ for (const [id, ts] of this.activeThreads) {
312
+ if (now - ts > this.threadTtlMs) this.activeThreads.delete(id);
313
+ }
314
+ }
315
+ }
316
+
317
+ // ── Message Handling ──────────────────────────────────────────────────
318
+
319
+ private async onMessage(
320
+ event: GenericMessageEvent,
321
+ say: SayFn,
322
+ client: SlackClient,
323
+ ): Promise<void> {
324
+ // Ignore bot messages, message_changed, etc.
325
+ if (event.subtype) return;
326
+ if (event.bot_id) return;
327
+ if (this.botUserId && event.user === this.botUserId) return;
328
+ if (!event.text?.trim()) return;
329
+
330
+ const isDM = event.channel_type === "im";
331
+ const inTrackedThread = event.thread_ts != null
332
+ && this.isThreadActive(event.channel, event.thread_ts);
333
+
334
+ // Respond to DMs and messages in threads the bot is already participating in
335
+ if (!isDM && !inTrackedThread) return;
336
+
337
+ // Skip @mentions in tracked threads — onAppMention handles these.
338
+ // Processing both causes duplicate responses.
339
+ if (inTrackedThread && this.botUserId && event.text.includes(`<@${this.botUserId}>`)) return;
340
+
341
+ const userInfo = await this.extractUserInfo(event, client);
342
+ const permResult = checkPermissions(this.permissions, userInfo);
343
+ if (!permResult.allowed) {
344
+ await say({ text: "You do not have permission to use this bot.", thread_ts: event.ts });
345
+ return;
346
+ }
347
+
348
+ const text = this.stripMention(event.text.trim());
349
+ if (!text) return;
350
+
351
+ const threadTs = event.thread_ts ?? event.ts;
352
+ const sessionKey = event.thread_ts
353
+ ? `slack:thread:${event.channel}:${event.thread_ts}`
354
+ : isDM
355
+ ? `slack:dm:${event.user}`
356
+ : `slack:channel:${event.channel}:user:${event.user}`;
357
+
358
+ if (inTrackedThread) {
359
+ this.touchThread(event.channel, event.thread_ts!);
360
+ }
361
+
362
+ await this.conversationQueue.runOrQueue(sessionKey, {
363
+ onQueued: async () => {
364
+ await say({ text: "Queued. I will pick this up next.", thread_ts: threadTs });
365
+ },
366
+ run: async () => {
367
+ await this.runConversation(client, event.channel, threadTs, userInfo, text, sessionKey);
368
+ },
369
+ });
370
+ }
371
+
372
+ private async onAppMention(
373
+ event: KnownEventFromType<"app_mention">,
374
+ say: SayFn,
375
+ client: SlackClient,
376
+ ): Promise<void> {
377
+ if (!event.text?.trim()) return;
378
+
379
+ const username = await this.resolveUsername(event.user, client);
380
+ const rawTeam = (event as Record<string, unknown>).team;
381
+ const userInfo: UserInfo = {
382
+ userId: event.user,
383
+ teamId: typeof rawTeam === "string" ? rawTeam : "",
384
+ channelId: event.channel,
385
+ username,
386
+ };
387
+
388
+ const permResult = checkPermissions(this.permissions, userInfo);
389
+ if (!permResult.allowed) {
390
+ await say({ text: "You do not have permission to use this bot.", thread_ts: event.ts });
391
+ return;
392
+ }
393
+
394
+ const text = this.stripMention(event.text);
395
+ if (!text.trim()) {
396
+ await say({ text: "Please provide a message.", thread_ts: event.ts });
397
+ return;
398
+ }
399
+
400
+ // Always reply in thread — use existing thread or start new one
401
+ const threadTs = event.thread_ts ?? event.ts;
402
+ // Track this thread so the bot responds to follow-up messages without a mention
403
+ this.touchThread(event.channel, threadTs);
404
+
405
+ const sessionKey = threadTs
406
+ ? `slack:thread:${event.channel}:${threadTs}`
407
+ : `slack:channel:${event.channel}:user:${event.user}`;
408
+
409
+ await this.conversationQueue.runOrQueue(sessionKey, {
410
+ onQueued: async () => {
411
+ await say({ text: "Queued. I will pick this up next.", thread_ts: threadTs });
412
+ },
413
+ run: async () => {
414
+ await this.runConversation(client, event.channel, threadTs, userInfo, text, sessionKey);
415
+ },
416
+ });
417
+ }
418
+
419
+ // ── Slash Commands ────────────────────────────────────────────────────
420
+
421
+ private buildAskModalView(initialPrompt: string, metadata: ModalMetadata): SlackViewDefinition {
422
+ return {
423
+ type: "modal",
424
+ callback_id: ASK_MODAL_CALLBACK_ID,
425
+ private_metadata: JSON.stringify(metadata),
426
+ title: {
427
+ type: "plain_text",
428
+ text: "Ask OpenPalm",
429
+ },
430
+ submit: {
431
+ type: "plain_text",
432
+ text: "Ask",
433
+ },
434
+ close: {
435
+ type: "plain_text",
436
+ text: "Cancel",
437
+ },
438
+ blocks: [
439
+ {
440
+ type: "input",
441
+ block_id: ASK_MODAL_INPUT_BLOCK_ID,
442
+ label: {
443
+ type: "plain_text",
444
+ text: "Prompt",
445
+ },
446
+ element: {
447
+ type: "plain_text_input",
448
+ action_id: ASK_MODAL_INPUT_ACTION_ID,
449
+ multiline: true,
450
+ initial_value: initialPrompt,
451
+ },
452
+ },
453
+ ],
454
+ };
455
+ }
456
+
457
+ private async onGlobalShortcut(shortcut: GlobalShortcut, client: SlackClient): Promise<void> {
458
+ const metadata: ModalMetadata = {
459
+ source: "global-shortcut",
460
+ teamId: shortcut.team?.id,
461
+ };
462
+
463
+ try {
464
+ await client.views.open({
465
+ trigger_id: shortcut.trigger_id,
466
+ view: this.buildAskModalView("", metadata),
467
+ });
468
+ } catch (error) {
469
+ const errMsg = error instanceof Error ? error.message : String(error);
470
+ log.error("shortcut_modal_open_error", {
471
+ source: "global-shortcut",
472
+ userId: shortcut.user.id,
473
+ error: errMsg,
474
+ });
475
+ }
476
+ }
477
+
478
+ private async onMessageShortcut(shortcut: MessageShortcut, client: SlackClient): Promise<void> {
479
+ const messageText = shortcut.message.text?.trim() ?? "";
480
+ const initialPrompt = messageText
481
+ ? `Ask OpenPalm about this message:\n${messageText}\n\n`
482
+ : "Ask OpenPalm about this message:\n\n";
483
+
484
+ const metadata: ModalMetadata = {
485
+ source: "message-shortcut",
486
+ channelId: shortcut.channel.id,
487
+ threadTs: shortcut.message.ts,
488
+ teamId: shortcut.team?.id,
489
+ };
490
+
491
+ try {
492
+ await client.views.open({
493
+ trigger_id: shortcut.trigger_id,
494
+ view: this.buildAskModalView(initialPrompt, metadata),
495
+ });
496
+ } catch (error) {
497
+ const errMsg = error instanceof Error ? error.message : String(error);
498
+ log.error("shortcut_modal_open_error", {
499
+ source: "message-shortcut",
500
+ userId: shortcut.user.id,
501
+ channelId: shortcut.channel.id,
502
+ error: errMsg,
503
+ });
504
+ }
505
+ }
506
+
507
+ private parseModalMetadata(raw: string | undefined): ModalMetadata {
508
+ if (!raw) return { source: "global-shortcut" };
509
+ try {
510
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
511
+ const source = parsed.source === "message-shortcut" ? "message-shortcut" : "global-shortcut";
512
+ return {
513
+ source,
514
+ channelId: typeof parsed.channelId === "string" ? parsed.channelId : undefined,
515
+ threadTs: typeof parsed.threadTs === "string" ? parsed.threadTs : undefined,
516
+ teamId: typeof parsed.teamId === "string" ? parsed.teamId : undefined,
517
+ };
518
+ } catch {
519
+ return { source: "global-shortcut" };
520
+ }
521
+ }
522
+
523
+ private getModalPrompt(view: ModalView): string {
524
+ const blockValues = view.state.values[ASK_MODAL_INPUT_BLOCK_ID];
525
+ if (!blockValues) return "";
526
+ const actionValues = blockValues[ASK_MODAL_INPUT_ACTION_ID];
527
+ return typeof actionValues?.value === "string" ? actionValues.value.trim() : "";
528
+ }
529
+
530
+ private async onAskModalSubmission(
531
+ body: ViewSubmissionBody,
532
+ view: ModalView,
533
+ client: SlackClient,
534
+ ): Promise<void> {
535
+ const text = this.getModalPrompt(view);
536
+ if (!text) {
537
+ log.warn("modal_submission_empty_prompt", { userId: body.user.id });
538
+ return;
539
+ }
540
+
541
+ const metadata = this.parseModalMetadata(view.private_metadata);
542
+
543
+ try {
544
+ let channelId = metadata.channelId;
545
+ if (!channelId) {
546
+ const openResult = await client.conversations.open({ users: body.user.id });
547
+ channelId = openResult.channel?.id;
548
+ if (!channelId) {
549
+ throw new Error("Could not resolve DM channel for modal response");
550
+ }
551
+ }
552
+
553
+ const userInfo: UserInfo = {
554
+ userId: body.user.id,
555
+ teamId: body.team?.id ?? metadata.teamId ?? "",
556
+ channelId,
557
+ username: body.user.username ?? body.user.name,
558
+ };
559
+
560
+ const permResult = checkPermissions(this.permissions, userInfo);
561
+ if (!permResult.allowed) {
562
+ await client.chat.postMessage({
563
+ channel: channelId,
564
+ text: "You do not have permission to use this bot.",
565
+ thread_ts: metadata.threadTs,
566
+ });
567
+ return;
568
+ }
569
+
570
+ const sessionKey = metadata.threadTs
571
+ ? `slack:thread:${channelId}:${metadata.threadTs}`
572
+ : channelId.startsWith("D")
573
+ ? `slack:dm:${userInfo.userId}`
574
+ : `slack:channel:${channelId}:user:${userInfo.userId}`;
575
+
576
+ await this.conversationQueue.runOrQueue(sessionKey, {
577
+ onQueued: async () => {
578
+ await client.chat.postMessage({
579
+ channel: channelId,
580
+ text: "Queued. I will pick this up next.",
581
+ thread_ts: metadata.threadTs,
582
+ });
583
+ },
584
+ run: async () => {
585
+ if (metadata.threadTs) {
586
+ await this.runConversation(client, channelId, metadata.threadTs, userInfo, text, sessionKey);
587
+ return;
588
+ }
589
+
590
+ const thinkingResult = await client.chat.postMessage({
591
+ channel: channelId,
592
+ text: `:hourglass: Processing your request...`,
593
+ });
594
+ const thinkingTs = thinkingResult.ts;
595
+
596
+ try {
597
+ const resp = await this.forward({
598
+ userId: `slack:${userInfo.userId}`,
599
+ text,
600
+ metadata: {
601
+ teamId: userInfo.teamId,
602
+ username: userInfo.username,
603
+ command: "ask_modal",
604
+ channelId,
605
+ sessionKey,
606
+ },
607
+ }, undefined, this.forwardTimeoutMs);
608
+ if (!resp.ok) throw new Error(`Guardian returned status ${resp.status}`);
609
+ const { answer = "No response received." } = await resp.json() as { answer?: string };
610
+
611
+ const chunks = splitMessage(answer, MAX_MESSAGE_LENGTH);
612
+ const firstChunk = chunks[0] ?? "No response received.";
613
+
614
+ if (thinkingTs) {
615
+ await client.chat.update({
616
+ channel: channelId,
617
+ ts: thinkingTs,
618
+ text: firstChunk,
619
+ });
620
+ }
621
+ for (let i = 1; i < chunks.length; i++) {
622
+ await client.chat.postMessage({
623
+ channel: channelId,
624
+ text: chunks[i],
625
+ thread_ts: thinkingTs,
626
+ });
627
+ }
628
+
629
+ log.info("modal_submission_completed", {
630
+ source: metadata.source,
631
+ userId: userInfo.userId,
632
+ channelId,
633
+ sessionKey,
634
+ });
635
+ } catch (error) {
636
+ const errMsg = error instanceof Error ? error.message : String(error);
637
+ log.error("modal_submission_error", {
638
+ source: metadata.source,
639
+ userId: userInfo.userId,
640
+ channelId,
641
+ sessionKey,
642
+ error: errMsg,
643
+ });
644
+ if (thinkingTs) {
645
+ await client.chat.update({
646
+ channel: channelId,
647
+ ts: thinkingTs,
648
+ text: `Error: ${errMsg}`,
649
+ });
650
+ }
651
+ }
652
+ },
653
+ });
654
+ } catch (error) {
655
+ const errMsg = error instanceof Error ? error.message : String(error);
656
+ log.error("modal_submission_error", {
657
+ source: metadata.source,
658
+ userId: body.user.id,
659
+ error: errMsg,
660
+ });
661
+ }
662
+ }
663
+
664
+ private async onAppHomeOpened(event: AppHomeOpenedEvent, client: SlackClient): Promise<void> {
665
+ try {
666
+ await client.views.publish({
667
+ user_id: event.user,
668
+ view: {
669
+ type: "home",
670
+ blocks: [
671
+ {
672
+ type: "header",
673
+ text: {
674
+ type: "plain_text",
675
+ text: "OpenPalm on Slack",
676
+ },
677
+ },
678
+ {
679
+ type: "section",
680
+ text: {
681
+ type: "mrkdwn",
682
+ text: "Ask questions from DMs, mentions, slash commands, or shortcuts.",
683
+ },
684
+ },
685
+ {
686
+ type: "section",
687
+ text: {
688
+ type: "mrkdwn",
689
+ text: "*Quick commands*\n• `/ask <message>` ask a question\n• `/clear` reset your session\n• `/help` show usage info",
690
+ },
691
+ },
692
+ {
693
+ type: "section",
694
+ text: {
695
+ type: "mrkdwn",
696
+ text: "*Shortcuts*\n• `Ask OpenPalm` (global shortcut)\n• `Ask OpenPalm about this message` (message shortcut)",
697
+ },
698
+ },
699
+ ],
700
+ },
701
+ });
702
+ } catch (error) {
703
+ const errMsg = error instanceof Error ? error.message : String(error);
704
+ log.error("app_home_publish_error", {
705
+ userId: event.user,
706
+ error: errMsg,
707
+ });
708
+ }
709
+ }
710
+
711
+ private async onAskCommand(
712
+ command: SlashCommand,
713
+ say: SayFn,
714
+ client: SlackClient,
715
+ ): Promise<void> {
716
+ const text = command.text?.trim();
717
+ if (!text) {
718
+ await say({ text: "Usage: `/ask <message>`" });
719
+ return;
720
+ }
721
+
722
+ const userInfo: UserInfo = {
723
+ userId: command.user_id,
724
+ teamId: command.team_id,
725
+ channelId: command.channel_id,
726
+ username: command.user_name,
727
+ };
728
+
729
+ const permResult = checkPermissions(this.permissions, userInfo);
730
+ if (!permResult.allowed) {
731
+ await say({ text: "You do not have permission to use this bot." });
732
+ return;
733
+ }
734
+
735
+ const sessionKey = `slack:channel:${command.channel_id}:user:${command.user_id}`;
736
+
737
+ await this.conversationQueue.runOrQueue(sessionKey, {
738
+ onQueued: async () => {
739
+ await say({ text: "Queued. I will pick this up next." });
740
+ },
741
+ run: async () => {
742
+ // Post initial "thinking" message
743
+ const thinkingResult = await client.chat.postMessage({
744
+ channel: command.channel_id,
745
+ text: `:hourglass: Processing your request...`,
746
+ });
747
+ const thinkingTs = thinkingResult.ts;
748
+
749
+ try {
750
+ const resp = await this.forward({
751
+ userId: `slack:${userInfo.userId}`,
752
+ text,
753
+ metadata: {
754
+ teamId: userInfo.teamId,
755
+ username: userInfo.username,
756
+ command: "ask",
757
+ channelId: command.channel_id,
758
+ sessionKey,
759
+ },
760
+ }, undefined, this.forwardTimeoutMs);
761
+ if (!resp.ok) throw new Error(`Guardian returned status ${resp.status}`);
762
+ const { answer = "No response received." } = await resp.json() as { answer?: string };
763
+
764
+ // Replace thinking message with answer
765
+ const chunks = splitMessage(answer, MAX_MESSAGE_LENGTH);
766
+ const firstChunk = chunks[0] ?? "No response received.";
767
+ if (thinkingTs) {
768
+ await client.chat.update({
769
+ channel: command.channel_id,
770
+ ts: thinkingTs,
771
+ text: firstChunk,
772
+ });
773
+ }
774
+ // Thread follow-up chunks under the initial message
775
+ for (let i = 1; i < chunks.length; i++) {
776
+ await client.chat.postMessage({
777
+ channel: command.channel_id,
778
+ text: chunks[i],
779
+ thread_ts: thinkingTs,
780
+ });
781
+ }
782
+
783
+ log.info("command_completed", {
784
+ command: "ask",
785
+ userId: userInfo.userId,
786
+ channelId: command.channel_id,
787
+ sessionKey,
788
+ });
789
+ } catch (error) {
790
+ const errMsg = error instanceof Error ? error.message : String(error);
791
+ log.error("command_error", { command: "ask", error: errMsg, sessionKey });
792
+ if (thinkingTs) {
793
+ await client.chat.update({
794
+ channel: command.channel_id,
795
+ ts: thinkingTs,
796
+ text: `Error: ${errMsg}`,
797
+ });
798
+ }
799
+ }
800
+ },
801
+ });
802
+ }
803
+
804
+ private async onClearCommand(
805
+ command: SlashCommand,
806
+ say: SayFn,
807
+ ): Promise<void> {
808
+ const userInfo: UserInfo = {
809
+ userId: command.user_id,
810
+ teamId: command.team_id,
811
+ channelId: command.channel_id,
812
+ username: command.user_name,
813
+ };
814
+
815
+ const permResult = checkPermissions(this.permissions, userInfo);
816
+ if (!permResult.allowed) {
817
+ await say({ text: "You do not have permission to use this bot." });
818
+ return;
819
+ }
820
+
821
+ const sessionKey = `slack:channel:${command.channel_id}:user:${command.user_id}`;
822
+
823
+ try {
824
+ // Use this.forward directly — clear should not throw, we handle resp.ok manually
825
+ const resp = await this.forward({
826
+ userId: `slack:${userInfo.userId}`,
827
+ text: "clear session",
828
+ metadata: {
829
+ command: "clear",
830
+ channelId: command.channel_id,
831
+ teamId: userInfo.teamId,
832
+ username: userInfo.username,
833
+ sessionKey,
834
+ clearSession: true,
835
+ },
836
+ }, undefined, this.forwardTimeoutMs);
837
+
838
+ if (!resp.ok) {
839
+ await say({ text: "Could not clear this conversation right now." });
840
+ return;
841
+ }
842
+
843
+ const droppedQueued = this.conversationQueue.clear(sessionKey);
844
+ await say({
845
+ text: droppedQueued > 0
846
+ ? "Conversation cleared. Dropped queued follow-ups."
847
+ : "Conversation cleared.",
848
+ });
849
+ } catch (error) {
850
+ const errMsg = error instanceof Error ? error.message : String(error);
851
+ log.error("clear_error", { error: errMsg, sessionKey, userId: userInfo.userId });
852
+ await say({ text: "Could not clear this conversation right now." });
853
+ }
854
+ }
855
+
856
+ private async onHelpCommand(
857
+ command: SlashCommand,
858
+ say: SayFn,
859
+ ): Promise<void> {
860
+ const permResult = checkPermissions(this.permissions, {
861
+ userId: command.user_id,
862
+ teamId: command.team_id,
863
+ channelId: command.channel_id,
864
+ username: command.user_name,
865
+ });
866
+ if (!permResult.allowed) {
867
+ await say({ text: "You do not have permission to use this bot." });
868
+ return;
869
+ }
870
+
871
+ const lines = [
872
+ "*Available Commands:*\n",
873
+ "`/ask <message>` — Send a message to the assistant",
874
+ "`/clear` — Start a fresh conversation (clears session context)",
875
+ "`/help` — Show this help message",
876
+ "\nYou can also mention me in any channel or send me a DM to start a conversation.",
877
+ ];
878
+ await say({ text: lines.join("\n") });
879
+ }
880
+
881
+ // ── Conversation Runner ───────────────────────────────────────────────
882
+
883
+ private async runConversation(
884
+ client: SlackClient,
885
+ channel: string,
886
+ threadTs: string,
887
+ userInfo: UserInfo,
888
+ text: string,
889
+ sessionKey: string,
890
+ ): Promise<void> {
891
+ // Rich-UX streaming path (opt-in, Stage 5). Renders deltas + Block Kit tool
892
+ // status + interactive permission prompts live via the guardian /oc/* proxy.
893
+ // The conversationQueue's run() promise settles when streamTurn resolves at
894
+ // turn-end (session idle), keeping per-sessionKey serialization intact.
895
+ if (this.streamingEnabled) {
896
+ try {
897
+ await streamTurn({
898
+ client: this.ocClient,
899
+ registry: this.permissionRegistry,
900
+ slack: client as unknown as StreamSlackClient,
901
+ userId: `slack:${userInfo.userId}`,
902
+ requestingUserId: userInfo.userId,
903
+ channel,
904
+ threadTs,
905
+ sessionKey,
906
+ text,
907
+ });
908
+ log.info("stream_completed", { userId: userInfo.userId, channelId: channel, threadTs, sessionKey });
909
+ } catch (error) {
910
+ const errMsg = error instanceof Error ? error.message : String(error);
911
+ log.error("stream_error", { error: errMsg, userId: userInfo.userId, sessionKey });
912
+ await client.chat.postMessage({ channel, text: `Error: ${errMsg}`, thread_ts: threadTs }).catch(() => {});
913
+ }
914
+ return;
915
+ }
916
+
917
+ // Post a visible "thinking" message in the thread
918
+ let thinkingTs: string | undefined;
919
+ try {
920
+ const result = await client.chat.postMessage({
921
+ channel,
922
+ text: `:hourglass: Processing your request...`,
923
+ thread_ts: threadTs,
924
+ });
925
+ thinkingTs = result.ts;
926
+ } catch {
927
+ // Best-effort indicator; continue even if it fails
928
+ }
929
+
930
+ try {
931
+ const resp = await this.forward({
932
+ userId: `slack:${userInfo.userId}`,
933
+ text,
934
+ metadata: {
935
+ teamId: userInfo.teamId,
936
+ username: userInfo.username,
937
+ channelId: channel,
938
+ sessionKey,
939
+ },
940
+ }, undefined, this.forwardTimeoutMs);
941
+ if (!resp.ok) throw new Error(`Guardian returned status ${resp.status}`);
942
+ const { answer = "No response received." } = await resp.json() as { answer?: string };
943
+
944
+ // Replace thinking message with first chunk, post remaining as follow-ups
945
+ const chunks = splitMessage(answer, MAX_MESSAGE_LENGTH);
946
+ const firstChunk = chunks[0] ?? "No response received.";
947
+
948
+ if (thinkingTs) {
949
+ try {
950
+ await client.chat.update({ channel, ts: thinkingTs, text: firstChunk });
951
+ } catch {
952
+ // If update fails, just post as new message
953
+ await client.chat.postMessage({ channel, text: firstChunk, thread_ts: threadTs });
954
+ }
955
+ } else {
956
+ await client.chat.postMessage({ channel, text: firstChunk, thread_ts: threadTs });
957
+ }
958
+
959
+ for (let i = 1; i < chunks.length; i++) {
960
+ await client.chat.postMessage({ channel, text: chunks[i], thread_ts: threadTs });
961
+ }
962
+
963
+ log.info("message_completed", {
964
+ userId: userInfo.userId,
965
+ channelId: channel,
966
+ threadTs,
967
+ sessionKey,
968
+ });
969
+ } catch (error) {
970
+ const errMsg = error instanceof Error ? error.message : String(error);
971
+ log.error("message_error", { error: errMsg, userId: userInfo.userId, sessionKey });
972
+
973
+ // Replace thinking message with error, or post error as new message
974
+ if (thinkingTs) {
975
+ try {
976
+ await client.chat.update({ channel, ts: thinkingTs, text: `Error: ${errMsg}` });
977
+ return;
978
+ } catch {
979
+ // fall through to post as new message
980
+ }
981
+ }
982
+ await client.chat.postMessage({ channel, text: `Error: ${errMsg}`, thread_ts: threadTs });
983
+ }
984
+ }
985
+
986
+ // ── Rich-UX interactions (Block Kit buttons → guardian /oc reply) ─────────
987
+
988
+ private actionFirstValue(body: BlockActionBody): string | undefined {
989
+ const action = body.actions?.[0];
990
+ return typeof action?.value === "string" ? action.value : undefined;
991
+ }
992
+
993
+ private async onPermissionAction(
994
+ actionId: string,
995
+ body: BlockActionBody,
996
+ client: SlackClient,
997
+ ): Promise<void> {
998
+ const requestID = this.actionFirstValue(body);
999
+ if (!requestID) return;
1000
+ const clicker = body.user?.id ?? "";
1001
+ const outcome = await this.permissionRegistry.handlePermissionClick(requestID, actionId, clicker);
1002
+ if (!outcome) {
1003
+ // Unknown/expired request OR a non-requester clicked — refuse quietly.
1004
+ log.warn("permission_action_unauthorized_or_unknown", { requestID, actionId, clicker });
1005
+ return;
1006
+ }
1007
+ try {
1008
+ await client.chat.update({ channel: outcome.channel, ts: outcome.ts, text: outcome.text });
1009
+ } catch (error) {
1010
+ log.warn("permission_action_update_failed", { error: error instanceof Error ? error.message : String(error), requestID });
1011
+ }
1012
+ }
1013
+
1014
+ private async onStopAction(body: BlockActionBody, _client: SlackClient): Promise<void> {
1015
+ const sessionId = this.actionFirstValue(body);
1016
+ if (!sessionId) return;
1017
+ const clicker = body.user?.id ?? "";
1018
+ const handled = await this.permissionRegistry.handleStopClick(sessionId, clicker);
1019
+ if (!handled) {
1020
+ log.warn("stop_action_unauthorized_or_unknown", { sessionId, clicker });
1021
+ }
1022
+ }
1023
+
1024
+ // ── Utilities ─────────────────────────────────────────────────────────
1025
+
1026
+ private stripMention(text: string): string {
1027
+ if (!this.botUserId) return text;
1028
+ return text.replace(new RegExp(`<@${this.botUserId}>`, "g"), "").trim();
1029
+ }
1030
+
1031
+ /**
1032
+ * Resolve a Slack user ID to a display name, with caching.
1033
+ * Falls back to the user ID itself if the API call fails.
1034
+ */
1035
+ private async resolveUsername(userId: string, client: SlackClient): Promise<string> {
1036
+ const cached = this.usernameCache.get(userId);
1037
+ if (cached) return cached;
1038
+
1039
+ try {
1040
+ const result = await client.users.info({ user: userId });
1041
+ const name = result.user?.name ?? result.user?.real_name ?? userId;
1042
+ this.usernameCache.set(userId, name);
1043
+ return name;
1044
+ } catch {
1045
+ return userId;
1046
+ }
1047
+ }
1048
+
1049
+ private async extractUserInfo(event: GenericMessageEvent, client: SlackClient): Promise<UserInfo> {
1050
+ const rawTeam = (event as Record<string, unknown>).team;
1051
+ const username = await this.resolveUsername(event.user, client);
1052
+ return {
1053
+ userId: event.user,
1054
+ teamId: typeof rawTeam === "string" ? rawTeam : "",
1055
+ channelId: event.channel,
1056
+ username,
1057
+ };
1058
+ }
1059
+ }
1060
+
1061
+ async function collectTurnAnswer(client: OcClient, userId: string, sessionId: string, signal: AbortSignal): Promise<string> {
1062
+ const reasoningPartIds = new Set<string>();
1063
+ let answer = '';
1064
+ for await (const event of client.events(userId, signal)) {
1065
+ const raw = asRaw(event);
1066
+ const snapshot = partSnapshotType(raw);
1067
+ if (snapshot?.type === 'reasoning') reasoningPartIds.add(snapshot.partID);
1068
+ const delta = extractTextDelta(raw, sessionId, reasoningPartIds);
1069
+ if (delta) answer += delta;
1070
+ if (isTurnEnd(raw, sessionId)) break;
1071
+ }
1072
+ return answer || '(no response)';
1073
+ }
1074
+
1075
+ export { DEFAULT_FORWARD_TIMEOUT_MS, parseForwardTimeoutMs };
1076
+
1077
+ // ── Type shorthands for Slack Bolt ────────────────────────────────────────
1078
+ // Minimal subsets of the Bolt WebClient — only the methods this adapter uses.
1079
+ // The full Bolt client (this.app.client) has additional methods like auth.test
1080
+ // that are called directly on the Bolt instance, not through this type.
1081
+
1082
+ type SayFn = (msg: string | { text: string; thread_ts?: string }) => Promise<unknown>;
1083
+
1084
+ type SlackClient = {
1085
+ chat: {
1086
+ postMessage: (args: { channel: string; text: string; thread_ts?: string }) => Promise<{ ts?: string }>;
1087
+ update: (args: { channel: string; ts: string; text: string }) => Promise<unknown>;
1088
+ };
1089
+ conversations: {
1090
+ open: (args: { users: string }) => Promise<{ channel?: { id?: string } }>;
1091
+ };
1092
+ users: {
1093
+ info: (args: { user: string }) => Promise<{ user?: { name?: string; real_name?: string } }>;
1094
+ };
1095
+ views: {
1096
+ open: (args: { trigger_id: string; view: SlackViewDefinition }) => Promise<unknown>;
1097
+ publish: (args: { user_id: string; view: HomeViewDefinition }) => Promise<unknown>;
1098
+ };
1099
+ };
1100
+
1101
+ type SlashCommand = {
1102
+ text: string;
1103
+ user_id: string;
1104
+ user_name: string;
1105
+ team_id: string;
1106
+ channel_id: string;
1107
+ };
1108
+
1109
+ type GlobalShortcut = {
1110
+ trigger_id: string;
1111
+ user: { id: string; username?: string; name?: string };
1112
+ team?: { id?: string };
1113
+ };
1114
+
1115
+ type MessageShortcut = GlobalShortcut & {
1116
+ channel: { id: string };
1117
+ message: { ts: string; text?: string };
1118
+ };
1119
+
1120
+ type ModalMetadata = {
1121
+ source: "global-shortcut" | "message-shortcut";
1122
+ channelId?: string;
1123
+ threadTs?: string;
1124
+ teamId?: string;
1125
+ };
1126
+
1127
+ type ModalView = {
1128
+ private_metadata?: string;
1129
+ state: {
1130
+ values: Record<string, Record<string, { value?: string }>>;
1131
+ };
1132
+ };
1133
+
1134
+ type ViewSubmissionBody = {
1135
+ user: { id: string; username?: string; name?: string };
1136
+ team?: { id?: string };
1137
+ };
1138
+
1139
+ type BlockActionBody = {
1140
+ user?: { id?: string };
1141
+ actions?: Array<{ action_id?: string; value?: string }>;
1142
+ };
1143
+
1144
+ type SlackViewDefinition = {
1145
+ type: "modal";
1146
+ callback_id: string;
1147
+ private_metadata: string;
1148
+ title: { type: "plain_text"; text: string };
1149
+ submit: { type: "plain_text"; text: string };
1150
+ close: { type: "plain_text"; text: string };
1151
+ blocks: Array<{
1152
+ type: "input";
1153
+ block_id: string;
1154
+ label: { type: "plain_text"; text: string };
1155
+ element: {
1156
+ type: "plain_text_input";
1157
+ action_id: string;
1158
+ multiline: boolean;
1159
+ initial_value: string;
1160
+ };
1161
+ }>;
1162
+ };
1163
+
1164
+ type HomeViewDefinition = {
1165
+ type: "home";
1166
+ blocks: Array<{
1167
+ type: "header" | "section";
1168
+ text: {
1169
+ type: "plain_text" | "mrkdwn";
1170
+ text: string;
1171
+ };
1172
+ }>;
1173
+ };
1174
+
1175
+ type AppHomeOpenedEvent = {
1176
+ user: string;
1177
+ };