Skip to content

Commit 0199116

Browse files
Add Slack text snippet input for larger content (#3)
Enable users to send text snippets and file uploads to Silverback as input, bypassing the 4000-char Slack message limit. Files are downloaded via Slack API, filtered to text-compatible mimetypes, and combined with message text using XML-style prompt delimiters. - Add file-downloader utility with SSRF defense, 100KB limit - Add prompt-builder to combine text + file content - Allow file_share subtype through message handler - Wire file download into both mention and message handlers - Add files:read and files:write OAuth scopes to manifest - Add graceful degradation when files:read scope is missing - Add 12 tests for file-downloader Co-authored-by: Claude Bot <claude-bot@users.noreply.github.com>
1 parent d7f90cc commit 0199116

7 files changed

Lines changed: 404 additions & 13 deletions

File tree

manifest.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ oauth_config:
8181
- channels:read
8282
- chat:write
8383
- commands
84+
- files:read
85+
- files:write
8486
- groups:read
8587
- incoming-webhook
8688
- users:read

src/bot/events/mention.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,35 @@
11
import { App } from '@slack/bolt';
22
import { RequestQueue } from '../../queue/request-queue';
33
import { Logger } from '../../logging/logger';
4+
import { downloadTextFiles, SlackFileInfo } from '../utils/file-downloader';
5+
import { buildPrompt } from '../utils/prompt-builder';
46

57
const logger = new Logger('mention-handler');
68

7-
export function registerMentionHandler(app: App, queue: RequestQueue): void {
9+
export function registerMentionHandler(app: App, queue: RequestQueue, botToken: string): void {
810
app.event('app_mention', async ({ event, client, say }) => {
9-
const prompt = event.text.replace(/<@[A-Z0-9]+>/g, '').trim();
11+
const textPrompt = event.text.replace(/<@[A-Z0-9]+>/g, '').trim();
1012

11-
if (!prompt) {
13+
// Extract files if present
14+
const files: SlackFileInfo[] = 'files' in event && Array.isArray((event as any).files)
15+
? (event as any).files
16+
: [];
17+
18+
if (!textPrompt && files.length === 0) {
1219
await say({ text: 'Please provide a task description after mentioning me.', thread_ts: event.ts });
1320
return;
1421
}
1522

1623
const threadTs = event.thread_ts || event.ts;
1724

18-
logger.info('Received mention', { user: event.user, channel: event.channel, threadTs });
25+
// Download text files if present
26+
let prompt = textPrompt;
27+
if (files.length > 0 && botToken) {
28+
const downloaded = await downloadTextFiles(files, botToken);
29+
prompt = buildPrompt(textPrompt, downloaded);
30+
}
31+
32+
logger.info('Received mention', { user: event.user, channel: event.channel, threadTs, fileCount: files.length });
1933

2034
const entry = await queue.enqueue({
2135
threadId: threadTs as string,

src/bot/events/message.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,51 @@ import { App } from '@slack/bolt';
22
import { RequestQueue } from '../../queue/request-queue';
33
import { SessionManager } from '../../orchestrator/session';
44
import { Logger } from '../../logging/logger';
5+
import { downloadTextFiles, SlackFileInfo } from '../utils/file-downloader';
6+
import { buildPrompt } from '../utils/prompt-builder';
57

68
const logger = new Logger('message-handler');
79

8-
export function registerMessageHandler(app: App, queue: RequestQueue, sessionManager: SessionManager): void {
10+
export function registerMessageHandler(app: App, queue: RequestQueue, sessionManager: SessionManager, botToken: string): void {
911
app.event('message', async ({ event, client }) => {
1012
// Only handle thread replies
1113
if (!('thread_ts' in event) || !event.thread_ts) return;
1214
// Ignore bot messages
1315
if ('bot_id' in event && event.bot_id) return;
14-
// Ignore subtypes (edits, deletes, etc.)
15-
if ('subtype' in event && event.subtype) return;
16+
// Ignore subtypes (edits, deletes, etc.) but allow file_share
17+
if ('subtype' in event && event.subtype && event.subtype !== 'file_share') return;
1618

1719
const threadTs = event.thread_ts;
1820
const text = 'text' in event ? (event.text || '').trim() : '';
1921
const userId = 'user' in event ? event.user || '' : '';
2022
const channelId = event.channel;
2123

22-
// Ignore empty messages
23-
if (!text) return;
24+
// Extract files if present
25+
const files: SlackFileInfo[] = 'files' in event && Array.isArray((event as any).files)
26+
? (event as any).files
27+
: [];
28+
29+
// Ignore messages with no text and no files
30+
if (!text && files.length === 0) return;
2431

2532
// Check if this thread has an active session
2633
const session = await sessionManager.getSession(threadTs);
2734
if (!session) return;
2835

29-
logger.info('Thread reply in active session', { threadTs, userId });
36+
// Download text files if present
37+
let prompt = text;
38+
if (files.length > 0 && botToken) {
39+
const downloaded = await downloadTextFiles(files, botToken);
40+
prompt = buildPrompt(text, downloaded);
41+
}
42+
43+
logger.info('Thread reply in active session', { threadTs, userId, fileCount: files.length });
3044

3145
const entry = await queue.enqueue({
3246
threadId: threadTs,
3347
channelId,
3448
userId,
35-
prompt: text,
49+
prompt,
3650
});
3751

3852
if (entry.position > 0) {
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { downloadTextFiles, SlackFileInfo } from '../file-downloader';
3+
4+
function createMockFile(overrides: Partial<SlackFileInfo> = {}): SlackFileInfo {
5+
return {
6+
id: 'F123',
7+
name: 'test.txt',
8+
filetype: 'txt',
9+
mimetype: 'text/plain',
10+
url_private_download: 'https://files.slack.com/files-pri/T123/F123/test.txt',
11+
size: 100,
12+
...overrides,
13+
};
14+
}
15+
16+
describe('downloadTextFiles', () => {
17+
const mockFetch = vi.fn();
18+
const botToken = 'xoxb-test-token';
19+
20+
beforeEach(() => {
21+
global.fetch = mockFetch;
22+
mockFetch.mockReset();
23+
});
24+
25+
it('downloads a text file successfully', async () => {
26+
const file = createMockFile();
27+
mockFetch.mockResolvedValueOnce({
28+
ok: true,
29+
arrayBuffer: () => Promise.resolve(new TextEncoder().encode('file content').buffer),
30+
});
31+
32+
const results = await downloadTextFiles([file], botToken);
33+
34+
expect(results).toHaveLength(1);
35+
expect(results[0]).toEqual({
36+
filename: 'test.txt',
37+
content: 'file content',
38+
truncated: false,
39+
});
40+
expect(mockFetch).toHaveBeenCalledWith(
41+
'https://files.slack.com/files-pri/T123/F123/test.txt',
42+
{
43+
headers: {
44+
Authorization: 'Bearer xoxb-test-token',
45+
},
46+
redirect: 'error',
47+
}
48+
);
49+
});
50+
51+
it('filters out non-text files', async () => {
52+
const files = [
53+
createMockFile({ id: 'F1', mimetype: 'image/png', name: 'image.png' }),
54+
createMockFile({ id: 'F2', mimetype: 'video/mp4', name: 'video.mp4' }),
55+
createMockFile({ id: 'F3', mimetype: 'application/pdf', name: 'doc.pdf' }),
56+
];
57+
58+
const results = await downloadTextFiles(files, botToken);
59+
60+
expect(results).toHaveLength(0);
61+
expect(mockFetch).not.toHaveBeenCalled();
62+
});
63+
64+
it('truncates file larger than maxFileBytes and marks it', async () => {
65+
const largeContent = 'x'.repeat(200 * 1024); // 200KB
66+
const file = createMockFile({ size: 200 * 1024 });
67+
mockFetch.mockResolvedValueOnce({
68+
ok: true,
69+
arrayBuffer: () => Promise.resolve(new TextEncoder().encode(largeContent).buffer),
70+
});
71+
72+
const results = await downloadTextFiles([file], botToken, { maxFileBytes: 100 * 1024 });
73+
74+
expect(results).toHaveLength(1);
75+
expect(results[0].truncated).toBe(true);
76+
expect(results[0].content.length).toBeLessThanOrEqual(100 * 1024);
77+
});
78+
79+
it('skips file gracefully when fetch returns non-ok status', async () => {
80+
const file = createMockFile();
81+
mockFetch.mockResolvedValueOnce({
82+
ok: false,
83+
status: 404,
84+
});
85+
86+
const results = await downloadTextFiles([file], botToken);
87+
88+
expect(results).toHaveLength(0);
89+
});
90+
91+
it('returns empty array when files array is empty', async () => {
92+
const results = await downloadTextFiles([], botToken);
93+
94+
expect(results).toHaveLength(0);
95+
expect(mockFetch).not.toHaveBeenCalled();
96+
});
97+
98+
it('downloads multiple text files and respects maxFiles limit', async () => {
99+
const files = [
100+
createMockFile({ id: 'F1', name: 'file1.txt' }),
101+
createMockFile({ id: 'F2', name: 'file2.json', mimetype: 'application/json' }),
102+
createMockFile({ id: 'F3', name: 'file3.xml', mimetype: 'application/xml' }),
103+
createMockFile({ id: 'F4', name: 'file4.txt' }),
104+
createMockFile({ id: 'F5', name: 'file5.txt' }),
105+
createMockFile({ id: 'F6', name: 'file6.txt' }),
106+
];
107+
108+
mockFetch.mockImplementation(() =>
109+
Promise.resolve({
110+
ok: true,
111+
arrayBuffer: () => Promise.resolve(new TextEncoder().encode('content').buffer),
112+
})
113+
);
114+
115+
const results = await downloadTextFiles(files, botToken, { maxFiles: 3 });
116+
117+
expect(results).toHaveLength(3);
118+
expect(results[0].filename).toBe('file1.txt');
119+
expect(results[1].filename).toBe('file2.json');
120+
expect(results[2].filename).toBe('file3.xml');
121+
expect(mockFetch).toHaveBeenCalledTimes(3);
122+
});
123+
124+
it('skips file with non-slack.com hostname (SSRF defense)', async () => {
125+
const file = createMockFile({
126+
url_private_download: 'https://evil.com/malicious.txt',
127+
});
128+
129+
const results = await downloadTextFiles([file], botToken);
130+
131+
expect(results).toHaveLength(0);
132+
expect(mockFetch).not.toHaveBeenCalled();
133+
});
134+
135+
it('skips file without url_private_download', async () => {
136+
const file = createMockFile({ url_private_download: undefined });
137+
138+
const results = await downloadTextFiles([file], botToken);
139+
140+
expect(results).toHaveLength(0);
141+
expect(mockFetch).not.toHaveBeenCalled();
142+
});
143+
144+
it('continues processing after download failure', async () => {
145+
const files = [
146+
createMockFile({ id: 'F1', name: 'file1.txt' }),
147+
createMockFile({ id: 'F2', name: 'file2.txt' }),
148+
];
149+
150+
mockFetch
151+
.mockRejectedValueOnce(new Error('Network error'))
152+
.mockResolvedValueOnce({
153+
ok: true,
154+
arrayBuffer: () => Promise.resolve(new TextEncoder().encode('success').buffer),
155+
});
156+
157+
const results = await downloadTextFiles(files, botToken);
158+
159+
expect(results).toHaveLength(1);
160+
expect(results[0].filename).toBe('file2.txt');
161+
expect(results[0].content).toBe('success');
162+
});
163+
164+
it('accepts yaml files as text-compatible', async () => {
165+
const files = [
166+
createMockFile({ name: 'config.yaml', mimetype: 'application/yaml' }),
167+
createMockFile({ name: 'data.yml', mimetype: 'application/x-yaml' }),
168+
];
169+
170+
mockFetch.mockImplementation(() =>
171+
Promise.resolve({
172+
ok: true,
173+
arrayBuffer: () => Promise.resolve(new TextEncoder().encode('key: value').buffer),
174+
})
175+
);
176+
177+
const results = await downloadTextFiles(files, botToken);
178+
179+
expect(results).toHaveLength(2);
180+
});
181+
182+
it('uses default maxFileBytes from env when not provided', async () => {
183+
const originalEnv = process.env.MAX_SNIPPET_BYTES;
184+
process.env.MAX_SNIPPET_BYTES = '50000';
185+
186+
const largeContent = 'x'.repeat(60000);
187+
const file = createMockFile({ size: 60000 });
188+
mockFetch.mockResolvedValueOnce({
189+
ok: true,
190+
arrayBuffer: () => Promise.resolve(new TextEncoder().encode(largeContent).buffer),
191+
});
192+
193+
const results = await downloadTextFiles([file], botToken);
194+
195+
expect(results[0].truncated).toBe(true);
196+
expect(results[0].content.length).toBeLessThanOrEqual(50000);
197+
198+
process.env.MAX_SNIPPET_BYTES = originalEnv;
199+
});
200+
201+
it('skips file with invalid URL format', async () => {
202+
const file = createMockFile({ url_private_download: 'not-a-valid-url' });
203+
204+
const results = await downloadTextFiles([file], botToken);
205+
206+
expect(results).toHaveLength(0);
207+
expect(mockFetch).not.toHaveBeenCalled();
208+
});
209+
});

0 commit comments

Comments
 (0)