Root causes:
1. Multiple plugin subsystems each called startModeratorPresence,
creating competing WebSocket connections to the same bot token.
Discord only allows one connection per bot → 4008 rate limit →
infinite reconnect loop (1000+ connects → token reset by Discord)
2. Invalid session (op 9) handler called scheduleReconnect, but the
new connection would also get kicked → cascading reconnects
Fixes:
- Singleton guard: startModeratorPresence is a no-op if already started
- cleanup() nullifies old ws handlers before creating new connection
- Stale ws check: all callbacks verify they belong to current ws
- Exponential backoff with cap (max 60s) instead of fixed 2-5s delay
- heartbeat ACK tracking: detect zombie connections
- Non-recoverable codes (4004) properly stop all reconnection
Turn order should be enforced for ALL messages, not just non-human ones.
Previously, human messages bypassed turn check because they go through
human_list_sender path with shouldUseNoReply=false. Now turn check
always runs when channel has turn state.
Use Node.js built-in WebSocket to maintain a minimal Discord Gateway
connection for the moderator bot, keeping it 'online' with a
'Watching Moderating' status. Handles heartbeat, reconnect, and resume.
Also fix package-plugin.mjs to include moderator-presence.ts in dist.
Add a dedicated moderator Discord bot that sends handoff messages when
the current speaker says NO_REPLY. This solves the wakeup problem.
Flow:
1. Agent A is current speaker, receives message
2. Agent A responds with NO_REPLY
3. Plugin detects NO_REPLY in message_sent hook, advances turn to Agent B
4. Plugin sends via moderator bot: '轮到(@AgentB)了,如果没有想说的请直接回复NO_REPLY'
5. This real Discord message triggers Agent B's session
6. Turn manager allows Agent B to respond
Implementation:
- moderatorBotToken config field for the moderator bot's Discord token
- userIdFromToken() extracts Discord user ID from bot token (base64)
- resolveDiscordUserId() maps accountId → Discord user ID via account tokens
- sendModeratorMessage() calls Discord REST API directly
- message_received ignores moderator bot messages (transparent to turn state)
- Moderator bot is NOT in the turn order
Turn system redesign:
- Turn order auto-populated from config bindings (all bot accounts)
- No manual turnOrder config needed
- Humans (humanList) excluded from turn order automatically
- Dormant state: when all agents NO_REPLY in a cycle, currentSpeaker=null
- Reactivation: any new message wakes the system
- Human message → start from first in order
- Bot not in order → start from first
- Bot in order → next after sender
- Skip already-NO_REPLY'd agents when advancing
Identity injection:
- Group chat prompts now include agent identity
- Format: '你是 {name}(Discord 账号: {accountId})'
Other:
- Remove turnOrder from ChannelPolicy (no longer configurable)
- Add TURN-WAKEUP-PROBLEM.md documenting the NO_REPLY wake-up challenge
- Update message_received to call onNewMessage with proper human detection
- Update message_sent to call onSpeakerDone with NO_REPLY tracking
getLastChar used t[t.length-1] which only gets the trailing surrogate
of emoji like 🔚 (U+1F51A, a surrogate pair in UTF-16). This meant
end symbol matching ALWAYS failed for emoji symbols, causing every
non-humanList message to hit rule_match_no_end_symbol -> no-reply.
Fix: use Array.from(t) to correctly split by Unicode code points.
- buildEndMarkerInstruction() replaces hardcoded END_MARKER_INSTRUCTION,
dynamically using the resolved policy's endSymbols
- Instruction now explicitly exempts gateway keywords (NO_REPLY, HEARTBEAT_OK)
from requiring end symbols
- Export resolvePolicy from rules.ts for reuse in before_prompt_build hook
getLastChar was checking the last character of the full event.prompt,
which includes Conversation/Sender metadata blocks appended by OpenClaw
after the actual message. This meant end symbols like 🔚 at the end of
the message body were invisible — the last char was always backtick or
whitespace from the metadata JSON block.
Fix: strip trailing '(untrusted metadata)' blocks before extracting
the last character. This only affects non-humanList senders (humanList
senders bypass end symbol check via human_list_sender reason).
1. DM bypass: when neither senderId nor channelId can be extracted from
the prompt (DM sessions lack untrusted conversation info), skip the
no-reply gate and allow the message through with end-marker injection.
2. Tool visibility: change whispergateway_tools registration from
optional=true to optional=false so all agents can see the tool
without needing explicit tools.allow entries.
- Add default values for enableDiscordControlTool, enableWhispergatePolicyTool,
discordControlApiBaseUrl, enableDebugLogs, debugLogChannelIds
- Merge defaults in both baseConfig and getLivePluginConfig
- Fixes issue where whispergateway_tools tool was not exposed due to missing
config fields in openclaw.json
Root cause: PluginHookAgentContext in before_model_resolve only has
agentId, sessionKey, sessionId, workspaceDir, messageProvider.
senderId, channelId, input are NOT available in this hook phase.
The plugin was reading ctx.senderId (undefined) -> inHumanList=false
for ALL Discord sessions -> shouldUseNoReply=true -> all silenced.
Fix: use event.prompt which contains the full user message including
the 'Conversation info (untrusted metadata)' JSON block, and extract
sender_id from there. Same fix applied to before_prompt_build.