Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ bun.lockb
# Build output
dist/
*.tsbuildinfo
*.bun-build

# IDE
.idea/
Expand Down Expand Up @@ -53,3 +54,4 @@ docs/internal/
secrets/
credentials/
*.secret
/.claude/settings.local.json
Comment thread
Eduard-Kuzhyr marked this conversation as resolved.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
124 changes: 110 additions & 14 deletions src/commands/messages.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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) {
Expand Down Expand Up @@ -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, {
Expand All @@ -115,5 +109,107 @@ 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 <id>', 'Channel ID or User ID where command is issued')
.requiredOption('--command <slash>', 'Slash command including leading slash, e.g. /genie')
.option('--text <text>', 'Argument text for the command', '')
.option('--timeout <seconds>', 'Collection window: seconds to keep listening for ephemeral replies after the command is invoked', '15')
.option('--max-events <n>', 'Optional early-exit cap. Stop after N ephemeral frames instead of waiting the full --timeout window.')
.option('--workspace <id|name>', '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);
}

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();

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,
maxEvents,
invokeCommand: () => client.executeSlashCommand(
channelId,
options.command,
options.text || '',
clientToken,
),
});

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({
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<string, SlackUser>();
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;
}
161 changes: 161 additions & 0 deletions src/lib/block-renderer.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading