Skip to content

Commit a3f1d24

Browse files
authored
Add media support for Slack threads (#31)
* Add media support for Slack threads Enable the bot to read images (via Claude vision), download videos, and inline text files from Slack messages. Media files are saved to a temp directory so the agent can upload them to GitHub issues. Includes per-file type handling, size/count guards, skip notifications, and an in-memory download cache with TTL-based eviction. * Fix path traversal, filename collisions, and unbounded cache - Use basename() to strip directory segments from Slack filenames - Prefix temp files with index to prevent overwrites on duplicate names - Cap media cache at 100 entries, evicting oldest when exceeded
1 parent bd5b58e commit a3f1d24

File tree

5 files changed

+225
-17
lines changed

5 files changed

+225
-17
lines changed

prompts/system.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,18 @@ You read Slack thread conversations and respond to whatever is being asked. You
2121
- Summarize threads
2222
- Search for related issues or PRs
2323
- Answer questions about code or repositories
24+
- Analyze images shared in Slack threads (screenshots, diagrams, error messages, etc.)
25+
- Upload images and videos to GitHub issues or comments when requested
2426
- Give opinions or suggestions
2527
- Any other task the user requests
2628

29+
## Media handling
30+
31+
- **Images** shared in Slack threads are visible to you for visual analysis. They are also saved to temp paths listed in the "Attached Media" section so you can upload them to GitHub.
32+
- **Videos** are saved to disk but cannot be analyzed visually. You can upload them to GitHub issues/comments when asked.
33+
- **Text files** (plain text, markdown) have their content inlined in the thread text. They are also saved to disk for upload.
34+
- When uploading media to GitHub, use the file paths listed in the "Attached Media" section with `gh` CLI commands.
35+
2736
Your response will be posted back to the Slack thread — keep it concise and well-formatted for Slack.
2837

2938
## Attribution

src/agent.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { readFileSync, writeFileSync, existsSync } from "node:fs";
2-
import { join, dirname } from "node:path";
1+
import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
2+
import { join, dirname, basename } from "node:path";
3+
import { tmpdir } from "node:os";
34
import { fileURLToPath } from "node:url";
45
import { EventEmitter } from "node:events";
6+
import type { ImageContent } from "@mariozechner/pi-ai";
7+
import type { MediaAttachment } from "./slack.js";
58
import {
69
createAgentSession,
710
SessionManager,
@@ -31,6 +34,9 @@ interface AgentConfig {
3134

3235
export interface RunOptions {
3336
threadContent: string;
37+
images?: MediaAttachment[];
38+
videos?: MediaAttachment[];
39+
files?: MediaAttachment[];
3440
dryRun?: boolean;
3541
triggeredBy?: string;
3642
events?: EventEmitter;
@@ -130,6 +136,8 @@ export async function runAgent(options: RunOptions): Promise<RunResult> {
130136
}
131137

132138
const { threadContent, dryRun, triggeredBy, events } = options;
139+
const images = options.images ?? [];
140+
const allMedia = [...images, ...(options.videos ?? []), ...(options.files ?? [])];
133141
const effectiveModelId = options.model || modelId;
134142
const sessionManager = SessionManager.inMemory();
135143

@@ -166,8 +174,25 @@ export async function runAgent(options: RunOptions): Promise<RunResult> {
166174
tools: createCodingTools(cwd),
167175
});
168176

177+
let tempDir: string | undefined;
178+
169179
try {
170-
const prompt = buildPrompt(threadContent, dryRun, triggeredBy);
180+
let mediaPaths: string[] | undefined;
181+
182+
if (allMedia.length > 0) {
183+
tempDir = mkdtempSync(join(tmpdir(), "slack-media-"));
184+
mediaPaths = allMedia.map((attachment, i) => {
185+
const filePath = join(tempDir!, `${i}-${basename(attachment.filename)}`);
186+
writeFileSync(filePath, Buffer.from(attachment.data, "base64"));
187+
return filePath;
188+
});
189+
}
190+
191+
const imageContents: ImageContent[] | undefined = images.length > 0
192+
? images.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType }))
193+
: undefined;
194+
195+
const prompt = buildPrompt(threadContent, dryRun, triggeredBy, mediaPaths);
171196

172197
if (events) {
173198
subscribeToTextDeltas(session, events);
@@ -176,7 +201,10 @@ export async function runAgent(options: RunOptions): Promise<RunResult> {
176201
console.log("[agent] running prompt...");
177202
console.log("[agent] prompt:", prompt.slice(0, 200));
178203
console.log("[agent] model:", effectiveModelId);
179-
await session.prompt(prompt);
204+
if (mediaPaths) {
205+
console.log(`[agent] media: ${mediaPaths.length} files (${imageContents?.length ?? 0} images for vision)`);
206+
}
207+
await session.prompt(prompt, imageContents ? { images: imageContents } : undefined);
180208

181209
const messageCount = session.messages.length;
182210
console.log("[agent] messages in session:", messageCount);
@@ -195,6 +223,13 @@ export async function runAgent(options: RunOptions): Promise<RunResult> {
195223
console.log("[agent] result length:", text.length);
196224
return { text, cost, tokens };
197225
} finally {
226+
if (tempDir) {
227+
try {
228+
rmSync(tempDir, { recursive: true, force: true });
229+
} catch (err) {
230+
console.error("[agent] Failed to clean up temp dir:", err);
231+
}
232+
}
198233
session.dispose();
199234
}
200235
}

src/prompt.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export function buildPrompt(threadContent: string, dryRun?: boolean, triggeredBy?: string): string {
1+
export function buildPrompt(threadContent: string, dryRun?: boolean, triggeredBy?: string, mediaPaths?: string[]): string {
22
const dryRunNotice = dryRun
33
? "IMPORTANT: Do not execute any commands. Just describe what you would do.\n\n"
44
: "";
@@ -7,5 +7,9 @@ export function buildPrompt(threadContent: string, dryRun?: boolean, triggeredBy
77
? `Triggered by: ${triggeredBy}\n\n`
88
: "";
99

10-
return `${dryRunNotice}${attribution}## Slack Thread\n\n<slack-thread>\n${threadContent}\n</slack-thread>`;
10+
const mediaSection = mediaPaths?.length
11+
? `\n\n## Attached Media\n\nThe following files from the Slack thread are saved to disk for upload:\n${mediaPaths.map((p) => `- ${p}`).join("\n")}\n`
12+
: "";
13+
14+
return `${dryRunNotice}${attribution}## Slack Thread\n\n<slack-thread>\n${threadContent}\n</slack-thread>${mediaSection}`;
1115
}

src/slack.ts

Lines changed: 153 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,34 @@ import type { Config } from "./config.js";
44
import { runAgent, syncAuth, REVIEW_MODEL, PR_URL_PATTERN, REVIEW_KEYWORD_PATTERN } from "./agent.js";
55
import { AgentScheduler } from "./concurrency.js";
66

7+
export interface MediaAttachment {
8+
data: string; // base64
9+
mimeType: string;
10+
filename: string;
11+
}
12+
13+
export interface ThreadData {
14+
text: string;
15+
images: MediaAttachment[];
16+
videos: MediaAttachment[];
17+
files: MediaAttachment[];
18+
}
19+
20+
const IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
21+
const VIDEO_MIMES = new Set(["video/mp4", "video/quicktime", "video/webm"]);
22+
const TEXT_MIMES = new Set(["text/plain", "text/markdown"]);
23+
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB
24+
const MAX_FILES_PER_THREAD = 10;
25+
const MAX_CACHE_ENTRIES = 100;
26+
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
27+
28+
interface CachedAttachment {
29+
attachment: MediaAttachment;
30+
cachedAt: number;
31+
}
32+
33+
const mediaCache = new Map<string, CachedAttachment>();
34+
735
const nameCache = new Map<string, string>();
836

937
export async function startSlackBot(config: Config): Promise<void> {
@@ -38,13 +66,16 @@ export async function startSlackBot(config: Config): Promise<void> {
3866
const submission = scheduler.submit(threadTs, async () => {
3967
await react("rl-bonk-doge");
4068

41-
const threadContent = await fetchThread(client, event.channel, threadTs);
69+
const threadData = await fetchThread(client, event.channel, threadTs, config.slackBotToken);
4270
const isReview =
43-
PR_URL_PATTERN.test(threadContent) ||
71+
PR_URL_PATTERN.test(threadData.text) ||
4472
REVIEW_KEYWORD_PATTERN.test(text);
4573
const model = isReview ? REVIEW_MODEL : undefined;
4674
const { text: response, cost, tokens } = await runAgent({
47-
threadContent,
75+
threadContent: threadData.text,
76+
images: threadData.images,
77+
videos: threadData.videos,
78+
files: threadData.files,
4879
triggeredBy: userName,
4980
model,
5081
});
@@ -161,7 +192,8 @@ async function fetchThread(
161192
client: WebClient,
162193
channel: string,
163194
threadTs: string,
164-
): Promise<string> {
195+
botToken: string,
196+
): Promise<ThreadData> {
165197
const reply = await client.conversations.replies({
166198
channel,
167199
ts: threadTs,
@@ -180,11 +212,121 @@ async function fetchThread(
180212
}),
181213
);
182214

183-
return messages
184-
.map((m) => {
185-
const name = userNames.get(m.user || "") || "unknown";
186-
const ts = m.ts ? new Date(parseFloat(m.ts) * 1000).toISOString() : "";
187-
return `[${name}] (${ts}): ${m.text || ""}`;
188-
})
189-
.join("\n");
215+
const images: MediaAttachment[] = [];
216+
const videos: MediaAttachment[] = [];
217+
const files: MediaAttachment[] = [];
218+
let totalFiles = 0;
219+
220+
// Evict expired cache entries, then cap size by removing oldest
221+
const now = Date.now();
222+
for (const [key, entry] of mediaCache) {
223+
if (now - entry.cachedAt > CACHE_TTL_MS) mediaCache.delete(key);
224+
}
225+
while (mediaCache.size > MAX_CACHE_ENTRIES) {
226+
const oldest = mediaCache.keys().next().value!;
227+
mediaCache.delete(oldest);
228+
}
229+
230+
const textLines: string[] = [];
231+
232+
for (const m of messages) {
233+
const name = userNames.get(m.user || "") || "unknown";
234+
const ts = m.ts ? new Date(parseFloat(m.ts) * 1000).toISOString() : "";
235+
let line = `[${name}] (${ts}): ${m.text || ""}`;
236+
237+
const msgFiles = (m as any).files as any[] | undefined;
238+
if (msgFiles) {
239+
for (const file of msgFiles) {
240+
const mime: string = file.mimetype || "";
241+
const filename: string = file.name || "unnamed";
242+
const fileId: string = file.id || "";
243+
const size: number = file.size || 0;
244+
const url: string = file.url_private_download || "";
245+
246+
const isImage = IMAGE_MIMES.has(mime);
247+
const isVideo = VIDEO_MIMES.has(mime);
248+
const isText = TEXT_MIMES.has(mime);
249+
250+
if (!isImage && !isVideo && !isText) {
251+
line += `\n[skipped file: ${filename} — unsupported type ${mime}]`;
252+
continue;
253+
}
254+
255+
if (size > MAX_FILE_SIZE) {
256+
line += `\n[skipped file: ${filename} — exceeds 20MB limit]`;
257+
continue;
258+
}
259+
260+
if (totalFiles >= MAX_FILES_PER_THREAD) {
261+
line += `\n[skipped file: ${filename} — thread file limit reached]`;
262+
continue;
263+
}
264+
265+
if (!url) {
266+
line += `\n[skipped file: ${filename} — no download URL]`;
267+
continue;
268+
}
269+
270+
let attachment: MediaAttachment;
271+
const cached = fileId ? mediaCache.get(fileId) : undefined;
272+
if (cached && now - cached.cachedAt <= CACHE_TTL_MS) {
273+
attachment = cached.attachment;
274+
} else {
275+
try {
276+
attachment = await downloadSlackFile(url, mime, filename, botToken);
277+
if (fileId) {
278+
mediaCache.set(fileId, { attachment, cachedAt: now });
279+
}
280+
} catch (err) {
281+
console.error(`[slack] Failed to download file ${filename}:`, err);
282+
line += `\n[skipped file: ${filename} — download failed]`;
283+
continue;
284+
}
285+
}
286+
287+
totalFiles++;
288+
289+
if (isImage) {
290+
images.push(attachment);
291+
line += `\n[attached image: ${filename}]`;
292+
} else if (isVideo) {
293+
videos.push(attachment);
294+
line += `\n[attached video: ${filename}]`;
295+
} else if (isText) {
296+
files.push(attachment);
297+
const content = Buffer.from(attachment.data, "base64").toString("utf-8");
298+
line += `\n[attached file: ${filename}]\n--- file content ---\n${content}\n--- end file ---`;
299+
}
300+
}
301+
}
302+
303+
textLines.push(line);
304+
}
305+
306+
return {
307+
text: textLines.join("\n"),
308+
images,
309+
videos,
310+
files,
311+
};
312+
}
313+
314+
async function downloadSlackFile(
315+
url: string,
316+
mimeType: string,
317+
filename: string,
318+
botToken: string,
319+
): Promise<MediaAttachment> {
320+
const response = await fetch(url, {
321+
headers: { Authorization: `Bearer ${botToken}` },
322+
});
323+
if (!response.ok) {
324+
throw new Error(`HTTP ${response.status} downloading ${filename}`);
325+
}
326+
const buffer = Buffer.from(await response.arrayBuffer());
327+
return {
328+
data: buffer.toString("base64"),
329+
mimeType,
330+
filename,
331+
};
190332
}

test/prompt.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,22 @@ describe("buildPrompt", () => {
3838
const result = buildPrompt("hello");
3939
assert.ok(!result.includes("Triggered by:"));
4040
});
41+
42+
it("includes media paths when provided", () => {
43+
const paths = ["/tmp/slack-media-abc/screenshot.png", "/tmp/slack-media-abc/video.mp4"];
44+
const result = buildPrompt("hello", false, undefined, paths);
45+
assert.ok(result.includes("## Attached Media"));
46+
assert.ok(result.includes("/tmp/slack-media-abc/screenshot.png"));
47+
assert.ok(result.includes("/tmp/slack-media-abc/video.mp4"));
48+
});
49+
50+
it("omits media section when no paths provided", () => {
51+
const result = buildPrompt("hello");
52+
assert.ok(!result.includes("Attached Media"));
53+
});
54+
55+
it("omits media section when empty array provided", () => {
56+
const result = buildPrompt("hello", false, undefined, []);
57+
assert.ok(!result.includes("Attached Media"));
58+
});
4159
});

0 commit comments

Comments
 (0)