fix(slack): make attachments visible in thread/channel history; pass all mention images - #255
Conversation
…all mention images Thread and channel history serialized only msg.text, so a file-only message (an uploaded screenshot, invoice, PDF) rendered as an empty line — when a user asked "@bot summarize the invoices above", the bot could not even tell files existed and had to ask for a re-upload. - fetchThreadHistory / fetchChannelHistory now parse files[] and render one note per attachment: downloaded ones as [image saved: /path] / [file "name" saved: /path] (reusing downloadSlackFile, newest-first, capped at 6 per fetch to bound latency), voice as a marker, the rest as [attachment "name" (type) — not downloaded] so nothing is invisible. - History blocks append a one-line instruction to read saved paths when any attachment was downloaded. - Triggering messages now pass the first 5 attached images to the prompt instead of only imageFiles[0]. - New unit tests for the pure helpers (tests/slack-history.test.ts); suite result unchanged apart from the 9 new passing tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TerrysPOV
left a comment
There was a problem hiding this comment.
Genuinely useful fix — file-only Slack messages rendering as blank [User]: lines is a real bug, and the implementation is careful: path handling is safe (downloads reuse the hardened downloadSlackFile, keyed on Slack's file.id not the attacker-supplied file.name, with the extension allowlist and 25 MB cap intact — #185's hardening is preserved), newest-first cap logic is correct, text-only messages are unchanged, and you included real behavioural tests. The 5-image mention change is clean.
One thing needs addressing before this can ship, plus the version.
Code review
- The new history rendering feeds attacker-controllable content into the prompt unfenced, and instructs the model to read attacker-authored files directly. Both history helpers append
(Attachments … saved to local paths — read the relevant ones directly before answering.)and inject the transcript with only plain--- Thread History ---delimiters:
claudeclaw/src/commands/slack.ts
Lines 624 to 633 in 73243a2
sanitizeUserInput only strips ClaudeClaw's own [bracket] directives — it does not neutralize free-form injection prose, and it can't touch the contents of a downloaded screenshot/PDF. slack.ts never uses wrapUntrusted, so unlike discord.ts / telegram.ts this history text lands in the prompt outside the <untrusted-…> fence that the runner's anti-injection system prompt actually keys on. The feature auto-downloads up to 6 attachments from any participant across recent history and then tells the model to open them — so a member who uploads an image/PDF whose visible text says "ignore prior instructions and …" now gets that content read as part of answering an unrelated request. This widens the existing surface (Slack was already unfenced) rather than opening a new class, but it's a real widening — and notably it's less guarded than the sibling channel-read path already in this file, which does label its content:
claudeclaw/src/commands/slack.ts
Line 1303 in 73243a2
Please wrap the rendered history + attachment notes in wrapUntrusted(...) the way discord/telegram wrap user content (that's the mechanism the runner's fence depends on), or at minimum carry the same [… untrusted external content …] labeling that line 1303 uses, and soften the bare "read the relevant ones directly before answering" imperative for multi-user historical attachments.
- Version is stale — targets 1.0.41 but master is 1.0.43, which is the only thing making the PR conflict (slack.ts merges clean). Rebase and re-bump both
.claude-plugin/*.jsonto 1.0.44.
Non-blocking:
HISTORY_FILE_DOWNLOAD_CAPbounds successful downloads but not attempts —downloaded++only fires on success, so a history full of failing or oversized attachments will still attempt a fetch per file, each buffering up to 25 MB viaarrayBuffer()before the post-read size check rejects. Bounded, but a latency/resource vector on the low-spec machines the project targets; consider capping attempts.formatHistoryFileNotedoes(f.name ?? f.id).slice(0, 80), which throws if a file has neither field — inconsistent with the nearby!f.idguard. A?? ""fallback hardens it (Slack always sendsid, so low severity).historyHasSavedAttachmentmatches the literal substring" saved: "anywhere in the rendered text, so a user message containing that string triggers the "read the relevant ones" advisory spuriously. Cosmetic.
Problem
fetchThreadHistoryandfetchChannelHistoryserialize onlymsg.text— thefilesfield is never parsed. A message that is just a file upload (a screenshot, an invoice photo, a PDF) renders as an empty[User Uxxx]:line, so the model cannot even tell a file existed.Real-world failure: a user posts several invoice images in a channel, then mentions the bot with "summarize the invoices above". The bot fetches history, sees blank lines, and answers "I don't see any invoice — please re-upload into this thread". The information was there the whole time.
A related gap on the triggering message itself: only
imageFiles[0]is downloaded, so a mention carrying several images silently drops all but the first.Change
files[]and render one note per attachment:[image saved: /path]/[file "name" saved: /path]— reuses the existingdownloadSlackFile(same inbox dir, same 25MB cap and extension allowlist), newest-first, capped atHISTORY_FILE_DOWNLOAD_CAP = 6per fetch to bound latency on low-spec machines;[voice message — not transcribed](no transcription cost for history);url_private) →[attachment "name" (type) — not downloaded]— still visible, so the model can ask for it.sanitizeUserInput, same as message text.Tests
tests/slack-history.test.tscovers the pure helpers (formatHistoryFileNote,appendFileNotes, the instruction line informatThreadHistoryAsContext), including the headline case: a file-only message no longer renders as an empty line. Suite result is unchanged apart from the 9 new passing tests (the 3 pre-existingsessionFilesfailures fail identically on master without this change).Version bumps included per CONTRIBUTING (
bump:plugin-version/bump:marketplace-version→ 1.0.41).Deliberately out of scope
Rate-limit-aware caching/429 handling for history fetches: Slack's 2025 non-Marketplace terms throttle
conversations.history/replieshard (~1 req/min, 15/page) for affected apps, which argues for a per-channel cache +Retry-Afterhandling around these fetchers. We run that in production as a downstream patch, but it's opinionated enough that it felt like a separate discussion — happy to follow up with a second PR if there's interest.🤖 Generated with Claude Code