From c25c79196f15365b09429df21950a5972506cbbd Mon Sep 17 00:00:00 2001 From: Eduard Kuzhyr <230987025+Eduard-Kuzhyr@users.noreply.github.com> Date: Sat, 23 May 2026 17:02:04 +0200 Subject: [PATCH 1/4] feat: add 'messages command' for slash command capture Adds a browser-auth subcommand that executes Slack slash commands via the chat.command endpoint and captures the ephemeral reply over the MS WebSocket. Splits the implementation across focused modules: - block-renderer: block-kit + legacy-attachment renderer extracted from formatter so slash-command replies render in the terminal - ephemeral-capture: pure helpers for matching frames and converting payloads to SlackMessage - slash-command-runner: socket lifecycle orchestrator with single-cleanup on every settle path (success, timeout, error, invoke rejection) - recipient: resolveRecipientChannel helper used by send, draft, and command (kills 3x duplication) Also: validates --timeout input, parallelises captured-message user-info lookups via Promise.allSettled, and replaces the hardcoded UA version string with __APP_VERSION__. Refs #50 --- .gitignore | 2 + README.md | 14 ++ src/commands/messages.ts | 112 ++++++++++++-- src/lib/block-renderer.test.ts | 161 ++++++++++++++++++++ src/lib/block-renderer.ts | 130 ++++++++++++++++ src/lib/ephemeral-capture.test.ts | 163 ++++++++++++++++++++ src/lib/ephemeral-capture.ts | 63 ++++++++ src/lib/formatter.ts | 13 ++ src/lib/recipient.test.ts | 40 +++++ src/lib/recipient.ts | 14 ++ src/lib/slack-client.test.ts | 116 +++++++++++++++ src/lib/slack-client.ts | 53 +++++++ src/lib/slash-command-runner.test.ts | 213 +++++++++++++++++++++++++++ src/lib/slash-command-runner.ts | 103 +++++++++++++ src/types/index.ts | 13 ++ 15 files changed, 1196 insertions(+), 14 deletions(-) create mode 100644 src/lib/block-renderer.test.ts create mode 100644 src/lib/block-renderer.ts create mode 100644 src/lib/ephemeral-capture.test.ts create mode 100644 src/lib/ephemeral-capture.ts create mode 100644 src/lib/recipient.test.ts create mode 100644 src/lib/recipient.ts create mode 100644 src/lib/slash-command-runner.test.ts create mode 100644 src/lib/slash-command-runner.ts diff --git a/.gitignore b/.gitignore index 9630977..bcbb436 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ bun.lockb # Build output dist/ *.tsbuildinfo +*.bun-build # IDE .idea/ @@ -53,3 +54,4 @@ docs/internal/ secrets/ credentials/ *.secret +/.claude/settings.local.json diff --git a/README.md b/README.md index 4d65ab5..0ff0b10 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,13 @@ slackcli conversations read C1234567890 --limit=50 slackcli conversations read C1234567890 --json ``` +> **Note on "Only visible to you" messages:** Slack does *not* persist +> ephemeral messages (the "Only visible to you" replies from bots and +> slash commands) server-side, so `conversations read` cannot retrieve +> them after the fact. To trigger a slash command and capture its +> ephemeral reply live, use `slackcli messages command` (browser auth +> only). + ### Message Commands ```bash @@ -214,6 +221,13 @@ slackcli messages react --channel-id=C1234567890 --timestamp=1234567890.123456 - slackcli messages react --channel-id=C1234567890 --timestamp=1234567890.123456 --emoji=heart slackcli messages react --channel-id=C1234567890 --timestamp=1234567890.123456 --emoji=fire slackcli messages react --channel-id=C1234567890 --timestamp=1234567890.123456 --emoji=eyes + +# Execute a slash command and capture its ephemeral ("Only visible to you") reply. +# Requires browser auth (xoxc/xoxd). Default timeout is 15 seconds. +slackcli messages command --recipient-id=D0123456789 --command=/genie --text="help" + +# Customize timeout and emit JSON +slackcli messages command --recipient-id=C1234567890 --command=/giphy --text="cats" --timeout=30 --json ``` File uploads require Slack workspace permissions that allow file upload, such as `files:write` for standard Slack app tokens. diff --git a/src/commands/messages.ts b/src/commands/messages.ts index 5158ccd..fce5fb0 100644 --- a/src/commands/messages.ts +++ b/src/commands/messages.ts @@ -1,7 +1,10 @@ import { Command } from 'commander'; import ora from 'ora'; import { getAuthenticatedClient } from '../lib/auth.ts'; -import { success, error } from '../lib/formatter.ts'; +import { success, error, formatMessage } from '../lib/formatter.ts'; +import { runSlashCommand } from '../lib/slash-command-runner.ts'; +import { resolveRecipientChannel } from '../lib/recipient.ts'; +import type { SlackUser } from '../types/index.ts'; export function createMessagesCommand(): Command { const messages = new Command('messages') @@ -22,13 +25,8 @@ export function createMessagesCommand(): Command { try { const client = await getAuthenticatedClient(options.workspace); - // Check if recipient is a user ID (starts with U) and needs DM opened - let channelId = options.recipientId; - if (options.recipientId.startsWith('U')) { - spinner.text = 'Opening direct message...'; - const dmResponse = await client.openConversation(options.recipientId); - channelId = dmResponse.channel.id; - } + if (options.recipientId.startsWith('U')) spinner.text = 'Opening direct message...'; + const channelId = await resolveRecipientChannel(client, options.recipientId); spinner.text = 'Sending message...'; if (options.file) { @@ -94,12 +92,8 @@ export function createMessagesCommand(): Command { try { const client = await getAuthenticatedClient(options.workspace); - let channelId = options.recipientId; - if (options.recipientId.startsWith('U')) { - spinner.text = 'Opening direct message...'; - const dmResponse = await client.openConversation(options.recipientId); - channelId = dmResponse.channel.id; - } + if (options.recipientId.startsWith('U')) spinner.text = 'Opening direct message...'; + const channelId = await resolveRecipientChannel(client, options.recipientId); spinner.text = 'Creating draft...'; const response = await client.createDraft(channelId, options.message, { @@ -115,5 +109,95 @@ export function createMessagesCommand(): Command { } }); + // Execute slash command and capture ephemeral reply + messages + .command('command') + .description('Execute a slash command and capture the ephemeral reply (browser auth only)') + .requiredOption('--recipient-id ', 'Channel ID or User ID where command is issued') + .requiredOption('--command ', 'Slash command including leading slash, e.g. /genie') + .option('--text ', 'Argument text for the command', '') + .option('--timeout ', 'Seconds to wait for the ephemeral reply', '15') + .option('--workspace ', 'Workspace to use') + .option('--json', 'Output captured events as JSON', false) + .action(async (options) => { + const spinner = ora('Preparing command...').start(); + + try { + const client = await getAuthenticatedClient(options.workspace); + + if (client.authType !== 'browser') { + spinner.fail('Slash command execution requires browser authentication'); + error('Use `slackcli auth login-browser` to add a browser-auth workspace.'); + process.exit(1); + } + + if (options.recipientId.startsWith('U')) spinner.text = 'Opening direct message...'; + const channelId = await resolveRecipientChannel(client, options.recipientId); + + const timeoutSeconds = parseInt(options.timeout, 10); + if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 1) { + spinner.fail(`Invalid --timeout value: ${options.timeout}`); + error('--timeout must be a positive integer (seconds).'); + process.exit(1); + } + + spinner.text = 'Connecting to Slack real-time gateway...'; + const { url, headers, self } = await client.rtmConnect(); + + const clientToken = crypto.randomUUID(); + + spinner.text = `Executing ${options.command}...`; + const { messages: captured, timedOut } = await runSlashCommand({ + rtm: { url, headers, selfUserId: self?.id }, + channelId, + clientToken, + timeoutMs: timeoutSeconds * 1000, + invokeCommand: () => client.executeSlashCommand( + channelId, + options.command, + options.text || '', + clientToken, + ), + }); + + spinner.succeed( + timedOut + ? `Timed out after ${timeoutSeconds}s — no ephemeral reply received` + : `Captured ${captured.length} ephemeral event(s)`, + ); + + if (options.json) { + console.log(JSON.stringify({ + channel_id: channelId, + command: options.command, + text: options.text || '', + timed_out: timedOut, + messages: captured, + }, null, 2)); + return; + } + + if (captured.length === 0) { + error('No ephemeral reply captured. The command may reply asynchronously beyond the timeout window, or it may not respond ephemerally.'); + process.exit(2); + } + + const userIds = [...new Set(captured.map(m => m.user).filter((u): u is string => !!u))]; + const users = new Map(); + await Promise.allSettled(userIds.map(async (id) => { + const resp = await client.getUserInfo(id); + if (resp?.user) users.set(resp.user.id, resp.user); + })); + + for (const msg of captured) { + console.log(formatMessage(msg, users)); + } + } catch (err: any) { + spinner.fail('Failed to execute slash command'); + error(err.message); + process.exit(1); + } + }); + return messages; } diff --git a/src/lib/block-renderer.test.ts b/src/lib/block-renderer.test.ts new file mode 100644 index 0000000..db83098 --- /dev/null +++ b/src/lib/block-renderer.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'bun:test'; +import { renderBlocks, renderAttachments } from './block-renderer.ts'; + +const stripAnsi = (s: string) => s.replace(/\[[0-9;]*m/g, ''); + +describe('renderBlocks', () => { + it('renders header text', () => { + const out = stripAnsi(renderBlocks([{ type: 'header', text: { text: 'Hello' } }], 0)); + expect(out).toBe('Hello\n'); + }); + + it('skips header without text', () => { + expect(renderBlocks([{ type: 'header' }], 0)).toBe(''); + }); + + it('renders section text preserving newlines and indents', () => { + const out = stripAnsi(renderBlocks([{ type: 'section', text: { text: 'a\nb' } }], 2)); + expect(out).toBe(' a\n b\n'); + }); + + it('renders section fields as bullets and flattens internal newlines', () => { + const out = stripAnsi(renderBlocks([ + { type: 'section', fields: [{ text: 'one' }, { text: 'two\nlines' }] }, + ], 0)); + expect(out).toBe(' • one\n • two lines\n'); + }); + + it('renders context elements joined with space', () => { + const out = stripAnsi(renderBlocks([ + { type: 'context', elements: [{ text: 'foo' }, { alt_text: 'bar' }, {}] }, + ], 0)); + expect(out).toBe('foo bar\n'); + }); + + it('skips empty context', () => { + expect(renderBlocks([{ type: 'context', elements: [] }], 0)).toBe(''); + }); + + it('renders divider', () => { + const out = stripAnsi(renderBlocks([{ type: 'divider' }], 0)); + expect(out).toBe('─────────\n'); + }); + + it('skips actions and image blocks', () => { + expect(renderBlocks([{ type: 'actions' }, { type: 'image' }], 0)).toBe(''); + }); + + it('falls back to default renderer when block.type is unknown', () => { + const out = stripAnsi(renderBlocks([{ type: 'mystery', text: { text: 'x' } }], 0)); + expect(out).toBe('x\n'); + }); + + it('renders rich_text plain text + link + user + channel + emoji', () => { + const out = stripAnsi(renderBlocks([{ + type: 'rich_text', + elements: [{ + type: 'rich_text_section', + elements: [ + { type: 'text', text: 'hi ' }, + { type: 'link', text: 'site', url: 'https://x' }, + { type: 'text', text: ' ' }, + { type: 'user', user_id: 'U1' }, + { type: 'text', text: ' ' }, + { type: 'channel', channel_id: 'C1' }, + { type: 'text', text: ' ' }, + { type: 'emoji', name: 'wave' }, + ], + }], + }], 0)); + // Trailing blank line is from rich_text_section appending \n, then the + // outer per-line loop emitting one more newline for the empty tail. + expect(out).toBe('hi site <@U1> <#C1> :wave:\n\n'); + }); + + it('renders link url when text is missing', () => { + const out = stripAnsi(renderBlocks([{ + type: 'rich_text', + elements: [{ + type: 'rich_text_section', + elements: [{ type: 'link', url: 'https://x' }], + }], + }], 0)); + expect(out).toBe('https://x\n\n'); + }); + + it('renders rich_text_list as bullets', () => { + const out = stripAnsi(renderBlocks([{ + type: 'rich_text', + elements: [{ + type: 'rich_text_list', + elements: [ + { type: 'rich_text_section', elements: [{ type: 'text', text: 'one' }] }, + { type: 'rich_text_section', elements: [{ type: 'text', text: 'two' }] }, + ], + }], + }], 0)); + expect(out).toBe('• one\n• two\n\n'); + }); + + it('renders rich_text_quote with leading >', () => { + const out = stripAnsi(renderBlocks([{ + type: 'rich_text', + elements: [{ + type: 'rich_text_quote', + elements: [{ type: 'rich_text_section', elements: [{ type: 'text', text: 'q' }] }], + }], + }], 0)); + expect(out).toBe('> q\n\n'); + }); + + it('renders rich_text_preformatted as code fence', () => { + const out = stripAnsi(renderBlocks([{ + type: 'rich_text', + elements: [{ + type: 'rich_text_preformatted', + elements: [{ type: 'text', text: 'code' }], + }], + }], 0)); + expect(out).toBe('```\ncode\n```\n\n'); + }); +}); + +describe('renderAttachments', () => { + it('renders pretext, title, text, footer in order', () => { + const out = stripAnsi(renderAttachments([{ + pretext: 'P', + title: 'T', + text: 'body\nline2', + footer: 'F', + }], 0)); + expect(out).toBe('P\nT\nbody\nline2\nF\n'); + }); + + it('appends title_link in parens after title', () => { + const out = stripAnsi(renderAttachments([{ title: 'T', title_link: 'https://x' }], 0)); + expect(out).toBe('T (https://x)\n'); + }); + + it('renders fields with title: value and indents', () => { + const out = stripAnsi(renderAttachments([{ + fields: [ + { title: 'k', value: 'v' }, + { value: 'multi\nline' }, + { title: '', value: '' }, + ], + }], 0)); + expect(out).toBe(' k: v\n multi\n line\n'); + }); + + it('renders nested blocks at indent+2', () => { + const out = stripAnsi(renderAttachments([{ + blocks: [{ type: 'header', text: { text: 'H' } }], + }], 0)); + expect(out).toBe(' H\n'); + }); + + it('honours outer indent', () => { + const out = stripAnsi(renderAttachments([{ title: 'T' }], 4)); + expect(out).toBe(' T\n'); + }); +}); diff --git a/src/lib/block-renderer.ts b/src/lib/block-renderer.ts new file mode 100644 index 0000000..16a12d1 --- /dev/null +++ b/src/lib/block-renderer.ts @@ -0,0 +1,130 @@ +import chalk from 'chalk'; + +// Slack block-kit + legacy attachments renderer for terminal output. +// Recursive — rich_text blocks nest sections/lists/quotes/preformatted spans. + +export function renderBlocks(blocks: Array>, indent: number): string { + const indentStr = ' '.repeat(indent); + let out = ''; + + for (const block of blocks) { + switch (block.type) { + case 'header': + if (block.text?.text) { + out += `${indentStr}${chalk.bold(block.text.text)}\n`; + } + break; + case 'section': { + if (block.text?.text) { + for (const line of String(block.text.text).split('\n')) { + out += `${indentStr}${line}\n`; + } + } + if (Array.isArray(block.fields)) { + for (const f of block.fields) { + if (f?.text) out += `${indentStr} • ${String(f.text).replace(/\n/g, ' ')}\n`; + } + } + break; + } + case 'context': { + const parts = (block.elements || []) + .map((el: any) => el.text || el.alt_text || '') + .filter(Boolean); + if (parts.length) out += `${indentStr}${chalk.dim(parts.join(' '))}\n`; + break; + } + case 'divider': + out += `${indentStr}${chalk.dim('─────────')}\n`; + break; + case 'rich_text': { + const flat = flattenRichText(block.elements || []); + if (flat) { + for (const line of flat.split('\n')) { + out += `${indentStr}${line}\n`; + } + } + break; + } + case 'actions': + case 'image': + // Skip — interactive buttons / images don't render usefully in terminal + break; + default: + if (block.text?.text) out += `${indentStr}${block.text.text}\n`; + } + } + return out; +} + +function flattenRichText(elements: any[]): string { + let s = ''; + for (const el of elements) { + if (Array.isArray(el.elements)) { + const inner = flattenRichText(el.elements); + if (el.type === 'rich_text_list') { + for (const line of inner.split('\n')) { + if (line) s += `• ${line}\n`; + } + } else if (el.type === 'rich_text_quote') { + for (const line of inner.split('\n')) { + if (line) s += `> ${line}\n`; + } + } else if (el.type === 'rich_text_preformatted') { + s += '```\n' + inner + '\n```\n'; + } else { + s += inner; + if (el.type === 'rich_text_section') s += '\n'; + } + } else if (el.type === 'text') { + s += el.text || ''; + } else if (el.type === 'link') { + s += el.text || el.url || ''; + } else if (el.type === 'user') { + s += `<@${el.user_id}>`; + } else if (el.type === 'channel') { + s += `<#${el.channel_id}>`; + } else if (el.type === 'emoji') { + s += `:${el.name}:`; + } + } + return s; +} + +export function renderAttachments(attachments: Array>, indent: number): string { + const indentStr = ' '.repeat(indent); + let out = ''; + + for (const att of attachments) { + if (att.pretext) out += `${indentStr}${chalk.dim(String(att.pretext))}\n`; + if (att.title) { + const titleLine = att.title_link + ? `${chalk.bold(att.title)} ${chalk.dim(`(${att.title_link})`)}` + : chalk.bold(String(att.title)); + out += `${indentStr}${titleLine}\n`; + } + if (att.text) { + for (const line of String(att.text).split('\n')) { + out += `${indentStr}${line}\n`; + } + } + if (Array.isArray(att.fields)) { + for (const f of att.fields) { + const title = f?.title ? `${chalk.bold(f.title)}: ` : ''; + const value = f?.value != null ? String(f.value) : ''; + if (!title && !value) continue; + const lines = `${title}${value}`.split('\n'); + for (const line of lines) { + out += `${indentStr} ${line}\n`; + } + } + } + if (att.footer) { + out += `${indentStr}${chalk.dim(String(att.footer))}\n`; + } + if (Array.isArray(att.blocks) && att.blocks.length > 0) { + out += renderBlocks(att.blocks, indent + 2); + } + } + return out; +} diff --git a/src/lib/ephemeral-capture.test.ts b/src/lib/ephemeral-capture.test.ts new file mode 100644 index 0000000..dea5574 --- /dev/null +++ b/src/lib/ephemeral-capture.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'bun:test'; +import { matchesEphemeral, payloadToMessage, syncResponseToMessage } from './ephemeral-capture.ts'; + +const CH = 'C123'; +const TOKEN = 'tok-abc'; +const SELF = 'U999'; + +describe('matchesEphemeral', () => { + it('matches when is_ephemeral=true and channel matches', () => { + expect(matchesEphemeral( + { type: 'message', channel: CH, is_ephemeral: true, text: 'hi' }, + CH, TOKEN, + )).toBe(true); + }); + + it('matches when subtype is ephemeral', () => { + expect(matchesEphemeral( + { type: 'message', channel: CH, subtype: 'ephemeral', text: 'hi' }, + CH, TOKEN, + )).toBe(true); + }); + + it('matches when client_msg_id correlates with our token', () => { + expect(matchesEphemeral( + { type: 'message', channel: CH, client_msg_id: TOKEN, text: 'hi' }, + CH, TOKEN, + )).toBe(true); + }); + + it('matches when payload targets self user with is_ephemeral', () => { + expect(matchesEphemeral( + { type: 'message', channel: CH, user: SELF, is_ephemeral: true }, + CH, TOKEN, SELF, + )).toBe(true); + }); + + it('rejects payloads from a different channel', () => { + expect(matchesEphemeral( + { type: 'message', channel: 'C_OTHER', is_ephemeral: true, text: 'hi' }, + CH, TOKEN, + )).toBe(false); + }); + + it('rejects non-message types', () => { + for (const type of ['hello', 'pong', 'reconnect_url', 'presence_change']) { + expect(matchesEphemeral( + { type, channel: CH, is_ephemeral: true }, + CH, TOKEN, + )).toBe(false); + } + }); + + it('rejects message in correct channel without ephemeral signal', () => { + expect(matchesEphemeral( + { type: 'message', channel: CH, text: 'normal post', user: 'U_OTHER' }, + CH, TOKEN, SELF, + )).toBe(false); + }); + + it('does not correlate when clientToken is empty', () => { + expect(matchesEphemeral( + { type: 'message', channel: CH, client_msg_id: '' }, + CH, '', + )).toBe(false); + }); + + it('rejects null/undefined payloads', () => { + expect(matchesEphemeral(null, CH, TOKEN)).toBe(false); + expect(matchesEphemeral(undefined, CH, TOKEN)).toBe(false); + }); +}); + +describe('payloadToMessage', () => { + it('copies common fields and marks the message ephemeral', () => { + const payload = { + type: 'message', + subtype: 'bot_message', + user: 'U1', + bot_id: 'B1', + text: 'hello', + ts: '1.1', + thread_ts: '1.0', + blocks: [{ type: 'section' }], + attachments: [{ color: '#fff' }], + files: [{ id: 'F1' }], + client_msg_id: 'msg-1', + channel: CH, + }; + const msg = payloadToMessage(payload); + expect(msg.type).toBe('message'); + expect(msg.subtype).toBe('bot_message'); + expect(msg.user).toBe('U1'); + expect(msg.bot_id).toBe('B1'); + expect(msg.text).toBe('hello'); + expect(msg.ts).toBe('1.1'); + expect(msg.thread_ts).toBe('1.0'); + expect(msg.blocks).toEqual([{ type: 'section' }]); + expect(msg.attachments).toEqual([{ color: '#fff' }]); + expect(msg.files).toEqual([{ id: 'F1' } as any]); + expect(msg.client_msg_id).toBe('msg-1'); + expect(msg.channel).toBe(CH); + expect(msg.is_ephemeral).toBe(true); + }); + + it('falls back text to empty string when missing', () => { + const msg = payloadToMessage({ type: 'message', channel: CH, ts: '1.1' }); + expect(msg.text).toBe(''); + expect(msg.is_ephemeral).toBe(true); + }); + + it('synthesizes ts when missing', () => { + const msg = payloadToMessage({ type: 'message', channel: CH }); + expect(msg.ts).toBeDefined(); + expect(msg.ts.length).toBeGreaterThan(0); + }); +}); + +describe('syncResponseToMessage', () => { + it('synthesizes from response.text', () => { + const msg = syncResponseToMessage( + { ok: true, response: { text: 'sync ok', blocks: [{ type: 'section' }] } }, + CH, + ); + expect(msg).not.toBeNull(); + expect(msg!.text).toBe('sync ok'); + expect(msg!.blocks).toEqual([{ type: 'section' }]); + expect(msg!.is_ephemeral).toBe(true); + expect(msg!.channel).toBe(CH); + }); + + it('synthesizes from message.text', () => { + const msg = syncResponseToMessage( + { ok: true, message: { text: 'msg path', attachments: [{ a: 1 }] } }, + CH, + ); + expect(msg).not.toBeNull(); + expect(msg!.text).toBe('msg path'); + expect(msg!.attachments).toEqual([{ a: 1 }]); + }); + + it('returns null when neither response nor message present', () => { + expect(syncResponseToMessage({ ok: true }, CH)).toBeNull(); + }); + + it('returns null when both are present but empty', () => { + expect(syncResponseToMessage({ ok: true, response: {} }, CH)).toBeNull(); + }); + + it('returns null for null/undefined input', () => { + expect(syncResponseToMessage(null, CH)).toBeNull(); + expect(syncResponseToMessage(undefined, CH)).toBeNull(); + }); + + it('handles blocks-only response without text', () => { + const msg = syncResponseToMessage( + { response: { blocks: [{ type: 'section' }] } }, + CH, + ); + expect(msg).not.toBeNull(); + expect(msg!.text).toBe(''); + expect(msg!.blocks).toEqual([{ type: 'section' }]); + }); +}); diff --git a/src/lib/ephemeral-capture.ts b/src/lib/ephemeral-capture.ts new file mode 100644 index 0000000..204c209 --- /dev/null +++ b/src/lib/ephemeral-capture.ts @@ -0,0 +1,63 @@ +import type { SlackMessage } from '../types/index.ts'; + +// Returns true when an MS-socket frame represents an ephemeral message we +// should capture for the given channel + invocation correlation token. +export function matchesEphemeral( + payload: any, + channelId: string, + clientToken: string, + selfUserId?: string, +): boolean { + if (!payload || payload.type !== 'message') return false; + if (payload.channel !== channelId) return false; + + const isEphemeral = payload.is_ephemeral === true || payload.subtype === 'ephemeral'; + const correlated = clientToken !== '' && payload.client_msg_id === clientToken; + const directlyTargeted = !!selfUserId && payload.user === selfUserId && payload.is_ephemeral === true; + + return isEphemeral || correlated || directlyTargeted; +} + +// Converts a raw MS-socket frame into a SlackMessage, marking it ephemeral. +export function payloadToMessage(payload: any): SlackMessage { + return { + type: 'message', + subtype: payload.subtype, + user: payload.user, + bot_id: payload.bot_id, + text: payload.text || '', + ts: payload.ts || String(Date.now() / 1000), + thread_ts: payload.thread_ts, + blocks: payload.blocks, + attachments: payload.attachments, + files: payload.files, + is_ephemeral: true, + client_msg_id: payload.client_msg_id, + channel: payload.channel, + }; +} + +// When chat.command replies synchronously inside the HTTP body, build a +// synthetic SlackMessage from the response. Returns null if no reply body. +export function syncResponseToMessage(syncResp: any, channelId: string): SlackMessage | null { + if (!syncResp) return null; + + const body = syncResp.response || syncResp.message; + if (!body) return null; + + const text = body.text; + const blocks = body.blocks; + const attachments = body.attachments; + + if (!text && !blocks && !attachments) return null; + + return { + type: 'message', + text: text || '', + ts: String(Date.now() / 1000), + is_ephemeral: true, + channel: channelId, + blocks, + attachments, + }; +} diff --git a/src/lib/formatter.ts b/src/lib/formatter.ts index b21a096..e016470 100644 --- a/src/lib/formatter.ts +++ b/src/lib/formatter.ts @@ -3,6 +3,7 @@ import type { SlackCanvas, SlackChannel, SlackFile, SlackMessage, SlackUser, WorkspaceConfig, SavedItem, SearchMatch, ChannelSearchResult, PeopleSearchResult, UnreadChannel, } from '../types/index.ts'; +import { renderBlocks, renderAttachments } from './block-renderer.ts'; // Format timestamp to human-readable date export function formatTimestamp(ts: string): string { @@ -118,6 +119,18 @@ export function formatMessage( output += '\n'; } + // Blocks (rich layout payloads, used by many bots and slash commands) + if (msg.blocks && msg.blocks.length > 0) { + const blockText = renderBlocks(msg.blocks, indent + 2); + if (blockText) output += blockText; + } + + // Attachments (legacy bot/slash-command output) + if (msg.attachments && msg.attachments.length > 0) { + const attText = renderAttachments(msg.attachments, indent + 2); + if (attText) output += attText; + } + // Files if (msg.files && msg.files.length > 0) { msg.files.forEach(file => { diff --git a/src/lib/recipient.test.ts b/src/lib/recipient.test.ts new file mode 100644 index 0000000..a13097f --- /dev/null +++ b/src/lib/recipient.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'bun:test'; +import { resolveRecipientChannel } from './recipient.ts'; +import type { SlackClient } from './slack-client.ts'; + +function makeClient(stub: { + openConversation?: (u: string) => Promise; +}): { client: SlackClient; calls: string[] } { + const calls: string[] = []; + const client = { + openConversation: async (u: string) => { + calls.push(u); + return stub.openConversation ? stub.openConversation(u) : { channel: { id: `D-${u}` } }; + }, + } as unknown as SlackClient; + return { client, calls }; +} + +describe('resolveRecipientChannel', () => { + it('returns channel IDs unchanged', async () => { + const { client, calls } = makeClient({}); + expect(await resolveRecipientChannel(client, 'C123')).toBe('C123'); + expect(await resolveRecipientChannel(client, 'GAB12')).toBe('GAB12'); + expect(await resolveRecipientChannel(client, 'D9XY')).toBe('D9XY'); + expect(calls).toEqual([]); + }); + + it('opens a DM and returns the new channel id when recipientId starts with U', async () => { + const { client, calls } = makeClient({}); + const id = await resolveRecipientChannel(client, 'U777'); + expect(id).toBe('D-U777'); + expect(calls).toEqual(['U777']); + }); + + it('propagates errors from openConversation', async () => { + const { client } = makeClient({ + openConversation: async () => { throw new Error('user_not_found'); }, + }); + await expect(resolveRecipientChannel(client, 'U1')).rejects.toThrow('user_not_found'); + }); +}); diff --git a/src/lib/recipient.ts b/src/lib/recipient.ts new file mode 100644 index 0000000..a0f3c6b --- /dev/null +++ b/src/lib/recipient.ts @@ -0,0 +1,14 @@ +import type { SlackClient } from './slack-client.ts'; + +// If recipientId is a user ID (starts with U), open a DM channel and return +// its channel ID. Otherwise return recipientId unchanged. The DM-open round +// trip is what `messages send`, `messages draft`, and `messages command` +// each used to do inline. +export async function resolveRecipientChannel( + client: SlackClient, + recipientId: string, +): Promise { + if (!recipientId.startsWith('U')) return recipientId; + const dm = await client.openConversation(recipientId); + return dm.channel.id; +} diff --git a/src/lib/slack-client.test.ts b/src/lib/slack-client.test.ts index b84f9c8..bfdac69 100644 --- a/src/lib/slack-client.test.ts +++ b/src/lib/slack-client.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { SlackClient } from './slack-client.ts'; +import type { WorkspaceConfig } from '../types/index.ts'; class TestSlackClient extends SlackClient { public readonly calls: Array<{ method: string; params: Record }> = []; @@ -113,3 +114,118 @@ describe('SlackClient.uploadFileExternal', () => { expect(client.calls).toEqual([]); }); }); + +const browserConfig: WorkspaceConfig = { + workspace_id: 'T1', + workspace_name: 'browser-ws', + workspace_url: 'https://example.slack.com', + auth_type: 'browser', + xoxc_token: 'xoxc-test', + xoxd_token: 'xoxd-test', +}; + +const standardConfig: WorkspaceConfig = { + workspace_id: 'T2', + workspace_name: 'std-ws', + auth_type: 'standard', + token: 'xoxb-test', + token_type: 'bot', +}; + +// Helper: create a SlackClient and stub out `request` to capture calls. +function createBrowserClient(stubResponse: any = { ok: true }): { + client: SlackClient; + calls: Array<{ method: string; params: Record }>; +} { + const calls: Array<{ method: string; params: Record }> = []; + const client = new SlackClient(browserConfig); + (client as any).request = async (method: string, params: Record) => { + calls.push({ method, params }); + return stubResponse; + }; + return { client, calls }; +} + +describe('SlackClient.executeSlashCommand', () => { + it('throws on standard auth', async () => { + const client = new SlackClient(standardConfig); + await expect( + client.executeSlashCommand('C123', '/genie', 'help'), + ).rejects.toThrow('requires browser authentication'); + }); + + it('calls chat.command with required params', async () => { + const { client, calls } = createBrowserClient(); + await client.executeSlashCommand('C123', '/genie', 'help', 'fixed-token'); + + expect(calls.length).toBe(1); + expect(calls[0].method).toBe('chat.command'); + expect(calls[0].params).toEqual({ + channel: 'C123', + command: '/genie', + text: 'help', + disp: '/genie', + client_token: 'fixed-token', + }); + }); + + it('defaults text to empty string', async () => { + const { client, calls } = createBrowserClient(); + await client.executeSlashCommand('C123', '/genie', undefined as any, 'tok'); + expect(calls[0].params.text).toBe(''); + }); + + it('generates a UUID-shaped client_token when none supplied', async () => { + const { client, calls } = createBrowserClient(); + await client.executeSlashCommand('C123', '/genie', ''); + const ct = calls[0].params.client_token; + expect(typeof ct).toBe('string'); + // RFC 4122-ish: 8-4-4-4-12 hex + expect(ct).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + }); + + it('returns the underlying request response unchanged', async () => { + const { client } = createBrowserClient({ ok: true, response: { text: 'hi' } }); + const resp = await client.executeSlashCommand('C123', '/genie', '', 't'); + expect(resp).toEqual({ ok: true, response: { text: 'hi' } }); + }); +}); + +describe('SlackClient.rtmConnect', () => { + it('throws on standard auth', async () => { + const client = new SlackClient(standardConfig); + await expect(client.rtmConnect()).rejects.toThrow('rtm.connect'); + }); + + it('builds wss url containing the xoxc token', async () => { + const { client } = createBrowserClient({ + ok: true, + user_id: 'U999', + user: 'me', + }); + const resp = await client.rtmConnect(); + expect(resp.url.startsWith('wss://wss-primary.slack.com/?token=')).toBe(true); + expect(resp.url).toContain(encodeURIComponent('xoxc-test')); + }); + + it('returns headers with Cookie carrying the xoxd token', async () => { + const { client } = createBrowserClient({ ok: true, user_id: 'U1', user: 'me' }); + const resp = await client.rtmConnect(); + expect(resp.headers.Cookie).toBe(`d=${encodeURIComponent('xoxd-test')}`); + expect(resp.headers.Origin).toBe('https://app.slack.com'); + }); + + it('extracts self from auth.test response', async () => { + const { client } = createBrowserClient({ ok: true, user_id: 'U777', user: 'eduard' }); + const resp = await client.rtmConnect(); + expect(resp.self).toEqual({ id: 'U777', name: 'eduard' }); + }); + + it('calls auth.test (not rtm.connect) under the hood', async () => { + const { client, calls } = createBrowserClient({ ok: true, user_id: 'U1', user: 'me' }); + await client.rtmConnect(); + const methods = calls.map(c => c.method); + expect(methods).toContain('auth.test'); + expect(methods).not.toContain('rtm.connect'); + }); +}); diff --git a/src/lib/slack-client.ts b/src/lib/slack-client.ts index a734a22..16238bc 100644 --- a/src/lib/slack-client.ts +++ b/src/lib/slack-client.ts @@ -9,6 +9,9 @@ interface ExternalUploadUrlResponse { file_id?: string; } +// @ts-ignore - replaced at build time via --define __APP_VERSION__ +const APP_VERSION: string = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'; + export class SlackClient { private config: WorkspaceConfig; private webClient?: WebClient; @@ -457,6 +460,56 @@ export class SlackClient { return null; } + // Execute a slash command server-side (browser auth only). + // Slack does not expose a public chat.command API; this is the same + // undocumented endpoint the web/desktop client uses. + async executeSlashCommand(channel: string, command: string, text: string = '', clientToken?: string): Promise { + if (this.config.auth_type === 'standard') { + throw new Error('Slash command execution requires browser authentication (xoxc/xoxd)'); + } + + const params: Record = { + channel, + command, + text, + disp: command, + client_token: clientToken || crypto.randomUUID(), + }; + + return this.request('chat.command', params); + } + + // Build the Slack MS gateway WebSocket connection details (browser auth only). + // The public rtm.connect endpoint returns a LEGACY_BOT URL that rejects + // xoxc/xoxd sessions with invalid_auth, so we construct the same URL the + // browser client uses and surface the Cookie header the caller must send + // during the WebSocket upgrade. + async rtmConnect(): Promise<{ url: string; headers: Record; self?: any }> { + if (this.config.auth_type !== 'browser') { + throw new Error('rtm.connect with browser tokens is required to capture ephemeral events'); + } + + const auth = await this.testAuth(); + const self = { id: (auth as any).user_id, name: (auth as any).user }; + + const token = encodeURIComponent(this.config.xoxc_token); + const xoxd = encodeURIComponent(this.config.xoxd_token); + const authTs = Math.floor(Date.now() / 1000); + + const url = `wss://wss-primary.slack.com/?token=${token}` + + `&sync_desync=1&slack_client=desktop&no_query_on_subscribe=1` + + `&flannel_api_ver=4&include_min_version_bump_check=1` + + `&auth_ts=${authTs}&batch_presence_aware=1`; + + const headers: Record = { + Cookie: `d=${xoxd}`, + Origin: 'https://app.slack.com', + 'User-Agent': `Mozilla/5.0 (compatible; SlackCLI/${APP_VERSION})`, + }; + + return { url, headers, self }; + } + // Check auth type get authType(): string { return this.config.auth_type; diff --git a/src/lib/slash-command-runner.test.ts b/src/lib/slash-command-runner.test.ts new file mode 100644 index 0000000..520d071 --- /dev/null +++ b/src/lib/slash-command-runner.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'bun:test'; +import { runSlashCommand } from './slash-command-runner.ts'; +import type { SocketLike, SocketFactory } from './slash-command-runner.ts'; + +const CH = 'C123'; +const TOKEN = 'tok-abc'; + +type Listener = (ev: any) => void; + +interface FakeSocket extends SocketLike { + emit(type: 'open' | 'message' | 'error', ev: any): void; + emitFrame(payload: any): void; + closed: boolean; +} + +function makeFakeFactory(): { factory: SocketFactory; sockets: FakeSocket[] } { + const sockets: FakeSocket[] = []; + const factory: SocketFactory = () => { + const listeners: Record = { open: [], message: [], error: [] }; + const sock: FakeSocket = { + addEventListener: (type, cb) => { listeners[type].push(cb); }, + close: () => { sock.closed = true; }, + emit: (type, ev) => { listeners[type].forEach(l => l(ev)); }, + emitFrame: (payload) => { listeners.message.forEach(l => l({ data: JSON.stringify(payload) })); }, + closed: false, + }; + sockets.push(sock); + return sock; + }; + return { factory, sockets }; +} + +const tick = () => new Promise(r => setTimeout(r, 0)); + +describe('runSlashCommand', () => { + it('invokes command on hello and captures matching ephemeral frame', async () => { + const { factory, sockets } = makeFakeFactory(); + let invoked = 0; + + const promise = runSlashCommand({ + rtm: { url: 'ws://x', headers: {} }, + channelId: CH, + clientToken: TOKEN, + timeoutMs: 5000, + socketFactory: factory, + invokeCommand: async () => { invoked++; return { ok: true }; }, + }); + + await tick(); + sockets[0].emitFrame({ type: 'hello' }); + await tick(); + expect(invoked).toBe(1); + + sockets[0].emitFrame({ + type: 'message', channel: CH, is_ephemeral: true, text: 'eph', ts: '1.1', + }); + + const result = await promise; + expect(result.timedOut).toBe(false); + expect(result.messages.length).toBe(1); + expect(result.messages[0].text).toBe('eph'); + expect(sockets[0].closed).toBe(true); + }); + + it('captures synchronous response body alongside ephemeral frame', async () => { + const { factory, sockets } = makeFakeFactory(); + + const promise = runSlashCommand({ + rtm: { url: 'ws://x', headers: {} }, + channelId: CH, + clientToken: TOKEN, + timeoutMs: 5000, + socketFactory: factory, + invokeCommand: async () => ({ response: { text: 'sync hi' } }), + }); + + await tick(); + sockets[0].emitFrame({ type: 'hello' }); + await tick(); + sockets[0].emitFrame({ + type: 'message', channel: CH, subtype: 'ephemeral', text: 'late', ts: '1.2', + }); + + const result = await promise; + expect(result.messages.map(m => m.text)).toEqual(['sync hi', 'late']); + }); + + it('resolves with timedOut=true when no ephemeral arrives, returning sync-only messages', async () => { + const { factory, sockets } = makeFakeFactory(); + + const promise = runSlashCommand({ + rtm: { url: 'ws://x', headers: {} }, + channelId: CH, + clientToken: TOKEN, + timeoutMs: 5, + socketFactory: factory, + invokeCommand: async () => ({ response: { text: 'sync only' } }), + }); + + await tick(); + sockets[0].emitFrame({ type: 'hello' }); + + const result = await promise; + expect(result.timedOut).toBe(true); + expect(result.messages.map(m => m.text)).toEqual(['sync only']); + expect(sockets[0].closed).toBe(true); + }); + + it('rejects and cleans up when invokeCommand throws', async () => { + const { factory, sockets } = makeFakeFactory(); + const boom = new Error('invoke failed'); + + const promise = runSlashCommand({ + rtm: { url: 'ws://x', headers: {} }, + channelId: CH, + clientToken: TOKEN, + timeoutMs: 5000, + socketFactory: factory, + invokeCommand: async () => { throw boom; }, + }); + + await tick(); + sockets[0].emitFrame({ type: 'hello' }); + + await expect(promise).rejects.toBe(boom); + expect(sockets[0].closed).toBe(true); + }); + + it('rejects on socket error and closes the socket', async () => { + const { factory, sockets } = makeFakeFactory(); + + const promise = runSlashCommand({ + rtm: { url: 'ws://x', headers: {} }, + channelId: CH, + clientToken: TOKEN, + timeoutMs: 5000, + socketFactory: factory, + invokeCommand: async () => ({}), + }); + + await tick(); + sockets[0].emit('error', { message: 'kaput' }); + + await expect(promise).rejects.toThrow('WebSocket error: kaput'); + expect(sockets[0].closed).toBe(true); + }); + + it('ignores unrelated frames (different channel, non-message types)', async () => { + const { factory, sockets } = makeFakeFactory(); + + const promise = runSlashCommand({ + rtm: { url: 'ws://x', headers: {} }, + channelId: CH, + clientToken: TOKEN, + timeoutMs: 20, + socketFactory: factory, + invokeCommand: async () => ({}), + }); + + await tick(); + sockets[0].emitFrame({ type: 'hello' }); + sockets[0].emitFrame({ type: 'presence_change', user: 'U1' }); + sockets[0].emitFrame({ type: 'message', channel: 'C_OTHER', is_ephemeral: true, text: 'x' }); + sockets[0].emitFrame({ type: 'message', channel: CH, text: 'normal post' }); + + const result = await promise; + expect(result.timedOut).toBe(true); + expect(result.messages).toEqual([]); + }); + + it('only invokes the command on the first hello', async () => { + const { factory, sockets } = makeFakeFactory(); + let invoked = 0; + + const promise = runSlashCommand({ + rtm: { url: 'ws://x', headers: {} }, + channelId: CH, + clientToken: TOKEN, + timeoutMs: 5, + socketFactory: factory, + invokeCommand: async () => { invoked++; return {}; }, + }); + + await tick(); + sockets[0].emitFrame({ type: 'hello' }); + sockets[0].emitFrame({ type: 'hello' }); + sockets[0].emitFrame({ type: 'hello' }); + + await promise; + expect(invoked).toBe(1); + }); + + it('skips frames with non-string data without throwing', async () => { + const { factory, sockets } = makeFakeFactory(); + + const promise = runSlashCommand({ + rtm: { url: 'ws://x', headers: {} }, + channelId: CH, + clientToken: TOKEN, + timeoutMs: 10, + socketFactory: factory, + invokeCommand: async () => ({}), + }); + + await tick(); + sockets[0].emit('message', { data: 0 }); // non-string, JSON.parse('') throws → swallowed + sockets[0].emit('message', { data: '{not json' }); + sockets[0].emitFrame({ type: 'hello' }); + + const result = await promise; + expect(result.timedOut).toBe(true); + }); +}); diff --git a/src/lib/slash-command-runner.ts b/src/lib/slash-command-runner.ts new file mode 100644 index 0000000..1c4f22c --- /dev/null +++ b/src/lib/slash-command-runner.ts @@ -0,0 +1,103 @@ +import type { SlackMessage } from '../types/index.ts'; +import { matchesEphemeral, payloadToMessage, syncResponseToMessage } from './ephemeral-capture.ts'; + +// Slack MS-socket connection details from SlackClient.rtmConnect. +export interface RtmConnection { + url: string; + headers: Record; + selfUserId?: string; +} + +// Minimal WebSocket surface — the runner only listens and closes. +export interface SocketLike { + addEventListener(type: 'open' | 'message' | 'error', cb: (ev: any) => void): void; + close(): void; +} + +export type SocketFactory = (url: string, headers: Record) => SocketLike; + +export interface SlashCommandRunOptions { + rtm: RtmConnection; + channelId: string; + clientToken: string; + invokeCommand: () => Promise; + timeoutMs: number; + socketFactory?: SocketFactory; +} + +export interface SlashCommandRunResult { + messages: SlackMessage[]; + timedOut: boolean; +} + +const defaultSocketFactory: SocketFactory = (url, headers) => + new WebSocket(url, { headers } as any) as unknown as SocketLike; + +// Drives the chat.command + ephemeral-capture flow: +// 1. Open socket, wait for "hello". +// 2. Invoke the slash command; capture any synchronous reply body. +// 3. Watch incoming frames; the first frame matching matchesEphemeral +// resolves the run. +// 4. If no ephemeral arrives within timeoutMs, resolve with timedOut=true. +// Every settle path runs cleanup() exactly once: clear timer, close socket. +export async function runSlashCommand(opts: SlashCommandRunOptions): Promise { + const factory = opts.socketFactory || defaultSocketFactory; + const captured: SlackMessage[] = []; + + return new Promise((resolve, reject) => { + let timer: ReturnType | undefined; + let invoked = false; + let settled = false; + const socket = factory(opts.rtm.url, opts.rtm.headers); + + const cleanup = () => { + if (timer) { + clearTimeout(timer); + timer = undefined; + } + try { socket.close(); } catch { /* ignore */ } + }; + + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + cleanup(); + fn(); + }; + + socket.addEventListener('error', (ev: any) => { + settle(() => reject(new Error(`WebSocket error: ${ev?.message || 'unknown'}`))); + }); + + socket.addEventListener('message', async (ev: any) => { + let payload: any; + try { + payload = JSON.parse(typeof ev.data === 'string' ? ev.data : ''); + } catch { + return; + } + + if (payload.type === 'hello' && !invoked) { + invoked = true; + try { + const syncResp = await opts.invokeCommand(); + const synthetic = syncResponseToMessage(syncResp, opts.channelId); + if (synthetic) captured.push(synthetic); + } catch (e: any) { + settle(() => reject(e)); + return; + } + timer = setTimeout( + () => settle(() => resolve({ messages: captured, timedOut: true })), + opts.timeoutMs, + ); + return; + } + + if (matchesEphemeral(payload, opts.channelId, opts.clientToken, opts.rtm.selfUserId)) { + captured.push(payloadToMessage(payload)); + settle(() => resolve({ messages: captured, timedOut: false })); + } + }); + }); +} diff --git a/src/types/index.ts b/src/types/index.ts index d7f716d..55631cb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -76,6 +76,7 @@ export interface SlackFile { export interface SlackMessage { type: string; + subtype?: string; user?: string; bot_id?: string; text: string; @@ -90,6 +91,9 @@ export interface SlackMessage { blocks?: Array>; attachments?: Array>; files?: SlackFile[]; + is_ephemeral?: boolean; + client_msg_id?: string; + channel?: string; } export interface SlackAuthTestResponse { @@ -135,6 +139,15 @@ export interface MessageDraftOptions { workspace?: string; } +export interface MessageCommandOptions { + recipientId: string; + command: string; + text?: string; + timeout?: number; + workspace?: string; + json?: boolean; +} + export interface AuthLoginOptions { token: string; workspaceName: string; From e362e95e4d3261ae29870b553394b6fa5e9fa41b Mon Sep 17 00:00:00 2001 From: Eduard Kuzhyr <230987025+Eduard-Kuzhyr@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:21:30 +0200 Subject: [PATCH 2/4] fix: #50: tighten ephemeral match + bound pre-hello wait GENAI=YES Address review feedback on PR #51: - ephemeral-capture: hard-reject frames whose client_msg_id belongs to another invocation (different non-empty value vs. our clientToken). Keep the existing heuristic for frames without client_msg_id, since Slack does not stamp our chat.command client_token onto bot-emitted ephemerals. - slash-command-runner: arm a single timer at socket creation and re-arm it on the hello frame. Previously the timer was armed only inside the hello handler, so a connection that opened but never received hello would hang the CLI. Verified live against /genie in a DM channel. --- src/lib/ephemeral-capture.test.ts | 14 ++++++++++++++ src/lib/ephemeral-capture.ts | 16 ++++++++++++++++ src/lib/slash-command-runner.test.ts | 20 ++++++++++++++++++++ src/lib/slash-command-runner.ts | 26 +++++++++++++++++++------- 4 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/lib/ephemeral-capture.test.ts b/src/lib/ephemeral-capture.test.ts index dea5574..35b0d42 100644 --- a/src/lib/ephemeral-capture.test.ts +++ b/src/lib/ephemeral-capture.test.ts @@ -57,6 +57,20 @@ describe('matchesEphemeral', () => { )).toBe(false); }); + it('rejects ephemeral whose client_msg_id belongs to another invocation', () => { + expect(matchesEphemeral( + { type: 'message', channel: CH, is_ephemeral: true, client_msg_id: 'other-token', text: 'hi' }, + CH, TOKEN, + )).toBe(false); + }); + + it('still rejects mismatched client_msg_id even with subtype=ephemeral', () => { + expect(matchesEphemeral( + { type: 'message', channel: CH, subtype: 'ephemeral', client_msg_id: 'other-token' }, + CH, TOKEN, + )).toBe(false); + }); + it('does not correlate when clientToken is empty', () => { expect(matchesEphemeral( { type: 'message', channel: CH, client_msg_id: '' }, diff --git a/src/lib/ephemeral-capture.ts b/src/lib/ephemeral-capture.ts index 204c209..e78baa0 100644 --- a/src/lib/ephemeral-capture.ts +++ b/src/lib/ephemeral-capture.ts @@ -2,6 +2,16 @@ import type { SlackMessage } from '../types/index.ts'; // Returns true when an MS-socket frame represents an ephemeral message we // should capture for the given channel + invocation correlation token. +// +// Correlation is best-effort because Slack does not always echo the +// client_token we send to chat.command back as client_msg_id on the +// resulting frame. Strategy: +// 1. Reject anything that is not a `message` frame in our channel. +// 2. Hard-reject when the payload carries a client_msg_id that does NOT +// match our token — that frame belongs to another invocation. +// 3. Otherwise accept on any ephemeral signal: explicit is_ephemeral, +// subtype `ephemeral`, exact correlation, or a frame addressed to our +// own user with is_ephemeral. export function matchesEphemeral( payload: any, channelId: string, @@ -11,6 +21,12 @@ export function matchesEphemeral( if (!payload || payload.type !== 'message') return false; if (payload.channel !== channelId) return false; + // Hard-reject when payload's client_msg_id explicitly belongs to another + // invocation (different non-empty value). + if (clientToken !== '' && payload.client_msg_id && payload.client_msg_id !== clientToken) { + return false; + } + const isEphemeral = payload.is_ephemeral === true || payload.subtype === 'ephemeral'; const correlated = clientToken !== '' && payload.client_msg_id === clientToken; const directlyTargeted = !!selfUserId && payload.user === selfUserId && payload.is_ephemeral === true; diff --git a/src/lib/slash-command-runner.test.ts b/src/lib/slash-command-runner.test.ts index 520d071..ecf7d4b 100644 --- a/src/lib/slash-command-runner.test.ts +++ b/src/lib/slash-command-runner.test.ts @@ -190,6 +190,26 @@ describe('runSlashCommand', () => { expect(invoked).toBe(1); }); + it('resolves with timedOut=true when hello never arrives', async () => { + const { factory, sockets } = makeFakeFactory(); + let invoked = 0; + + const promise = runSlashCommand({ + rtm: { url: 'ws://x', headers: {} }, + channelId: CH, + clientToken: TOKEN, + timeoutMs: 5, + socketFactory: factory, + invokeCommand: async () => { invoked++; return {}; }, + }); + + const result = await promise; + expect(result.timedOut).toBe(true); + expect(result.messages).toEqual([]); + expect(invoked).toBe(0); + expect(sockets[0].closed).toBe(true); + }); + it('skips frames with non-string data without throwing', async () => { const { factory, sockets } = makeFakeFactory(); diff --git a/src/lib/slash-command-runner.ts b/src/lib/slash-command-runner.ts index 1c4f22c..a2ef89e 100644 --- a/src/lib/slash-command-runner.ts +++ b/src/lib/slash-command-runner.ts @@ -34,11 +34,14 @@ const defaultSocketFactory: SocketFactory = (url, headers) => new WebSocket(url, { headers } as any) as unknown as SocketLike; // Drives the chat.command + ephemeral-capture flow: -// 1. Open socket, wait for "hello". -// 2. Invoke the slash command; capture any synchronous reply body. +// 1. Open socket; arm a `timeoutMs` connection timeout immediately so we +// do not hang if Slack never sends a `hello` frame. +// 2. On `hello`, invoke the slash command; capture any synchronous reply +// body, then re-arm the timer to give the ephemeral the full window. // 3. Watch incoming frames; the first frame matching matchesEphemeral // resolves the run. -// 4. If no ephemeral arrives within timeoutMs, resolve with timedOut=true. +// 4. If no ephemeral arrives before the timer fires, resolve with +// timedOut=true. // Every settle path runs cleanup() exactly once: clear timer, close socket. export async function runSlashCommand(opts: SlashCommandRunOptions): Promise { const factory = opts.socketFactory || defaultSocketFactory; @@ -65,6 +68,17 @@ export async function runSlashCommand(opts: SlashCommandRunOptions): Promise { + if (timer) clearTimeout(timer); + timer = setTimeout( + () => settle(() => resolve({ messages: captured, timedOut: true })), + opts.timeoutMs, + ); + }; + + // Pre-hello safety net: bound the wait for a `hello` frame. + armTimeout(); + socket.addEventListener('error', (ev: any) => { settle(() => reject(new Error(`WebSocket error: ${ev?.message || 'unknown'}`))); }); @@ -87,10 +101,8 @@ export async function runSlashCommand(opts: SlashCommandRunOptions): Promise reject(e)); return; } - timer = setTimeout( - () => settle(() => resolve({ messages: captured, timedOut: true })), - opts.timeoutMs, - ); + // Reset the clock so the post-invocation window is exactly timeoutMs. + armTimeout(); return; } From f3050f700d5023de4c2e321cba4b95e8e37c8d66 Mon Sep 17 00:00:00 2001 From: Eduard Kuzhyr <230987025+Eduard-Kuzhyr@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:04:23 +0200 Subject: [PATCH 3/4] feat: #50: collect ephemerals across full --timeout window --- src/commands/messages.ts | 24 +++-- src/lib/slash-command-runner.test.ts | 125 +++++++++++++++++++++++++++ src/lib/slash-command-runner.ts | 30 +++++-- 3 files changed, 165 insertions(+), 14 deletions(-) diff --git a/src/commands/messages.ts b/src/commands/messages.ts index fce5fb0..23b1c4a 100644 --- a/src/commands/messages.ts +++ b/src/commands/messages.ts @@ -116,7 +116,8 @@ export function createMessagesCommand(): Command { .requiredOption('--recipient-id ', 'Channel ID or User ID where command is issued') .requiredOption('--command ', 'Slash command including leading slash, e.g. /genie') .option('--text ', 'Argument text for the command', '') - .option('--timeout ', 'Seconds to wait for the ephemeral reply', '15') + .option('--timeout ', 'Collection window: seconds to keep listening for ephemeral replies after the command is invoked', '15') + .option('--max-events ', 'Optional early-exit cap. Stop after N ephemeral frames instead of waiting the full --timeout window.') .option('--workspace ', 'Workspace to use') .option('--json', 'Output captured events as JSON', false) .action(async (options) => { @@ -141,6 +142,16 @@ export function createMessagesCommand(): Command { process.exit(1); } + let maxEvents: number | undefined; + if (options.maxEvents !== undefined) { + maxEvents = parseInt(options.maxEvents, 10); + if (!Number.isFinite(maxEvents) || maxEvents < 1) { + spinner.fail(`Invalid --max-events value: ${options.maxEvents}`); + error('--max-events must be a positive integer.'); + process.exit(1); + } + } + spinner.text = 'Connecting to Slack real-time gateway...'; const { url, headers, self } = await client.rtmConnect(); @@ -152,6 +163,7 @@ export function createMessagesCommand(): Command { channelId, clientToken, timeoutMs: timeoutSeconds * 1000, + maxEvents, invokeCommand: () => client.executeSlashCommand( channelId, options.command, @@ -160,11 +172,11 @@ export function createMessagesCommand(): Command { ), }); - spinner.succeed( - timedOut - ? `Timed out after ${timeoutSeconds}s — no ephemeral reply received` - : `Captured ${captured.length} ephemeral event(s)`, - ); + if (captured.length > 0) { + spinner.succeed(`Captured ${captured.length} event(s) in ${timeoutSeconds}s window`); + } else { + spinner.succeed(`Timed out after ${timeoutSeconds}s — no ephemeral reply received`); + } if (options.json) { console.log(JSON.stringify({ diff --git a/src/lib/slash-command-runner.test.ts b/src/lib/slash-command-runner.test.ts index ecf7d4b..299f907 100644 --- a/src/lib/slash-command-runner.test.ts +++ b/src/lib/slash-command-runner.test.ts @@ -42,6 +42,7 @@ describe('runSlashCommand', () => { channelId: CH, clientToken: TOKEN, timeoutMs: 5000, + maxEvents: 1, socketFactory: factory, invokeCommand: async () => { invoked++; return { ok: true }; }, }); @@ -70,6 +71,7 @@ describe('runSlashCommand', () => { channelId: CH, clientToken: TOKEN, timeoutMs: 5000, + maxEvents: 1, socketFactory: factory, invokeCommand: async () => ({ response: { text: 'sync hi' } }), }); @@ -210,6 +212,129 @@ describe('runSlashCommand', () => { expect(sockets[0].closed).toBe(true); }); + it('captures multiple ephemeral frames when maxEvents > 1', async () => { + const { factory, sockets } = makeFakeFactory(); + + const promise = runSlashCommand({ + rtm: { url: 'ws://x', headers: {} }, + channelId: CH, + clientToken: TOKEN, + timeoutMs: 5000, + maxEvents: 3, + socketFactory: factory, + invokeCommand: async () => ({}), + }); + + await tick(); + sockets[0].emitFrame({ type: 'hello' }); + await tick(); + sockets[0].emitFrame({ type: 'message', channel: CH, is_ephemeral: true, text: 'one', ts: '1.1' }); + sockets[0].emitFrame({ type: 'message', channel: CH, is_ephemeral: true, text: 'two', ts: '1.2' }); + sockets[0].emitFrame({ type: 'message', channel: CH, is_ephemeral: true, text: 'three', ts: '1.3' }); + + const result = await promise; + expect(result.timedOut).toBe(false); + expect(result.messages.map(m => m.text)).toEqual(['one', 'two', 'three']); + expect(sockets[0].closed).toBe(true); + }); + + it('returns partial captures (timedOut=false) when fewer than maxEvents arrive before timer', async () => { + const { factory, sockets } = makeFakeFactory(); + + const promise = runSlashCommand({ + rtm: { url: 'ws://x', headers: {} }, + channelId: CH, + clientToken: TOKEN, + timeoutMs: 20, + maxEvents: 5, + socketFactory: factory, + invokeCommand: async () => ({}), + }); + + await tick(); + sockets[0].emitFrame({ type: 'hello' }); + await tick(); + sockets[0].emitFrame({ type: 'message', channel: CH, is_ephemeral: true, text: 'only', ts: '1.1' }); + + const result = await promise; + // timedOut tracks the ephemeral stream: at least one arrived, so false. + expect(result.timedOut).toBe(false); + expect(result.messages.map(m => m.text)).toEqual(['only']); + }); + + it('without maxEvents collects every ephemeral until the timer fires', async () => { + const { factory, sockets } = makeFakeFactory(); + + const promise = runSlashCommand({ + rtm: { url: 'ws://x', headers: {} }, + channelId: CH, + clientToken: TOKEN, + timeoutMs: 30, + socketFactory: factory, + invokeCommand: async () => ({}), + }); + + await tick(); + sockets[0].emitFrame({ type: 'hello' }); + await tick(); + sockets[0].emitFrame({ type: 'message', channel: CH, is_ephemeral: true, text: 'a', ts: '1.1' }); + sockets[0].emitFrame({ type: 'message', channel: CH, is_ephemeral: true, text: 'b', ts: '1.2' }); + sockets[0].emitFrame({ type: 'message', channel: CH, is_ephemeral: true, text: 'c', ts: '1.3' }); + + const result = await promise; + expect(result.timedOut).toBe(false); + expect(result.messages.map(m => m.text)).toEqual(['a', 'b', 'c']); + expect(sockets[0].closed).toBe(true); + }); + + it('window stays open between matches: late frame still captured', async () => { + const { factory, sockets } = makeFakeFactory(); + + const promise = runSlashCommand({ + rtm: { url: 'ws://x', headers: {} }, + channelId: CH, + clientToken: TOKEN, + timeoutMs: 50, + socketFactory: factory, + invokeCommand: async () => ({}), + }); + + await tick(); + sockets[0].emitFrame({ type: 'hello' }); + await tick(); + sockets[0].emitFrame({ type: 'message', channel: CH, is_ephemeral: true, text: 'first', ts: '1.1' }); + await new Promise(r => setTimeout(r, 20)); + sockets[0].emitFrame({ type: 'message', channel: CH, is_ephemeral: true, text: 'late', ts: '1.2' }); + + const result = await promise; + expect(result.timedOut).toBe(false); + expect(result.messages.map(m => m.text)).toEqual(['first', 'late']); + }); + + it('sync response body does not count toward maxEvents cap', async () => { + const { factory, sockets } = makeFakeFactory(); + + const promise = runSlashCommand({ + rtm: { url: 'ws://x', headers: {} }, + channelId: CH, + clientToken: TOKEN, + timeoutMs: 5000, + maxEvents: 2, + socketFactory: factory, + invokeCommand: async () => ({ response: { text: 'sync' } }), + }); + + await tick(); + sockets[0].emitFrame({ type: 'hello' }); + await tick(); + sockets[0].emitFrame({ type: 'message', channel: CH, is_ephemeral: true, text: 'eph1', ts: '1.1' }); + sockets[0].emitFrame({ type: 'message', channel: CH, is_ephemeral: true, text: 'eph2', ts: '1.2' }); + + const result = await promise; + expect(result.timedOut).toBe(false); + expect(result.messages.map(m => m.text)).toEqual(['sync', 'eph1', 'eph2']); + }); + it('skips frames with non-string data without throwing', async () => { const { factory, sockets } = makeFakeFactory(); diff --git a/src/lib/slash-command-runner.ts b/src/lib/slash-command-runner.ts index a2ef89e..5cf9a19 100644 --- a/src/lib/slash-command-runner.ts +++ b/src/lib/slash-command-runner.ts @@ -23,6 +23,11 @@ export interface SlashCommandRunOptions { invokeCommand: () => Promise; timeoutMs: number; socketFactory?: SocketFactory; + // Optional early-exit cap: stop after this many matching ephemeral + // frames have been captured. Sync HTTP response bodies do not count + // toward this cap. When unset, the runner waits the full timeoutMs + // window and returns every ephemeral that arrived in it. + maxEvents?: number; } export interface SlashCommandRunResult { @@ -33,19 +38,25 @@ export interface SlashCommandRunResult { const defaultSocketFactory: SocketFactory = (url, headers) => new WebSocket(url, { headers } as any) as unknown as SocketLike; -// Drives the chat.command + ephemeral-capture flow: +// Drives the chat.command + ephemeral-capture flow. Slack does not send a +// terminator frame for slash commands, so timeoutMs doubles as the +// collection window: // 1. Open socket; arm a `timeoutMs` connection timeout immediately so we // do not hang if Slack never sends a `hello` frame. // 2. On `hello`, invoke the slash command; capture any synchronous reply -// body, then re-arm the timer to give the ephemeral the full window. -// 3. Watch incoming frames; the first frame matching matchesEphemeral -// resolves the run. -// 4. If no ephemeral arrives before the timer fires, resolve with -// timedOut=true. +// body, then re-arm the timer for the full post-invocation window. +// 3. Append every matching ephemeral to `captured` and keep listening. +// 4. Settle when the timer fires, OR — if maxEvents is set — early-exit +// after that many ephemerals. +// 5. timedOut is true only when zero ephemerals arrived. Sync-only +// replies still count as a successful capture in `messages`, but +// timedOut reflects the ephemeral stream specifically. // Every settle path runs cleanup() exactly once: clear timer, close socket. export async function runSlashCommand(opts: SlashCommandRunOptions): Promise { const factory = opts.socketFactory || defaultSocketFactory; const captured: SlackMessage[] = []; + const maxEvents = opts.maxEvents !== undefined ? Math.max(1, opts.maxEvents) : Infinity; + let ephemeralMatches = 0; return new Promise((resolve, reject) => { let timer: ReturnType | undefined; @@ -71,7 +82,7 @@ export async function runSlashCommand(opts: SlashCommandRunOptions): Promise { if (timer) clearTimeout(timer); timer = setTimeout( - () => settle(() => resolve({ messages: captured, timedOut: true })), + () => settle(() => resolve({ messages: captured, timedOut: ephemeralMatches === 0 })), opts.timeoutMs, ); }; @@ -108,7 +119,10 @@ export async function runSlashCommand(opts: SlashCommandRunOptions): Promise resolve({ messages: captured, timedOut: false })); + ephemeralMatches++; + if (ephemeralMatches >= maxEvents) { + settle(() => resolve({ messages: captured, timedOut: false })); + } } }); }); From a33e483d3a9c4cf9fdcf47a3b8e7615365c87b1a Mon Sep 17 00:00:00 2001 From: Eduard Kuzhyr <230987025+Eduard-Kuzhyr@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:43:55 +0200 Subject: [PATCH 4/4] fix: #50: address PR review feedback on slash-command capture --- src/lib/ephemeral-capture.test.ts | 6 ++-- src/lib/ephemeral-capture.ts | 4 +-- src/lib/formatter.test.ts | 52 ++++++++++++++++++++++++++++ src/lib/formatter.ts | 15 +++++--- src/lib/slash-command-runner.test.ts | 24 +++++++++++++ src/lib/slash-command-runner.ts | 6 +++- 6 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/lib/ephemeral-capture.test.ts b/src/lib/ephemeral-capture.test.ts index 35b0d42..f3ae9bf 100644 --- a/src/lib/ephemeral-capture.test.ts +++ b/src/lib/ephemeral-capture.test.ts @@ -122,10 +122,9 @@ describe('payloadToMessage', () => { expect(msg.is_ephemeral).toBe(true); }); - it('synthesizes ts when missing', () => { + it('synthesizes ts in Slack sec.microsec format when missing', () => { const msg = payloadToMessage({ type: 'message', channel: CH }); - expect(msg.ts).toBeDefined(); - expect(msg.ts.length).toBeGreaterThan(0); + expect(msg.ts).toMatch(/^\d+\.\d{6}$/); }); }); @@ -140,6 +139,7 @@ describe('syncResponseToMessage', () => { expect(msg!.blocks).toEqual([{ type: 'section' }]); expect(msg!.is_ephemeral).toBe(true); expect(msg!.channel).toBe(CH); + expect(msg!.ts).toMatch(/^\d+\.\d{6}$/); }); it('synthesizes from message.text', () => { diff --git a/src/lib/ephemeral-capture.ts b/src/lib/ephemeral-capture.ts index e78baa0..264aa93 100644 --- a/src/lib/ephemeral-capture.ts +++ b/src/lib/ephemeral-capture.ts @@ -42,7 +42,7 @@ export function payloadToMessage(payload: any): SlackMessage { user: payload.user, bot_id: payload.bot_id, text: payload.text || '', - ts: payload.ts || String(Date.now() / 1000), + ts: payload.ts || (Date.now() / 1000).toFixed(6), thread_ts: payload.thread_ts, blocks: payload.blocks, attachments: payload.attachments, @@ -70,7 +70,7 @@ export function syncResponseToMessage(syncResp: any, channelId: string): SlackMe return { type: 'message', text: text || '', - ts: String(Date.now() / 1000), + ts: (Date.now() / 1000).toFixed(6), is_ephemeral: true, channel: channelId, blocks, diff --git a/src/lib/formatter.test.ts b/src/lib/formatter.test.ts index 411e1cd..8ea62d0 100644 --- a/src/lib/formatter.test.ts +++ b/src/lib/formatter.test.ts @@ -351,6 +351,58 @@ describe('formatMessage file display', () => { }); }); +describe('formatMessage text vs blocks', () => { + const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ''); + const users = new Map([ + ['U1', { id: 'U1', name: 'alice', real_name: 'Alice' }], + ]); + + it('suppresses text when blocks are present (avoids duplicate body)', () => { + const msg: SlackMessage = { + type: 'message', user: 'U1', + text: 'Notification fallback copy of the body', + ts: '1700000000.000100', + blocks: [{ type: 'section', text: { text: 'Block body' } }], + }; + const output = stripAnsi(formatMessage(msg, users)); + expect(output).toContain('Block body'); + expect(output).not.toContain('Notification fallback copy of the body'); + }); + + it('renders text when blocks are absent', () => { + const msg: SlackMessage = { + type: 'message', user: 'U1', + text: 'Plain message', + ts: '1700000000.000100', + }; + const output = stripAnsi(formatMessage(msg, users)); + expect(output).toContain('Plain message'); + }); + + it('renders text when only attachments are present (no blocks)', () => { + const msg: SlackMessage = { + type: 'message', user: 'U1', + text: 'Lead-in text', + ts: '1700000000.000100', + attachments: [{ title: 'Att title' }], + }; + const output = stripAnsi(formatMessage(msg, users)); + expect(output).toContain('Lead-in text'); + expect(output).toContain('Att title'); + }); + + it('renders text when blocks is an empty array', () => { + const msg: SlackMessage = { + type: 'message', user: 'U1', + text: 'Visible text', + ts: '1700000000.000100', + blocks: [], + }; + const output = stripAnsi(formatMessage(msg, users)); + expect(output).toContain('Visible text'); + }); +}); + describe('formatFileSize', () => { it('formats 0 bytes', () => { expect(formatFileSize(0)).toBe('0 B'); diff --git a/src/lib/formatter.ts b/src/lib/formatter.ts index e016470..3a8154f 100644 --- a/src/lib/formatter.ts +++ b/src/lib/formatter.ts @@ -104,11 +104,16 @@ export function formatMessage( let output = `${indentStr}${chalk.dim(`[${timestamp}]`)} ${chalk.bold(`@${userName}`)}${threadIndicator}\n`; - // Message text - const textLines = msg.text.split('\n'); - textLines.forEach(line => { - output += `${indentStr} ${line}\n`; - }); + // Message text. Suppressed when blocks are present, since bots typically + // copy block content into `text` as a notification fallback — rendering + // both would duplicate the body. + const hasBlocks = !!(msg.blocks && msg.blocks.length > 0); + if (msg.text && !hasBlocks) { + const textLines = msg.text.split('\n'); + textLines.forEach(line => { + output += `${indentStr} ${line}\n`; + }); + } // Show timestamps for threading if (msg.ts) { diff --git a/src/lib/slash-command-runner.test.ts b/src/lib/slash-command-runner.test.ts index 299f907..26b9d7b 100644 --- a/src/lib/slash-command-runner.test.ts +++ b/src/lib/slash-command-runner.test.ts @@ -147,6 +147,30 @@ describe('runSlashCommand', () => { expect(sockets[0].closed).toBe(true); }); + it('resolves with captures on socket error after at least one ephemeral arrived', async () => { + const { factory, sockets } = makeFakeFactory(); + + const promise = runSlashCommand({ + rtm: { url: 'ws://x', headers: {} }, + channelId: CH, + clientToken: TOKEN, + timeoutMs: 5000, + socketFactory: factory, + invokeCommand: async () => ({}), + }); + + await tick(); + sockets[0].emitFrame({ type: 'hello' }); + await tick(); + sockets[0].emitFrame({ type: 'message', channel: CH, is_ephemeral: true, text: 'got it', ts: '1.1' }); + sockets[0].emit('error', { message: 'kaput' }); + + const result = await promise; + expect(result.timedOut).toBe(false); + expect(result.messages.map(m => m.text)).toEqual(['got it']); + expect(sockets[0].closed).toBe(true); + }); + it('ignores unrelated frames (different channel, non-message types)', async () => { const { factory, sockets } = makeFakeFactory(); diff --git a/src/lib/slash-command-runner.ts b/src/lib/slash-command-runner.ts index 5cf9a19..77a33ef 100644 --- a/src/lib/slash-command-runner.ts +++ b/src/lib/slash-command-runner.ts @@ -91,7 +91,11 @@ export async function runSlashCommand(opts: SlashCommandRunOptions): Promise { - settle(() => reject(new Error(`WebSocket error: ${ev?.message || 'unknown'}`))); + if (ephemeralMatches > 0) { + settle(() => resolve({ messages: captured, timedOut: false })); + } else { + settle(() => reject(new Error(`WebSocket error: ${ev?.message || 'unknown'}`))); + } }); socket.addEventListener('message', async (ev: any) => {