') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(discord): carry media through the sidecar bridge both ways by Castrozan · Pull Request #19 · Castrozan/clawde · GitHub
Skip to content

feat(discord): carry media through the sidecar bridge both ways - #19

Merged
Castrozan merged 4 commits into
mainfrom
discord-sidecar-media
Aug 25, 2026
Merged

feat(discord): carry media through the sidecar bridge both ways#19
Castrozan merged 4 commits into
mainfrom
discord-sidecar-media

Conversation

@Castrozan

@CastrozanCastrozan commented Aug 25, 2026

Copy link
Copy Markdown
Owner

A Discord message whose whole content was a video, an image or a sticker reached a sidecar agent as an empty prompt: the bridge passed only message.clean_content. Monster answered one such video with "Tsc. Até pra falar você travou, criatura.", reading the silence as the person freezing mid-sentence. Sidecar is the transport for every codex and opencode channel agent; the embedded plugin transport used by claude agents already carried media both ways.

Inbound

channel_message/inbound_media.py downloads each attachment into <state-directory>/inbox/<message-id>/ and appends a <discord-media> block naming every attachment and sticker. A downloaded file is named by absolute path so the agent can open it; anything over 25 MB is named by URL instead; a failed download still reports that the attachment arrived. Attachment file names are sanitized, so a crafted name cannot write outside the inbox.

A message carrying only content the bridge still drops now runs no turn at all, rather than spending one on an empty prompt and drawing the same nonsense answer.

Outbound

channel_message/outbound_reply.py treats a reply line that is nothing but an absolute path to a file inside the agent's own workspace as an attachment: the line is stripped from the message and the file is uploaded with the first chunk. This is what makes clawde-agent-voice-note and clawde-agent-gif-search usable from a sidecar agent at all, since a sidecar has no reply tool and no files argument. Paths outside the workspace, symlinks escaping it, files over 25 MB and everything past Discord's ten-attachment limit are refused and logged rather than uploaded.

Retention

channel_message/inbox_retention.py keeps the downloads from becoming unbounded disk that a guest controls: two day retention, and oldest-first eviction once the inbox passes one gigabyte.

discord-runtime-sidecar.md documents both directions for the agent.

Verification

nix build .#checks.aarch64-darwin.unit-tests: 646 passed, including 33 new tests. Three of them drive on_message end to end: a video-only message saves its file and names it in the prompt, a reply line naming a workspace file leaves as an attachment, and an unrenderable message never reaches the harness. formatting and discord-transport-eval are green too.

A message whose whole content was an attachment or a sticker reached a
sidecar agent as an empty prompt, because the bridge passed only
clean_content, and the agent answered as if the person had said nothing.
Downloaded attachments now land in a per-message inbox under the agent's
channel state directory and are named to the agent by absolute path,
with oversized ones offered by URL instead.
The same channel carried nothing back: a sidecar agent had no reply tool
and no files argument, so every path printed by a media tool was dead
text. A reply line that is nothing but a path to a file inside the
agent's own workspace is now stripped from the message and uploaded as
an attachment.
Agent-Machine: kira
Agent-Resume: claude --resume efee1316-8d94-438e-951e-e9c978626531
Attachments and stickers now reach the agent, but a message carrying
only content the bridge still drops would arrive as an empty prompt and
draw the same nonsense answer. The bridge logs it and runs no turn.
The bridge-level tests exercise the whole path: a video-only message
saves its file and names it in the prompt, and a reply line naming a
workspace file leaves as an attachment.
Agent-Machine: kira
Agent-Resume: claude --resume efee1316-8d94-438e-951e-e9c978626531
…orever
Downloading every attachment a guild posts turned the channel state
directory into unbounded disk, which a guest controls: nothing capped
the total and the seven day window only slowed it down. Retention drops
to two days and the inbox now evicts its oldest messages once it passes
one gigabyte.
The retention policy moves into its own module, since deciding what to
keep on disk is not the same job as describing what arrived.
Agent-Machine: kira
Agent-Resume: claude --resume efee1316-8d94-438e-951e-e9c978626531
Discord's upload ceiling depends on the guild's boost level, so a file
this bridge considers small enough is still rejected at 413, and the
exception took the agent's text down with it. The upload failure is
logged and the text goes out on its own.
Agent-Machine: kira
Agent-Resume: claude --resume efee1316-8d94-438e-951e-e9c978626531
@Castrozan
Castrozan merged commit ef1af90 into mainAug 25, 2026
3 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Castrozan