* fix(discord,slack): add SSRF policy for media downloads in proxy environments
Discord and Slack media downloads (attachments, stickers, forwarded
images) call fetchRemoteMedia without any ssrfPolicy. When running
behind a local transparent proxy (Clash, mihomo, Shadowrocket) in
fake-ip mode, DNS returns virtual IPs in the 198.18.0.0/15 range,
which the SSRF guard blocks.
Add per-channel SSRF policy constants—matching the pattern already
applied to Telegram on main—that allowlist known CDN hostnames and
set allowRfc2544BenchmarkRange: true.
Refs #25355, #25322
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(slack): keep raw-fetch allowlist line anchors stable
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* feat(slack): create thread sessions for auto-threaded DM messages
When replyToMode="all", every top-level message starts a new Slack thread.
Previously, only subsequent replies in that thread got an isolated session
(via 🧵<threadTs> suffix). The initial message fell back to the base
DM session, mixing context across unrelated conversations.
Now, when replyToMode="all" and a message is not already a thread reply,
the message's own ts is used as the threadId for session key resolution.
This gives the initial message AND all subsequent thread replies the same
isolated session.
This enables per-thread session isolation for Slack DMs — each new message
starts its own thread and session, keeping conversations separate.
* Slack: fix auto-thread session key mode check and add changelog
---------
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* fix(slack): reject HTML responses when downloading media
Slack sometimes returns HTML login pages instead of binary media when
authentication fails or URLs expire. This change detects HTML responses
by checking content-type header and buffer content, then skips to the
next available file URL.
* fix: format import order and add braces to continue statement
* chore: format Slack media tests
* chore: apply formatter to Slack media tests
* fix(slack): merge auth-header forwarding and html media guard
---------
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* fix(slack): use SLACK_USER_TOKEN when connecting to Slack (closes#26480)
* test(slack): fix account fixture typing for user token source
---------
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* fix(slack): honor direct replyToMode when thread label exists
ThreadLabel is a session/conversation label, not a reliable indicator
of an actual Slack thread reply. Using it to force replyToMode="all"
overrides replyToModeByChatType.direct="off" in DMs.
Switch to MessageThreadId which indicates a real thread target is
available, preserving expected behavior: thread replies stay threaded,
normal DMs respect the configured mode.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Slack: add changelog for threading tool context fix
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* feat: detect stale Slack sockets and auto-restart
Slack Socket Mode connections can silently stop delivering events while
still appearing connected (health checks pass, WebSocket stays open).
This "half-dead socket" problem causes messages to go unanswered.
This commit adds two layers of protection:
1. **Event liveness tracking**: Every inbound Slack event (messages,
reactions, member joins/leaves, channel events, pins) now calls
`setStatus({ lastEventAt, lastInboundAt })` to update the channel
account snapshot with the timestamp of the last received event.
2. **Health monitor stale socket detection**: The channel health monitor
now checks `lastEventAt` against a configurable threshold (default
30 minutes). If a channel has been running longer than the threshold
and hasn't received any events in that window, it is flagged as
unhealthy and automatically restarted — the same way disconnected
or crashed channels are already handled.
The restart reason is logged as "stale-socket" for observability, and
the existing cooldown/rate-limit logic (3 restarts/hour max) prevents
restart storms.
* Slack: gate liveness tracking to accepted events
---------
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* feat(slack): track thread participation for auto-reply without @mention
* fix(slack): scope thread participation cache by accountId and capture actual reply thread ts
* fix(slack): capture reply thread ts from all delivery paths and only after success
* Slack: add changelog for thread participation cache behavior
---------
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
* fix(slack): resolve replyToMode per-message using chat type
The Slack monitor resolved replyToMode once at startup from the
top-level config, ignoring replyToModeByChatType overrides. This caused
DM replies to be threaded even when replyToModeByChatType.direct was
set to "off".
Now the inbound message handler calls resolveSlackReplyToMode(account,
chatType) per-message — the same function already used by the outbound
dock and tool threading context — so per-chat-type overrides take
effect on the inbound path.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Slack: add changelog for per-message replyToMode resolution
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
- Codex P2: drain coroutine now only clears drainingTts if it's the
same instance (=== check), preventing a newer drain from being
unreachable by stopTts.
- Codex P2: set stopped=true on WebSocket onFailure so subsequent
sendText calls are rejected and stale state doesn't persist.
Agent events arrive on multiple threads concurrently. A stale event
with shorter accumulated text was falsely triggering 'text diverged',
causing the streaming TTS to restart with a new WebSocket — resulting
in multiple simultaneous ElevenLabs connections (2-3 voices) and
eventual system TTS fallback when hasReceivedAudio was false.
Fix: if sentFullText.startsWith(fullText), the event is stale (we
already have this text), not diverged. Accept and ignore it.
- Codex P1: setSpeakerEnabled now syncs talkMode.setPlaybackEnabled
so muting the speaker works when ttsOnAllResponses is active.
- Codex P2: abandonAudioFocus() called in stopSpeaking to prevent
audio focus leak after TTS completes or is interrupted.
- Codex P1: streamAndPlayMp3 was computed but never called after PCM
failure. Now properly invoked as fallback.
- Codex P2: MicCaptureManager.speakAssistantReply now skipped when
TalkModeManager.ttsOnAllResponses is active, preventing both
pipelines from speaking the same assistant reply.
Bug fixes:
- @Synchronized on ElevenLabsStreamingTts.sendText/finish to prevent
sentFullText/sentTextLength races across OkHttp and caller threads
- Pre-set pendingRunId via onRunIdKnown callback before chat.send to
eliminate race where gateway events arrive before runId is stored
- Track drain coroutine as Job; cancel prior on rapid mic toggle to
prevent duplicate TTS and stale transcript sends
- Mic button disabled during 2s drain cooldown (micCooldown StateFlow)
Codex review fixes:
- Gate agent streaming TTS on sessionKey to prevent cross-session
audio leaks (P1)
- Clear ElevenLabs credentials when talk.provider is not elevenlabs;
gate streaming TTS on activeProviderIsElevenLabs (P2)
System TTS fallback fixes:
- Null streamingTts immediately in finishStreamingTts so next response
gets a fresh TTS instance
- Add hasReceivedAudio flag to ElevenLabsStreamingTts to detect when
WebSocket connects but returns no audio (invalid key, network error)
- Fall back to playTtsForText when streaming TTS produced no audio
- Track ttsJob to cleanly cancel prior playTtsForText on new response
- Re-throw CancellationException instead of cascading into fallback
attempts that also get cancelled
ChatController:
- final/aborted/error run events now trigger a history refresh regardless of
whether the runId is in pendingRuns; only delta events require the run to be
tracked (prevents voice-initiated responses from being silently dropped)
MicCaptureManager:
- Don't auto-send on onResults silence detection — accumulate transcript
segments and send when mic is toggled off, giving the recognizer time to
finish processing buffered audio
- Capture any partial live transcript if no final segments arrived (2s drain
window before stop)
- Join multi-segment transcripts with sentence-ending punctuation to avoid
run-on text sent to the gateway
TalkModeManager is instantiated lazily in NodeRuntime and drives ElevenLabs
streaming TTS for all assistant responses when the voice screen is active.
MicCaptureManager continues to own STT and chat.send; TalkModeManager is
TTS-only (ttsOnAllResponses = true, setEnabled never called).
- talkMode.ttsOnAllResponses = true when mic is enabled or voice screen active
- Barge-in: tapping the mic button calls stopTts() before re-enabling mic
- Lifecycle: PostOnboardingTabs LaunchedEffect + VoiceTabScreen onDispose both
call setVoiceScreenActive(false) so TTS stops cleanly on tab switch or
app backgrounding
- applyMainSessionKey wires the session key into TalkModeManager so it
subscribes to the correct chat session for TTS
Streams text to the ElevenLabs WebSocket API and plays audio in real-time
via AudioTrack (PCM 24kHz). Key design points:
- sendText(fullText) takes the full accumulated text and only transmits the
new suffix, detecting divergence for restart
- Chunks are queued if the WebSocket isn't yet connected; flushed in onOpen
- finish() sends EOS to ElevenLabs; deferred if called before onOpen fires
- sendText returns true (not false) when finished=true to avoid treating a
normal end-of-stream as a diverge restart
- finishStreamingTts coroutine uses identity check before nulling streamingTts
to prevent a mid-drain restart from orphaning a live TTS session
- eleven_v3 does NOT support WebSocket streaming; use eleven_flash_v2_5
* feat(ui): add hide-cron toggle to chat session selector
Adds a clock icon toggle button in the chat controls bar that filters
cron sessions out of the session dropdown. Default: hidden (true).
Why: cron sessions (key prefix `cron:`) accumulate fast — a job running
every 15 min produces 48 entries/day. They pollute the session selector
on small screens and devices like the Rabbit R1.
Changes:
- app-render.helpers.ts
- isCronSessionKey() — exported helper (exported for tests)
- countHiddenCronSessions() — counts filterable crons, skips active key
- resolveSessionOptions() — new hideCron param; skips cron: keys
unless that key is the currently active session (never drop it)
- renderCronFilterIcon() — clock SVG with optional badge count
- renderChatControls() — reads state.sessionsHideCron (default true),
passes hideCron to resolveSessionOptions, adds toggle button at the
end of the controls bar showing hidden count as a badge
- app-view-state.ts — adds sessionsHideCron: boolean to AppViewState
- app.ts — @state() sessionsHideCron = true (persists across re-renders)
- app-render.helpers.node.test.ts — tests for isCronSessionKey
* fix(ui): harden cron session filtering and i18n labels
---------
Co-authored-by: FLUX <flux@openclaw.ai>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>