Adds a dashboard<->bot control plane (bot pushes state + polls commands, keeping
the bot's single outbound-HTTP direction):
- New bot_control.BotControl + endpoints: GET /api/bot/state, /api/bot/commands;
POST /api/bot/report, /api/bot/select.
- Dashboard header bar: bot identity/connection, server dropdown (top "없음"),
voice-channel dropdown (top "없음"), and live participant list.
- Turns record who spoke (Turn.speaker, via X-User-Name on the voice-turn POST).
- dave/bot.mjs: reports identity/guilds/voice-channels/members, polls join/leave
commands and joins dynamically, and sends the speaker's display name.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Log voice connection errors instead of letting them surface silently.
- Collapse bursty repeated receive-stream errors (DAVE E2EE group-transition
decrypt failures) into one line + a suppressed-count summary, so a member
joining/leaving no longer floods the log.
Deploy: voice-server now runs as the wsai-voice.service user unit (STT+Claude
Haiku brain+TTS on GPU); the bot container reaches it via
host.docker.internal:8787.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Raise the voice-Ready ceiling 20s->40s: the DAVE/MLS handshake cycles
signalling<->connecting and can take ~25s, so 20s spuriously failed the join.
- Handle AudioReceiveStream 'error' (e.g. a DAVE decrypt/UDP GenericFailure on
one packet): log and free the speaker slot instead of letting the unhandled
'error' event crash the whole bot process.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The bot (dave/bot.mjs) previously only joined the channel and counted audio
frames — it never fed STT or spoke back. Wire the real loop:
- Node bot: buffer each speaker's Opus->PCM utterance until AfterSilence,
wrap as WAV, POST to the Python voice-turn endpoint, then play the returned
reply wav into the channel via an AudioPlayer (ffmpeg->Opus). Skips its own
audio, dedupes overlapping subscriptions, and ignores sub-0.35s noise.
- Python: new `python -m wsai --voice-server` serves /api/voice-turn — decode
the uploaded utterance, GPU faster-whisper STT, produce a reply (echo of what
was heard for now), GPU MeloTTS synth, return the reply wav (recognised/reply
text ride along as X-Heard/X-Reply headers). Both engines pre-warmed; turns
show in the dashboard feed. MeloTTS.synth() extracted for direct wav reuse.
Echo mode verifies listening+speaking+GPU recognition entirely in Discord; the
Claude brain is the next slice. Verified the endpoint round-trip: utterance wav
-> correct Korean X-Heard/X-Reply + a WAVE reply on device=cuda. 12 tests pass,
node --check clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the user-token selfbot voice path with an official Discord bot using
discord.js 14 + @discordjs/voice 0.19. The bot logs in with the stored testbot
token, joins the target voice channel, passes the DAVE/MLS E2EE handshake, and
receives per-user Opus audio via VoiceReceiver (the STT input path). ToS-safe.
Live-verified: bot joined guild "사지방" / channel "일반" and reached Ready.
Selfbot (gate.mjs/join.mjs) kept only for the deferred screenshare-video track,
which official bots cannot receive. Docs updated (README/PLAN); M1 done on bot path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The RUN_MS auto-leave was only armed inside announceReady(), which requires a
fully successful join (DAVE/MLS op29/op30 -> mlsReady). When the E2EE handshake
stalls after op26 key_package, announceReady never fires, so the "time-boxed"
selfbot ran unbounded in a live channel. Arm RUN_MS at process startup instead,
independent of handshake state, and make leaveAndExit idempotent so the ceiling,
ready timer, and signal handlers can't double-fire.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
join.mjs joins the target voice channel over the proven DAVE handshake and stays
connected, mapping SPEAKING->ssrc and tallying incoming RTP (foundation for M2
audio decrypt). README records GPU=on, shared OAuth brain, natural-but-<=1s TTS,
Discord-voice STT input, and the M1..M6 milestones.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fail-fast checkpoint the arbiter mandated before committing to option A
(protocol-level selfbot stream receive). dave/gate.mjs proves, live against
Discord, that a user token can pass the voice DAVE/MLS handshake:
- voice GW v8 IDENTIFY with max_dave_protocol_version=1 -> NO close 4017
- dave_protocol_version=1 negotiated (E2EE active on the channel)
- @snazzah/davey drives full MLS membership: op25 external_sender -> op26
key_package -> op27 proposals -> op28 commit_welcome -> op29 announce_commit
-> MLS session ready=true, stable 5s, voicePrivacyCode derived
Confirms option A is viable: the selfbot can join the E2EE group as a full
member, which is the prerequisite for receiving+decrypting the video RTP.
PLAN.md updated with gate result, exact binary framing, and next A steps.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>