Repository navigation
Conversation
… session
The Discord bridge already pushes image attachments into a live discord-voice
session (_push_vision_image / vision_push.py) but not text. discord-voice-server
has no messageCreate handler, so any message the bridge delivers into a voice
channel — including a results/ body routed there via [channel: <id>] — is never
seen by the voice agent.
This closes that gap, mirroring the image path:
- src/voice_text_push.py — new helper analogous to vision_push.py. urllib POST
to a localhost control server, GET /voice/state for liveness + which channel
is active, POST /voice/text to inject. Fail-soft on every error path
(URLError / ConnectionRefused / non-200) — a push failure returns False, never
raises. No-op when no session is active for the target channel.
- discord-voice-server.ts — adds a tiny localhost-only text control server
(port 7848, env DISCORD_VOICE_CONTROL_PORT), mirroring vision-tools.ts's
startVisionControlServer: localhost-bound, JSON, fail-soft on EADDRINUSE.
/voice/text injects via transport.sendContent([{role:'user',text}], false) —
the same mechanism vision-tools uses for context hints and the resultQueue
drain uses for task results. turnComplete=false → context-only, no forced
generation. Started after the session is live, closed on shutdown.
- discord-bridge.py — wires _push_voice_text into both outbound delivery paths
(poll_results normal/redirected delivery, poll_dm_fallback channel-redirect)
after the message is sent to the channel. The trigger case: a results/ body
routed via [channel: <voice-channel-id>] to a channel running discord-voice.
Purely additive: channels without an active voice session are unchanged. A push
failure (no session, WS error, server down) never breaks message delivery.
Verified: py_compile clean on both .py files; npm run typecheck passes; helper
fail-soft confirmed with no server listening; end-to-end against a mock control
server confirms matching-channel push, non-matching-channel no-op, and
sessionReady=false no-op. discord-bridge test suite passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
liususan091219
left a comment
There was a problem hiding this comment.
Reviewed the bridge-text → voice-session push. Well-architected — it mirrors the proven image-push path rather than inventing a new mechanism, and it's genuinely fail-soft. Mergeable; two non-blocking points worth a look.
Things I like:
- It mirrors the
vision_push.py/ vision-tools control-server architecture exactly — same localhost-HTTP shape, same fail-soft contract. Reusing a proven pattern rather than a bespoke one keeps the discord-voice integration surface coherent. - Fail-soft is airtight on every layer:
voice_text_push.push_textreturnsFalseon URLError/ConnectionRefused/non-200, the bridge call sites inpoll_results/poll_dm_fallbackare wrapped intry/except, andinjectVoiceTextguards!s || s.closing, missingtransport, non-functionsendContent, andisConnected === falsebefore a try/catch. A push failure can never break channel delivery — exactly the property the image path already has. turnComplete=falseis the right call — pushed text lands as conversation context, not a forced generation. Matches how vision-tools injects screen-share hints and how theresultQueuedrain injects task results.- The channel-match gate in
push_text(active != target→ no-op) means text only injects into a session actually serving that channel — no cross-channel leakage. - EADDRINUSE handling leaves
voiceControlServer = nullso shutdown never closes a port another process owns. That's the correct defensive choice and it's commented. - Port choice is documented inline (7845 screen-capture / 7846 cred-proxy / 7847 vision-control / 7848 free) and env-overridable.
Non-blocking observations:
- No committed test. The PR body describes solid manual verification — fail-soft with no server, end-to-end against a mock control server, channel-match/non-match,
sessionReady=false, int vs str channel ids. That's exactly the behavior a committed test should pin. The sibling security PRs in this batch (#1029, #1023, #1022) all ship tests; a smalltests/voice-text-push.test.pycodifying the/voice/state+/voice/textcontract and the channel-match gate would protect this from a silent regression. Worth adding. - Fixed port vs. multi-instance.
VOICE_CONTROL_PORTdefaults to 7848 andvoice_text_push.pyreads the sameDISCORD_VOICE_CONTROL_PORTenv. Onmain(single discord-voice process) that's correct. But if two discord-voice-server processes ever run on one host, the second hits EADDRINUSE and skips its server — and the bridge can only ever reach whichever instance grabbed 7848; the other's channel gets a silent no-op. Not a bug formain, but the multi-instance discord-voice work exists on a branch, so flagging the linkage: a per-instance port (and a way for the bridge to pick the right one per channel) would be needed there.
Other notes:
(s.voiceSession as any)?.transportreaches into bodhi internals viaas any— but it's the same internal-access pattern vision-tools and the resultQueue drain already use, and thetypeof sendContent !== 'function'guard degrades gracefully if bodhi ever renames the field. Acceptable.- The unauthenticated localhost-bound control server is the same trust model as the :7847 vision server and :7845 screen-capture — consistent, no new exposure.
Cross-PR: sits alongside #1026 (operational-log tee) and #1027 (health probe) — three PRs all closing the "discord-voice is under-integrated vs the other surfaces" gap. #1026 and this PR both insert a block into discord-voice-server.ts near the cleanup section; whichever merges second should expect a trivial textual rebase.
Solid, additive, correctly fail-soft. The test gap (obs 1) is the one thing I'd genuinely like closed before merge. COMMENT.
— Lucy (Mac Studio bot)
|
Superseded — owner pivoted the za-warudo context handoff to a per-channel |
…dirs When the same skill directory name (e.g. screen-companion) exists in both the repo skills/ and workspace skills/, both were appended into the tool list, triggering assertUniqueToolNames and crashing the voice session at startup. Fixes sonichi#1030. Replace bare arrays with Maps keyed by dirName so later directories (workspace, private) silently overwrite earlier ones (repo) — the same last-write-wins semantics loadCoreDocumentedSkills already used. 5 tests in tests/skill-loader-dedup.test.ts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The gap
The Discord bridge already pushes image attachments into a live discord-voice (Gemini Live) session —
_push_vision_image/src/vision_push.py, called from the on-message attachment handler. It does not push text.discord-voice-server.tshas nomessageCreatehandler — it never reads text posted in its voice channel. So any message the bridge delivers into a voice channel — including aresults/body routed there via the[channel: <id>]redirect marker — is never seen by the voice agent.The change
Mirrors the image path exactly:
src/voice_text_push.py— new helper analogous tovision_push.py.urllibPOST to a localhost control server:GET /voice/statefor liveness + which channel is active,POST /voice/textto inject. Fail-soft on every error path (URLError/ConnectionRefused/ non-200) — returnsFalse, never raises. No-op when no session is active for the target channel (channel-match gate, on top ofvision_push.py'sis_voice_ready-equivalent check).discord-voice-server.ts— adds a tiny localhost-only text control server (port7848, envDISCORD_VOICE_CONTROL_PORT), mirroringvision-tools.ts'sstartVisionControlServer: localhost-bound, JSON, fail-soft onEADDRINUSE(another server owns the port)./voice/textinjects viatransport.sendContent([{role:'user', text}], false)— the same mechanismvision-tools.tsuses for screen-share context hints and the discord-voiceresultQueuedrain uses for task results.turnComplete=false→ context-only, no forced generation. Started after the session is live, closed on shutdown.(The discord-voice-server runs its own
VoiceSession; it does not — and the vision-tools control server on:7847is owned byvoice-agent.ts, not discord-voice-server — so a matching control server on the discord-voice side is the transport that lets the bridge reach a discord-voice session.transport.sendContentis the proven text-inject path the session already uses internally.)discord-bridge.py— wires_push_voice_textinto both outbound delivery paths (poll_resultsnormal/redirected delivery;poll_dm_fallbackchannel-redirect) after the message is sent to the channel. The trigger case: aresults/body routed via[channel: <voice-channel-id>]to a channel where discord-voice is running — the text lands in the voice agent's session.Fail-soft / additive
Purely additive — a channel without an active voice session is delivered exactly as before. A push failure (no session, WS error, server down, port unbound) returns
Falseand is wrapped intry/exceptat the call site; it can never break message delivery or crash the bridge — exactly like the image path.Verification
python3 -m py_compile src/discord-bridge.py src/voice_text_push.py— clean.npm run typecheck— passes.push_text→False, no exception)./voice/state+/voice/textcontract: matching-channel push delivers, non-matching-channel is a no-op,sessionReady=falseis a no-op, int and str channel ids both work.discord-bridgetest suite passes (the one unrelatedslack-bridge-allowlistfailure is pre-existing —slack-bridge.pyusesstr | Noneon Python 3.9; confirmed identical onmain).🤖 Generated with Claude Code