Skip to content

fix: make channel/server mute enforceable end to end (mute hardening) - #12892

Merged
lalalune merged 2 commits into
developfrom
feat/dmf-mute
Jul 4, 2026
Merged

fix: make channel/server mute enforceable end to end (mute hardening)#12892
lalalune merged 2 commits into
developfrom
feat/dmf-mute

Conversation

@NubsCarson

Copy link
Copy Markdown
Member

mute hardening: make channel/server mute enforceable end to end

The core channel mute feature (ROOM action writes participant room_state; the message service drops MUTED rooms before the planner) exists but is structurally a no-op on real deployments. Five verified gaps on current develop, fixed as one cohesive change:

Gap 1 (blocker): the muted-room drop was bypassed by mentions

services/message.ts only dropped a MUTED room when the message was not a mention/reply and did not contain the agent's name. On a mention-gated deployment (DISCORD_SHOULD_RESPOND_ONLY_TO_MENTIONS=true, the default), strict mode already drops every non-mention at the connector (plugin-discord/messages.ts strict-mode gate), so every turn that reached the mute check was a mention — and mentions skipped the check. "Mute this channel" did literally nothing.

Fix: the drop now goes through resolveEffectiveMuteState (new services/message/mute-state.ts) and is independent of addressing — a muted room drops even a direct @mention. Unmute happens from another room or DM via the ROOM action's existing cross-room targeting (platform + chatName).

Gap 2: no server/guild-wide mute

Participant mute is per-room only (the participants table FKs room_id to rooms, so a world id can't reuse that row shape). Server-wide mute lives on world.metadata.agentMuteState — the world record is where world-scoped agent state already lives (roles, settings) — and is consulted by the same resolver, so one op silences every channel of a guild, including channels created after the mute. ROOM gains scope: "room" | "server" (mute/unmute only).

Gap 3: muted state was invisible

list_channels output now carries a per-channel muted flag (summary line counts them: Listed 12 channels from Discord (1 muted)), and list_connections carries mutedRoomCount per connection — "which channels are you muted in" is now answerable.

Gap 4: timed mute was silently permanent

scheduleAutoUnmuteIso had zero consumers, and durationMinutes was ignored entirely on the default (current-room) path. Now the expiry is persisted (agentMuteUntilIso on room metadata / world metadata) and enforced by a due-check inside resolveEffectiveMuteState: the first inbound message at/after the ISO time auto-unmutes (participant state + metadata cleared) and processes normally. Fires on both the connector gate and the core gate — no separate scheduler to drift.

Gap 5: Discord channel gating was whitelist-only + boot-frozen

CHANNEL_IDS is parsed once at boot; the runtime dynamicChannelIds set is additive-only and in-memory. Instead of growing that mechanism, the Discord messageCreate listener now consults the persisted mute state before ingestion — a muted channel costs zero memory writes, zero embeddings, zero model calls. The muted set is runtime-mutable (ROOM action) and survives restarts (participants/worlds tables). Threads inherit their parent channel's mute. A failed lookup fails open with runtime.reportError (core's own gate still guards the planner).

Tests (all green)

  • mute-state.test.ts (15): room/server mute, expiry due-check writes, thread-parent inheritance, read-only list flags
  • message.mute-drop.test.ts (4): end-to-end through the real DefaultMessageService.handleMessage — a MUTED room ends the turn with status muted and zero model calls even with mentionContext.isMention=true; guild mute drops a mention in an unmuted child room; expired timed mute auto-unmutes and proceeds
  • room.mute.test.ts (6): durationMinutes persists the expiry + returns scheduleAutoUnmuteIso; scope=server writes world metadata and the sibling room drops via the resolver; preconditions + validate gating
  • message.list-muted.test.ts (3): muted flag in list_channels, guild-wide flags, mutedRoomCount in list_connections
  • discord-events-mute-gate.test.ts (6): muted channel drops a direct @mention before the debouncer; guild mute; thread parent; expired-mute auto-unmute; fail-open + reportError

Verification (real counts)

  • packages/core vitest: 3027 passed / 7 failed — all 7 fail identically with this branch stashed (submodule-dir callsite audit, SHELL tier surface, live-network media fetch); full suite otherwise green
  • plugins/plugin-discord vitest: 198 passed / 4 failed — the 4 are the same pre-existing live-network outbound-media tests, verified failing on pristine develop
  • tsgo typecheck: clean on both packages
  • audit:error-policy-ratchet: no new fallback-slop; the one new catch is error-policy:J7 annotated
  • biome: new files clean; edits to existing files keep file-local style

Runtime cost on the hot path: the core gate reuses the participant state already fetched for the LLM-off check; the world consult is 1–2 keyed lookups per message.

Follow-up (separate lane): app UI toggle surface for mute/unmute — filed as a [phone-ui] issue.

- muted-room drop no longer bypassed by mentions (strict-mode deployments made mute a no-op)
- server-wide mute via ROOM scope=server (world.metadata, consulted by the same inbound gate)
- durationMinutes persisted as agentMuteUntilIso; inbound due-check auto-unmutes at the ISO time
- muted flag in list_channels + mutedRoomCount in list_connections
- plugin-discord drops muted channels before ingestion (persisted, runtime-mutable, threads inherit parent mute)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: aea48fe2-7739-4c3f-8774-ca5c79e40dde

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/dmf-mute

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@lalalune
lalalune merged commit 37f9d96 into develop Jul 4, 2026
27 of 62 checks passed
@lalalune
lalalune deleted the feat/dmf-mute branch July 4, 2026 04:58

@lalalune lalalune left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the core resolver, ROOM action persistence, MESSAGE list visibility, and Discord pre-ingestion gate. The room/world ID mapping matches the existing Discord ensureConnection convention, the timed mute due-check clears the right stores, and the connector target fields line up with TargetInfo.

Local validation on the latest PR head ed9b7d9391fef553314fbaff0ce9c1a5766b8345:

  • git diff --check origin/develop...origin/pr/12892 passed
  • bunx biome check on all touched source/test files passed
  • bunx vitest run packages/core/src/services/message/mute-state.test.ts packages/core/src/services/message.mute-drop.test.ts packages/core/src/features/advanced-capabilities/actions/room.mute.test.ts packages/core/src/features/advanced-capabilities/actions/message.list-muted.test.ts plugins/plugin-discord/__tests__/discord-events-mute-gate.test.ts passed: 5 files, 34 tests

I initially found Biome formatting failures in the added files; the PR now includes a formatting commit and the rerun is clean.

@lalalune

lalalune commented Jul 4, 2026

Copy link
Copy Markdown
Member

Reviewed the full diff — this is sound and I've enabled auto-merge (merge-when-green). All 5 mute-enforcement gaps are correctly fixed: the mention-bypass drop now keys on muteState.muted independent of addressing (the real blocker), server-wide mute lives in world.metadata.agentMuteState with room-then-world precedence, timed mute lazy-auto-unmutes on first inbound at/after expiry, and the Discord boot-whitelist gate consults persisted state before ingestion (fail-open + J7). Server-scope roomOpAction is correctly roleGate:{minRole:'ADMIN'}, so no silencing abuse. Tests exercise the real DefaultMessageService.handleMessage path.

One behavior tradeoff worth confirming: in-channel unmute is impossible by design — a muted channel drops even an 'unmute' spoken inside it, so recovery requires unmuting from another room/DM (naming platform+chatName). Intentional and documented, but a real friction point for the un-mute UX; flagging for owner awareness. Also note the branch is ~9 commits behind develop — auto-merge will need it current.

lalalune pushed a commit to singlaamitesh/eliza that referenced this pull request Jul 7, 2026
…dropping persisted world metadata

list_servers returned raw connector worlds with no muted flag, and the
discord listing fabricated fresh World objects from the guild cache,
dropping persisted world.metadata — so a server-wide mute (agentMuteState,
elizaOS#12892) was invisible at the server level.

- core: resolveMutedWorldFlags resolves per-world mute (connector-carried
  metadata first, persisted world fallback); list_servers surfaces a muted
  flag per server and a (N muted) summary, mirroring list_channels
- discord: listConnectorServers starts from the persisted world and
  refreshes live guild fields on top instead of fabricating a bare World

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants