diff --git a/packages/core/src/features/advanced-capabilities/actions/message.list-muted.test.ts b/packages/core/src/features/advanced-capabilities/actions/message.list-muted.test.ts new file mode 100644 index 0000000000000..39d7fa4610713 --- /dev/null +++ b/packages/core/src/features/advanced-capabilities/actions/message.list-muted.test.ts @@ -0,0 +1,186 @@ +/** + * Muted visibility in the MESSAGE list ops: list_channels carries a per-channel + * `muted` flag and list_connections a `mutedRoomCount`, resolved from the same + * participant/world state the ROOM action writes — making "which channels are + * you muted in" answerable. Map-backed runtime + mock connectors. + */ +import { describe, expect, it } from "vitest"; +import type { Room, World } from "../../../types/environment"; +import type { + ActionResult, + IAgentRuntime, + Memory, + UUID, +} from "../../../types/index.ts"; +import { messageAction } from "./message.ts"; + +const AGENT_ID = "00000000-0000-0000-0000-000000000001" as UUID; +const MUTED_ROOM_ID = "00000000-0000-0000-0000-0000000000d1" as UUID; +const OPEN_ROOM_ID = "00000000-0000-0000-0000-0000000000d2" as UUID; +const MUTED_WORLD_ID = "00000000-0000-0000-0000-0000000000e1" as UUID; + +function mockConnector( + source: string, + label: string, + rooms: Array<{ name: string; roomId?: UUID; serverId?: string }>, +) { + return { + source, + label, + capabilities: [], + supportedTargetKinds: [], + contexts: [], + listRooms: async () => + rooms.map((room) => ({ + target: { + source, + ...(room.roomId ? { roomId: room.roomId } : {}), + ...(room.serverId ? { serverId: room.serverId } : {}), + }, + label: room.name, + kind: "room" as const, + score: 0.5, + contexts: [], + })), + }; +} + +function mockRuntime( + connectors: unknown[], + seed?: { + states?: Record; + rooms?: Room[]; + worlds?: World[]; + }, +): IAgentRuntime { + const states = new Map( + Object.entries(seed?.states ?? {}), + ); + const rooms = new Map( + (seed?.rooms ?? []).map((room) => [room.id, room]), + ); + const worlds = new Map( + (seed?.worlds ?? []).map((world) => [world.id, world]), + ); + return { + agentId: AGENT_ID, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + getMessageConnectors: () => connectors, + getParticipantUserState: async (roomId: UUID, entityId: UUID) => + states.get(`${roomId}:${entityId}`) ?? null, + getRoom: async (roomId: UUID) => rooms.get(roomId) ?? null, + getWorld: async (worldId: UUID) => worlds.get(worldId) ?? null, + } as unknown as IAgentRuntime; +} + +const message = { + id: "00000000-0000-0000-0000-0000000000aa", + roomId: "00000000-0000-0000-0000-0000000000bb", + entityId: "00000000-0000-0000-0000-0000000000cc", + agentId: AGENT_ID, + content: { text: "which channels are you muted in?", source: "discord" }, + createdAt: 1, +} as unknown as Memory; + +async function runOp( + runtime: IAgentRuntime, + parameters: Record, +): Promise { + const result = await messageAction.handler( + runtime, + message, + undefined, + { parameters }, + undefined, + undefined, + ); + if (!result) throw new Error("handler returned no result"); + return result; +} + +describe("MESSAGE op=list_channels — muted flag", () => { + it("flags room-muted channels and counts them in the summary", async () => { + const runtime = mockRuntime( + [ + mockConnector("discord", "Discord", [ + { name: "#relay-flood", roomId: MUTED_ROOM_ID }, + { name: "#general", roomId: OPEN_ROOM_ID }, + ]), + ], + { + states: { [`${MUTED_ROOM_ID}:${AGENT_ID}`]: "MUTED" }, + rooms: [ + { id: MUTED_ROOM_ID, source: "discord" } as Room, + { id: OPEN_ROOM_ID, source: "discord" } as Room, + ], + }, + ); + const result = await runOp(runtime, { action: "list_channels" }); + const data = result.data as { + channels: { label: string; muted: boolean }[]; + }; + expect(result.success).toBe(true); + expect(data.channels.find((c) => c.label === "#relay-flood")?.muted).toBe( + true, + ); + expect(data.channels.find((c) => c.label === "#general")?.muted).toBe( + false, + ); + expect(result.text).toContain("(1 muted)"); + }); + + it("flags every channel of a server-muted guild", async () => { + const runtime = mockRuntime( + [ + mockConnector("discord", "Discord", [ + { name: "#a", roomId: OPEN_ROOM_ID, serverId: "guild-1" }, + { name: "#b", roomId: MUTED_ROOM_ID, serverId: "guild-1" }, + ]), + ], + { + worlds: [ + { + id: MUTED_WORLD_ID, + agentId: AGENT_ID, + metadata: { agentMuteState: "MUTED" }, + } as World, + ], + }, + ); + // The guild-id → worldId mapping is createUniqueUuid-based; answer for + // any id so the mapping stays the resolver's concern. + (runtime as unknown as { getWorld: unknown }).getWorld = async () => + ({ + id: MUTED_WORLD_ID, + agentId: AGENT_ID, + metadata: { agentMuteState: "MUTED" }, + }) as World; + const result = await runOp(runtime, { action: "list_channels" }); + const data = result.data as { channels: { muted: boolean }[] }; + expect(data.channels.every((c) => c.muted)).toBe(true); + }); +}); + +describe("MESSAGE op=list_connections — mutedRoomCount", () => { + it("reports the muted room count per connection", async () => { + const runtime = mockRuntime( + [ + mockConnector("discord", "Discord", [ + { name: "#relay-flood", roomId: MUTED_ROOM_ID }, + { name: "#general", roomId: OPEN_ROOM_ID }, + ]), + ], + { + states: { [`${MUTED_ROOM_ID}:${AGENT_ID}`]: "MUTED" }, + rooms: [{ id: MUTED_ROOM_ID, source: "discord" } as Room], + }, + ); + const result = await runOp(runtime, { action: "list_connections" }); + const data = result.data as { + connections: { platform: string; mutedRoomCount: number }[]; + }; + expect( + data.connections.find((c) => c.platform === "discord")?.mutedRoomCount, + ).toBe(1); + }); +}); diff --git a/packages/core/src/features/advanced-capabilities/actions/message.ts b/packages/core/src/features/advanced-capabilities/actions/message.ts index 2877d2f3682a8..c3c4a06a6de90 100644 --- a/packages/core/src/features/advanced-capabilities/actions/message.ts +++ b/packages/core/src/features/advanced-capabilities/actions/message.ts @@ -15,6 +15,7 @@ import { getActionSpec } from "../../../generated/spec-helpers.ts"; import { logger } from "../../../logger.ts"; import { resolveCanonicalOwnerIdForMessage } from "../../../roles.ts"; import { runWithActionRoutingContext } from "../../../runtime/action-routing-context.ts"; +import { resolveMutedTargetFlags } from "../../../services/message/mute-state.ts"; import type { Action, ActionExample, @@ -2801,15 +2802,23 @@ async function handleListChannels( ); } const targets = await listRooms(context); + // Muted visibility: without the flag "which channels are you muted in" + // is unanswerable — the participant/world mute state is queryable + // nowhere else. + const mutedFlags = await resolveMutedTargetFlags(runtime, targets); + const mutedCount = mutedFlags.filter(Boolean).length; return opSuccess( "list_channels", - `Listed ${targets.length} channels from ${connector.label}.`, + `Listed ${targets.length} channels from ${connector.label}${ + mutedCount > 0 ? ` (${mutedCount} muted)` : "" + }.`, { source: connector.source, - channels: targets.map((t) => ({ + channels: targets.map((t, index) => ({ label: t.label, kind: t.kind, target: t.target, + muted: mutedFlags[index] === true, })), }, ); @@ -2894,6 +2903,7 @@ async function handleListConnections( label: string; accountId: string | undefined; roomCount: number; + mutedRoomCount: number; }> = []; for (const connector of connectors) { @@ -2911,9 +2921,13 @@ async function handleListConnections( connector, ); let roomCount = 0; + let mutedRoomCount = 0; try { const targets = (await connector.listRooms?.(context)) ?? []; roomCount = targets.length; + mutedRoomCount = (await resolveMutedTargetFlags(runtime, targets)).filter( + Boolean, + ).length; } catch (error) { logger.debug( `[MESSAGE/list_connections] listRooms failed for ${connector.source}: ${ @@ -2926,6 +2940,7 @@ async function handleListConnections( label: connector.label, accountId: connector.accountId, roomCount, + mutedRoomCount, }); } diff --git a/packages/core/src/features/advanced-capabilities/actions/room.mute.test.ts b/packages/core/src/features/advanced-capabilities/actions/room.mute.test.ts new file mode 100644 index 0000000000000..9a78c1b5ab7f4 --- /dev/null +++ b/packages/core/src/features/advanced-capabilities/actions/room.mute.test.ts @@ -0,0 +1,259 @@ +/** + * ROOM action mute hardening: scope=server (world-level mute the inbound gate + * consults) and durationMinutes persistence (agentMuteUntilIso, consumed by + * the mute-state due-check). Map-backed runtime; the model gate answers via a + * stubbed TEXT_SMALL response; assertions read the stores the inbound gate + * reads, closing the loop from action write to message drop. + */ +import { describe, expect, it } from "vitest"; +import { resolveEffectiveMuteState } from "../../../services/message/mute-state"; +import type { Room, World } from "../../../types/environment"; +import type { + HandlerOptions, + IAgentRuntime, + Memory, + State, + UUID, +} from "../../../types/index"; +import { roomOpAction } from "./room"; + +const AGENT_ID = "00000000-0000-0000-0000-0000000000a1" as UUID; +const USER_ID = "00000000-0000-0000-0000-0000000000c1" as UUID; +const ROOM_ID = "00000000-0000-0000-0000-0000000000d1" as UUID; +const SIBLING_ROOM_ID = "00000000-0000-0000-0000-0000000000d2" as UUID; +const WORLD_ID = "00000000-0000-0000-0000-0000000000e1" as UUID; + +function makeRuntime(seed?: { + states?: Record; + rooms?: Room[]; + worlds?: World[]; +}) { + const states = new Map( + Object.entries(seed?.states ?? {}), + ); + const rooms = new Map( + (seed?.rooms ?? []).map((room) => [room.id, room]), + ); + const worlds = new Map( + (seed?.worlds ?? []).map((world) => [world.id, world]), + ); + const memories: Memory[] = []; + const runtime = { + agentId: AGENT_ID, + character: { name: "Eliza" }, + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }, + useModel: async () => "yes", + createMemory: async (memory: Memory) => { + memories.push(memory); + return memory.id; + }, + getParticipantUserState: async (roomId: UUID, entityId: UUID) => + states.get(`${roomId}:${entityId}`) ?? null, + updateParticipantUserState: async ( + roomId: UUID, + entityId: UUID, + state: "FOLLOWED" | "MUTED" | null, + ) => { + states.set(`${roomId}:${entityId}`, state); + }, + getRoom: async (roomId: UUID) => rooms.get(roomId) ?? null, + updateRoom: async (room: Room) => { + rooms.set(room.id, room); + }, + getWorld: async (worldId: UUID) => worlds.get(worldId) ?? null, + updateWorld: async (world: World) => { + worlds.set(world.id, world); + }, + } as unknown as IAgentRuntime; + return { runtime, states, rooms, worlds, memories }; +} + +function room(id: UUID): Room { + return { + id, + name: `room-${id.slice(-2)}`, + source: "discord", + type: "GROUP", + worldId: WORLD_ID, + } as Room; +} + +function world(extra?: Partial): World { + return { id: WORLD_ID, agentId: AGENT_ID, name: "Cozy Devs", ...extra }; +} + +function msg(text: string): Memory { + return { + id: "00000000-0000-0000-0000-0000000000b1" as UUID, + entityId: USER_ID, + agentId: AGENT_ID, + roomId: ROOM_ID, + content: { text, source: "discord" }, + } as Memory; +} + +function opts(parameters: Record): HandlerOptions { + return { parameters } as HandlerOptions; +} + +const state = { values: {}, data: {}, text: "" } as State; + +describe("ROOM action — timed mute persistence (durationMinutes)", () => { + it("mute with durationMinutes writes agentMuteUntilIso and returns scheduleAutoUnmuteIso", async () => { + const { runtime, states, rooms } = makeRuntime({ + rooms: [room(ROOM_ID)], + worlds: [world()], + }); + const before = Date.now(); + const result = await roomOpAction.handler( + runtime, + msg("mute this channel for 30 minutes"), + state, + opts({ action: "mute", durationMinutes: 30 }), + ); + expect(result.success).toBe(true); + expect(states.get(`${ROOM_ID}:${AGENT_ID}`)).toBe("MUTED"); + const untilIso = rooms.get(ROOM_ID)?.metadata?.agentMuteUntilIso; + expect(typeof untilIso).toBe("string"); + const expiry = Date.parse(untilIso as string); + expect(expiry).toBeGreaterThanOrEqual(before + 29 * 60_000); + expect(expiry).toBeLessThanOrEqual(Date.now() + 31 * 60_000); + expect((result.data as Record).scheduleAutoUnmuteIso).toBe( + untilIso, + ); + }); + + it("an untimed mute clears a stale expiry; unmute clears state and expiry", async () => { + const stale = new Date(Date.now() + 5 * 60_000).toISOString(); + const { runtime, states, rooms } = makeRuntime({ + rooms: [room(ROOM_ID)], + worlds: [world()], + }); + rooms.set(ROOM_ID, { + ...room(ROOM_ID), + metadata: { agentMuteUntilIso: stale }, + }); + await roomOpAction.handler( + runtime, + msg("mute this channel"), + state, + opts({ action: "mute" }), + ); + expect(rooms.get(ROOM_ID)?.metadata).not.toHaveProperty( + "agentMuteUntilIso", + ); + await roomOpAction.handler( + runtime, + msg("unmute this channel"), + state, + opts({ action: "unmute" }), + ); + expect(states.get(`${ROOM_ID}:${AGENT_ID}`)).toBeNull(); + }); +}); + +describe("ROOM action — scope=server (guild-wide mute)", () => { + it("mutes the world; a sibling room in the same world then drops via the inbound gate", async () => { + const { runtime, worlds } = makeRuntime({ + rooms: [room(ROOM_ID), room(SIBLING_ROOM_ID)], + worlds: [world()], + }); + const result = await roomOpAction.handler( + runtime, + msg("mute this whole server"), + state, + opts({ action: "mute", scope: "server" }), + ); + expect(result.success).toBe(true); + expect((result.data as Record).scope).toBe("server"); + expect(worlds.get(WORLD_ID)?.metadata?.agentMuteState).toBe("MUTED"); + // The guild mute drops a child channel that has no room-level mute. + expect( + await resolveEffectiveMuteState(runtime, { + roomIds: [SIBLING_ROOM_ID], + }), + ).toEqual({ muted: true, scope: "server", worldId: WORLD_ID }); + }); + + it("timed server mute stores the expiry; unmute clears the world metadata", async () => { + const { runtime, worlds } = makeRuntime({ + rooms: [room(ROOM_ID)], + worlds: [world()], + }); + const result = await roomOpAction.handler( + runtime, + msg("mute the server for an hour"), + state, + opts({ action: "mute", scope: "server", durationMinutes: 60 }), + ); + expect(result.success).toBe(true); + expect(typeof worlds.get(WORLD_ID)?.metadata?.agentMuteUntilIso).toBe( + "string", + ); + const unmuted = await roomOpAction.handler( + runtime, + msg("unmute the server"), + state, + opts({ action: "unmute", scope: "server" }), + ); + expect(unmuted.success).toBe(true); + const metadata = worlds.get(WORLD_ID)?.metadata ?? {}; + expect(metadata).not.toHaveProperty("agentMuteState"); + expect(metadata).not.toHaveProperty("agentMuteUntilIso"); + }); + + it("preconditions: muting an already-muted server fails; scope=server rejects follow", async () => { + const { runtime } = makeRuntime({ + rooms: [room(ROOM_ID)], + worlds: [world({ metadata: { agentMuteState: "MUTED" } })], + }); + const again = await roomOpAction.handler( + runtime, + msg("mute the server"), + state, + opts({ action: "mute", scope: "server" }), + ); + expect(again.success).toBe(false); + expect((again.values as Record).error).toBe( + "ROOM_MUTE_PRECONDITION_FAILED", + ); + const follow = await roomOpAction.handler( + runtime, + msg("follow the server"), + state, + opts({ action: "follow", scope: "server" }), + ); + expect(follow.success).toBe(false); + expect((follow.values as Record).error).toBe( + "ROOM_SCOPE_INVALID", + ); + }); + + it("validate gates on world mute state for scope=server", async () => { + const { runtime } = makeRuntime({ + rooms: [room(ROOM_ID)], + worlds: [world({ metadata: { agentMuteState: "MUTED" } })], + }); + expect( + await roomOpAction.validate( + runtime, + msg("mute the server"), + state, + opts({ action: "mute", scope: "server" }), + ), + ).toBe(false); + expect( + await roomOpAction.validate( + runtime, + msg("unmute the server"), + state, + opts({ action: "unmute", scope: "server" }), + ), + ).toBe(true); + }); +}); diff --git a/packages/core/src/features/advanced-capabilities/actions/room.ts b/packages/core/src/features/advanced-capabilities/actions/room.ts index 12743dcda67dc..0774c7ae61773 100644 --- a/packages/core/src/features/advanced-capabilities/actions/room.ts +++ b/packages/core/src/features/advanced-capabilities/actions/room.ts @@ -9,6 +9,11 @@ import { shouldUnfollowRoomTemplate, shouldUnmuteRoomTemplate, } from "../../../prompts.ts"; +import { + setRoomMuteUntil, + setWorldMuteState, + worldMuteActive, +} from "../../../services/message/mute-state.ts"; import type { Action, ActionExample, @@ -32,10 +37,11 @@ import { * Replaces MUTE_ROOM / UNMUTE_ROOM / FOLLOW_ROOM / UNFOLLOW_ROOM (core) and * the connector-targeted CHAT_THREAD action (app-lifeops). Defaults to the * current room when `roomId` / `chatName` are omitted; supports cross-room - * targeting by `platform` + `chatName` lookup, and an optional - * `durationMinutes` mute window that surfaces a scheduling hint for the - * connector layer (auto-unmute scheduling lives in app-lifeops' - * scheduled-trigger-task plumbing — core does not import outer plugins). + * targeting by `platform` + `chatName` lookup, `scope=server` mute/unmute of + * the whole guild the target room belongs to (world.metadata, consulted by + * the same inbound mute gate), and an optional `durationMinutes` mute window + * persisted as `agentMuteUntilIso` — services/message/mute-state.ts unmutes + * on the first inbound message at/after that ISO time. */ const ROOM_OPS = ["follow", "unfollow", "mute", "unmute"] as const; @@ -52,8 +58,11 @@ type RoomOpParams = { platform?: string; chatName?: string; durationMinutes?: number; + scope?: string; }; +type RoomOpScope = "room" | "server"; + type RuntimeLike = IAgentRuntime & { getRoomsForParticipant?: (entityId: UUID) => Promise; }; @@ -165,6 +174,20 @@ function normalizeString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } +function normalizeScope(value: unknown): RoomOpScope { + if (typeof value !== "string") return "room"; + const normalized = value.trim().toLowerCase(); + return normalized === "server" || normalized === "guild" ? "server" : "room"; +} + +function muteUntilIsoFromDuration( + durationMinutes: number | undefined, +): string | undefined { + return durationMinutes && durationMinutes > 0 + ? new Date(Date.now() + durationMinutes * 60_000).toISOString() + : undefined; +} + function normalizePlatform(value: unknown): string | undefined { const trimmed = normalizeString(value); return trimmed ? trimmed.toLowerCase() : undefined; @@ -269,6 +292,16 @@ async function validateRoomOpAvailability( } roomId = (roomId ?? message.roomId) as UUID; + + if (normalizeScope(params.scope) === "server") { + if (op !== "mute" && op !== "unmute") return false; + const room = await runtime.getRoom(roomId); + if (!room?.worldId) return false; + const world = await runtime.getWorld(room.worldId); + const active = worldMuteActive(world); + return op === "mute" ? !active : active; + } + const current = (await runtime.getParticipantUserState( roomId, runtime.agentId, @@ -408,6 +441,7 @@ async function applyOp(args: { roomId: UUID; roomName: string; cfg: OpConfig; + durationMinutes?: number; }): Promise { try { await args.runtime.updateParticipantUserState( @@ -415,6 +449,17 @@ async function applyOp(args: { args.runtime.agentId, args.cfg.nextState, ); + // Timed-mute expiry lives on room.metadata so the inbound due-check + // (services/message/mute-state.ts) can auto-unmute at the ISO time. + // An untimed mute clears any stale expiry from a previous timed one; + // unmute always clears it. + let untilIso: string | undefined; + if (args.op === "mute") { + untilIso = muteUntilIsoFromDuration(args.durationMinutes); + await setRoomMuteUntil(args.runtime, args.roomId, untilIso ?? null); + } else if (args.op === "unmute") { + await setRoomMuteUntil(args.runtime, args.roomId, null); + } await args.runtime.createMemory( { entityId: args.message.entityId, @@ -442,6 +487,12 @@ async function applyOp(args: { roomId: args.roomId, roomName: args.roomName, [args.cfg.dataKey]: true, + ...(untilIso + ? { + durationMinutes: args.durationMinutes, + scheduleAutoUnmuteIso: untilIso, + } + : {}), }, success: true, }; @@ -472,6 +523,103 @@ async function applyOp(args: { } } +// Server-wide mute/unmute: writes world.metadata (the same record the inbound +// mute gate consults) instead of per-room participant state, so one op covers +// every room of the guild — including rooms created after the mute. Skips the +// model-decision gate: an explicit structured `scope` parameter is already an +// explicit instruction, same rationale as the connector-targeted path. +async function applyServerScopedOp(args: { + runtime: IAgentRuntime; + message: Memory; + op: "mute" | "unmute"; + room: Awaited>; + cfg: OpConfig; + durationMinutes?: number; +}): Promise { + const { runtime, message, op, room, cfg } = args; + const failure = (error: string, text: string): ActionResult => ({ + text, + values: { success: false, error }, + data: { actionName: "ROOM", op, scope: "server", error }, + success: false, + }); + + if (!room?.worldId) { + return failure( + "ROOM_SERVER_NOT_FOUND", + "That room does not belong to a server I can mute.", + ); + } + const world = await runtime.getWorld(room.worldId); + if (!world) { + return failure( + "ROOM_SERVER_NOT_FOUND", + "That room does not belong to a server I can mute.", + ); + } + const active = worldMuteActive(world); + if (op === "mute" ? active : !active) { + return failure( + `ROOM_${op.toUpperCase()}_PRECONDITION_FAILED`, + `Cannot ${op} server from state ${active ? "MUTED" : "NONE"}`, + ); + } + + const untilIso = + op === "mute" ? muteUntilIsoFromDuration(args.durationMinutes) : undefined; + await setWorldMuteState( + runtime, + world.id, + op === "mute" ? { ...(untilIso ? { untilIso } : {}) } : null, + ); + const serverName = world.name ?? `Server-${String(world.id).substring(0, 8)}`; + await runtime.createMemory( + { + entityId: message.entityId, + agentId: message.agentId, + roomId: message.roomId, + content: { + thought: + op === "mute" + ? `I muted the entire server ${serverName}` + : `I unmuted the entire server ${serverName}`, + actions: [cfg.startAction], + }, + }, + "messages", + ); + return { + text: + op === "mute" + ? `Server muted: ${serverName}${ + args.durationMinutes ? ` for ${args.durationMinutes} minutes` : "" + }` + : `Server unmuted: ${serverName}`, + values: { + success: true, + [cfg.resultKey]: true, + worldId: world.id, + serverName, + scope: "server", + }, + data: { + actionName: "ROOM", + op, + scope: "server", + worldId: world.id, + serverName, + [cfg.dataKey]: true, + ...(untilIso + ? { + durationMinutes: args.durationMinutes, + scheduleAutoUnmuteIso: untilIso, + } + : {}), + }, + success: true, + }; +} + export const roomOpAction: Action = { name: "ROOM", contexts: [...ROOM_CONTEXTS], @@ -490,12 +638,14 @@ export const roomOpAction: Action = { "JOIN_ROOM", "LEAVE_ROOM", "CHAT_THREAD", + "MUTE_SERVER", + "UNMUTE_SERVER", "ROOM", ], description: - "Room mute/unmute/follow/unfollow. Default current room. Use roomId or platform+chatName for connector chat. mute+durationMinutes returns auto-unmute hint.", + "Room mute/unmute/follow/unfollow. Default current room. Use roomId or platform+chatName for connector chat. scope=server mutes/unmutes the whole server/guild. mute+durationMinutes auto-unmutes at the ISO time.", descriptionCompressed: - "room mute|unmute|follow|unfollow; roomId or platform+chatName; durationMinutes", + "room mute|unmute|follow|unfollow; roomId or platform+chatName; scope room|server; durationMinutes", routingHint: "mute/unmute/follow/unfollow or join/leave a chat, channel, group, or thread -> ROOM; do NOT use to send/read messages in it -> MESSAGE, to reply in the current chat -> REPLY, or to publish to a public feed/timeline -> POST", parameters: [ @@ -531,10 +681,20 @@ export const roomOpAction: Action = { { name: "durationMinutes", description: - "For action=mute: temporary mute minutes. Returns scheduleAutoUnmuteIso hint.", + "For action=mute: temporary mute minutes; auto-unmutes at the returned scheduleAutoUnmuteIso time.", required: false, schema: { type: "number" as const }, }, + { + name: "scope", + description: + "For mute/unmute: room (default) targets one room; server mutes/unmutes the entire server/guild the room belongs to.", + required: false, + schema: { + type: "string" as const, + enum: ["room", "server"], + }, + }, ], examples: [ [ @@ -628,6 +788,40 @@ export const roomOpAction: Action = { const explicitRoomId = normalizeString(params.roomId); const chatName = normalizeString(params.chatName); const durationMinutes = normalizeDurationMinutes(params.durationMinutes); + const scope = normalizeScope(params.scope); + + if (scope === "server") { + if (op !== "mute" && op !== "unmute") { + return { + text: "scope=server only supports mute and unmute.", + values: { success: false, error: "ROOM_SCOPE_INVALID" }, + data: { + actionName: "ROOM", + op, + scope, + error: "ROOM_SCOPE_INVALID", + }, + success: false, + }; + } + const room = + platform && (explicitRoomId || chatName) + ? await resolveTargetRoom({ + runtime: runtime as RuntimeLike, + platform, + roomId: explicitRoomId, + chatName, + }) + : await runtime.getRoom((explicitRoomId ?? message.roomId) as UUID); + return applyServerScopedOp({ + runtime, + message, + op, + room, + cfg, + durationMinutes, + }); + } // Connector-targeted path (replaces CHAT_THREAD). // Skip the model gate and act directly on the named room; preserves @@ -676,26 +870,19 @@ export const roomOpAction: Action = { roomId: targetRoom.id, roomName, cfg, + durationMinutes, }); if ( op === "mute" && result.success && durationMinutes && - durationMinutes > 0 && typeof result.data === "object" && result.data !== null ) { return { ...result, text: `Muted ${roomName} on ${platform} for ${durationMinutes} minutes.`, - data: { - ...result.data, - platform, - durationMinutes, - scheduleAutoUnmuteIso: new Date( - Date.now() + durationMinutes * 60_000, - ).toISOString(), - }, + data: { ...result.data, platform }, }; } return result; @@ -776,6 +963,7 @@ export const roomOpAction: Action = { roomId, roomName, cfg, + durationMinutes, }); }, }; diff --git a/packages/core/src/services/message.mute-drop.test.ts b/packages/core/src/services/message.mute-drop.test.ts new file mode 100644 index 0000000000000..af61a322bc1da --- /dev/null +++ b/packages/core/src/services/message.mute-drop.test.ts @@ -0,0 +1,167 @@ +/** + * End-to-end mute drop through the real DefaultMessageService.handleMessage: + * a MUTED room ends the turn with zero model calls EVEN when the message is a + * direct @mention (mentionContext.isMention). On mention-gated deployments + * every turn reaching the service is a mention, so any mention bypass makes + * mute a no-op — this locks the mention-independence structurally. Fake + * runtime over real state maps; useModel throws, so any inference attempt + * fails the test. + */ +import { describe, expect, it, vi } from "vitest"; +import { TurnControllerRegistry } from "../runtime/turn-controller"; +import type { Room, World } from "../types/environment"; +import { EventType } from "../types/events"; +import type { IAgentRuntime, Memory, UUID } from "../types/index"; +import { DefaultMessageService } from "./message"; + +const AGENT_ID = "00000000-0000-0000-0000-0000000000a1" as UUID; +const USER_ID = "00000000-0000-0000-0000-0000000000c1" as UUID; +const ROOM_ID = "00000000-0000-0000-0000-0000000000d1" as UUID; +const WORLD_ID = "00000000-0000-0000-0000-0000000000e1" as UUID; +const RUN_ID = "00000000-0000-0000-0000-0000000000f1" as UUID; + +function makeRuntime(seed: { + states?: Record; + rooms?: Room[]; + worlds?: World[]; +}) { + const states = new Map( + Object.entries(seed.states ?? {}), + ); + const rooms = new Map( + (seed.rooms ?? []).map((room) => [room.id, room]), + ); + const worlds = new Map( + (seed.worlds ?? []).map((world) => [world.id, world]), + ); + const emitEvent = vi.fn(async () => undefined); + const useModel = vi.fn(async () => { + throw new Error("useModel must NOT be called for a muted room"); + }); + const noop = () => {}; + const runtime = { + agentId: AGENT_ID, + character: { name: "Eliza", username: "eliza" }, + logger: { debug: noop, info: noop, warn: noop, error: noop }, + stateCache: new Map(), + turnControllers: new TurnControllerRegistry(), + emitEvent, + useModel, + getService: () => null, + getSetting: () => undefined, + startRun: () => RUN_ID, + runActionsByMode: async () => undefined, + getMemoryById: async () => null, + createMemory: async (memory: Memory) => memory.id, + queueEmbeddingGeneration: async () => undefined, + getParticipantUserState: async (roomId: UUID, entityId: UUID) => + states.get(`${roomId}:${entityId}`) ?? null, + updateParticipantUserState: async ( + roomId: UUID, + entityId: UUID, + state: "FOLLOWED" | "MUTED" | null, + ) => { + states.set(`${roomId}:${entityId}`, state); + }, + getRoom: async (roomId: UUID) => rooms.get(roomId) ?? null, + updateRoom: async (room: Room) => { + rooms.set(room.id, room); + }, + getWorld: async (worldId: UUID) => worlds.get(worldId) ?? null, + updateWorld: async (world: World) => { + worlds.set(world.id, world); + }, + } as unknown as IAgentRuntime; + return { runtime, emitEvent, useModel, states }; +} + +function mentionMessage(): Memory { + return { + id: "00000000-0000-0000-0000-0000000000b1" as UUID, + entityId: USER_ID, + agentId: AGENT_ID, + roomId: ROOM_ID, + content: { + text: "hey @Eliza what do you think?", + source: "discord", + mentionContext: { isMention: true, mentionType: "platform_mention" }, + }, + } as unknown as Memory; +} + +function room(extra?: Partial): Room { + return { + id: ROOM_ID, + source: "discord", + type: "GROUP", + worldId: WORLD_ID, + ...extra, + } as Room; +} + +function runEndedStatuses(emitEvent: ReturnType): string[] { + return emitEvent.mock.calls + .filter(([event]) => event === EventType.RUN_ENDED) + .map(([, payload]) => (payload as { status: string }).status); +} + +describe("DefaultMessageService — muted room drops even a direct mention", () => { + it("room-level mute: turn ends with status 'muted', zero model calls", async () => { + const { runtime, emitEvent, useModel } = makeRuntime({ + states: { [`${ROOM_ID}:${AGENT_ID}`]: "MUTED" }, + rooms: [room()], + worlds: [{ id: WORLD_ID, agentId: AGENT_ID, name: "Guild" }], + }); + const service = new DefaultMessageService(); + const result = await service.handleMessage(runtime, mentionMessage()); + expect(result.didRespond).toBe(false); + expect(result.mode).toBe("none"); + expect(useModel).not.toHaveBeenCalled(); + expect(runEndedStatuses(emitEvent)).toContain("muted"); + }); + + it("server-level mute: a mention in an unmuted room of a muted guild drops too", async () => { + const { runtime, emitEvent, useModel } = makeRuntime({ + rooms: [room()], + worlds: [ + { + id: WORLD_ID, + agentId: AGENT_ID, + name: "Guild", + metadata: { agentMuteState: "MUTED" }, + }, + ], + }); + const service = new DefaultMessageService(); + const result = await service.handleMessage(runtime, mentionMessage()); + expect(result.didRespond).toBe(false); + expect(useModel).not.toHaveBeenCalled(); + expect(runEndedStatuses(emitEvent)).toContain("muted"); + }); + + it("expired timed mute: auto-unmutes at the ISO time and the turn proceeds past the gate", async () => { + const past = new Date(Date.now() - 1_000).toISOString(); + const { runtime, emitEvent, states } = makeRuntime({ + states: { [`${ROOM_ID}:${AGENT_ID}`]: "MUTED" }, + rooms: [room({ metadata: { agentMuteUntilIso: past } })], + worlds: [{ id: WORLD_ID, agentId: AGENT_ID, name: "Guild" }], + }); + const service = new DefaultMessageService(); + // The deliberately-minimal fake cannot run the full Stage 1 pipeline; + // passing the mute gate is proven by the auto-unmute write landing and + // the turn NOT ending with status "muted" (it fails deeper instead). + await service.handleMessage(runtime, mentionMessage()).catch(() => {}); + expect(states.get(`${ROOM_ID}:${AGENT_ID}`)).toBeNull(); + expect(runEndedStatuses(emitEvent)).not.toContain("muted"); + }); + + it("unmuted room: the same mention proceeds past the mute gate", async () => { + const { runtime, emitEvent } = makeRuntime({ + rooms: [room()], + worlds: [{ id: WORLD_ID, agentId: AGENT_ID, name: "Guild" }], + }); + const service = new DefaultMessageService(); + await service.handleMessage(runtime, mentionMessage()).catch(() => {}); + expect(runEndedStatuses(emitEvent)).not.toContain("muted"); + }); +}); diff --git a/packages/core/src/services/message.ts b/packages/core/src/services/message.ts index e9a9df2d9f100..bfd2d7840c373 100644 --- a/packages/core/src/services/message.ts +++ b/packages/core/src/services/message.ts @@ -264,6 +264,7 @@ import { extractGenerateTextContentText, getV5ModelText, } from "./message/generate-text-result"; +import { resolveEffectiveMuteState } from "./message/mute-state"; import type { OptimizedPromptTask } from "./optimized-prompt"; import { type OptimizedPromptRuntimeLike, @@ -1457,6 +1458,17 @@ export { stripReasoningBlocks, } from "./message/fallback-reply"; +export { + type EffectiveMuteState, + muteExpiryDue, + resolveEffectiveMuteState, + resolveMutedTargetFlags, + roomMuteActive, + setRoomMuteUntil, + setWorldMuteState, + worldMuteActive, +} from "./message/mute-state"; + export type V5MessageRuntimeStage1Result = | { kind: "terminal"; @@ -8805,8 +8817,13 @@ export class DefaultMessageService implements IMessageService { }; } - // Check if room is muted - const agentName = runtime.character.name ?? "agent"; + // Effective mute check — room participant state, server-wide world mute, + // and the timed-mute due-check — independent of any addressing logic. A + // muted room drops even a direct @mention: on mention-gated deployments + // (strict mode) every turn reaching this point IS a mention, so a + // mention bypass here made mute a complete no-op. Unmuting a muted room + // is done from another room (or DM) via the ROOM action's cross-room + // targeting. const mentionContext = message.content.mentionContext; const explicitlyAddressesAgent = mentionContext?.isMention === true || @@ -8815,14 +8832,18 @@ export class DefaultMessageService implements IMessageService { runtime.character.name, runtime.character.username, ]); - if ( - agentUserState === "MUTED" && - message.content.text && - !explicitlyAddressesAgent && - !message.content.text.toLowerCase().includes(agentName.toLowerCase()) - ) { + const muteState = await resolveEffectiveMuteState(runtime, { + roomIds: [message.roomId], + primaryParticipantState: agentUserState, + ...(message.worldId ? { worldId: message.worldId } : {}), + }); + if (muteState.muted) { runtime.logger.debug( - { src: "service:message", roomId: message.roomId }, + { + src: "service:message", + roomId: message.roomId, + scope: muteState.scope, + }, "Ignoring muted room", ); await this.emitRunEnded(runtime, runId, message, startTime, "muted"); diff --git a/packages/core/src/services/message/mute-state.test.ts b/packages/core/src/services/message/mute-state.test.ts new file mode 100644 index 0000000000000..d600c3e625429 --- /dev/null +++ b/packages/core/src/services/message/mute-state.test.ts @@ -0,0 +1,282 @@ +/** + * Effective-mute resolution: room participant mute, server-wide world mute, + * the timed-mute due-check (the structural consumer of the ROOM action's + * scheduleAutoUnmuteIso contract), and the muted flags for connector room + * listings. Deterministic map-backed runtime; state transitions are asserted + * against the stores, not mocks of the thing under test. + */ +import { describe, expect, it } from "vitest"; +import type { Room, World } from "../../types/environment"; +import type { UUID } from "../../types/primitives"; +import type { + IAgentRuntime, + MessageConnectorTarget, +} from "../../types/runtime"; +import { + resolveEffectiveMuteState, + resolveMutedTargetFlags, + setRoomMuteUntil, + setWorldMuteState, + worldMuteActive, +} from "./mute-state"; + +const AGENT_ID = "00000000-0000-0000-0000-0000000000a1" as UUID; +const ROOM_ID = "00000000-0000-0000-0000-0000000000d1" as UUID; +const PARENT_ROOM_ID = "00000000-0000-0000-0000-0000000000d2" as UUID; +const WORLD_ID = "00000000-0000-0000-0000-0000000000e1" as UUID; + +function makeRuntime(seed?: { + states?: Record; + rooms?: Room[]; + worlds?: World[]; +}) { + const states = new Map( + Object.entries(seed?.states ?? {}), + ); + const rooms = new Map( + (seed?.rooms ?? []).map((room) => [room.id, room]), + ); + const worlds = new Map( + (seed?.worlds ?? []).map((world) => [world.id, world]), + ); + const runtime = { + agentId: AGENT_ID, + getParticipantUserState: async (roomId: UUID, entityId: UUID) => + states.get(`${roomId}:${entityId}`) ?? null, + updateParticipantUserState: async ( + roomId: UUID, + entityId: UUID, + state: "FOLLOWED" | "MUTED" | null, + ) => { + states.set(`${roomId}:${entityId}`, state); + }, + getRoom: async (roomId: UUID) => rooms.get(roomId) ?? null, + updateRoom: async (room: Room) => { + rooms.set(room.id, room); + }, + getWorld: async (worldId: UUID) => worlds.get(worldId) ?? null, + updateWorld: async (world: World) => { + worlds.set(world.id, world); + }, + } as unknown as IAgentRuntime; + return { runtime, states, rooms, worlds }; +} + +function room(id: UUID, extra?: Partial): Room { + return { + id, + source: "discord", + type: "GROUP", + worldId: WORLD_ID, + ...extra, + } as Room; +} + +function world(extra?: Partial): World { + return { id: WORLD_ID, agentId: AGENT_ID, name: "Cozy Devs", ...extra }; +} + +describe("resolveEffectiveMuteState", () => { + it("reports not muted when no room or world mute exists", async () => { + const { runtime } = makeRuntime({ + rooms: [room(ROOM_ID)], + worlds: [world()], + }); + expect( + await resolveEffectiveMuteState(runtime, { roomIds: [ROOM_ID] }), + ).toEqual({ muted: false }); + }); + + it("reports a room-scoped mute for MUTED participant state (untimed)", async () => { + const { runtime } = makeRuntime({ + states: { [`${ROOM_ID}:${AGENT_ID}`]: "MUTED" }, + rooms: [room(ROOM_ID)], + worlds: [world()], + }); + expect( + await resolveEffectiveMuteState(runtime, { roomIds: [ROOM_ID] }), + ).toEqual({ muted: true, scope: "room", roomId: ROOM_ID }); + }); + + it("keeps a timed mute active before its ISO expiry", async () => { + const untilIso = new Date(Date.now() + 60_000).toISOString(); + const { runtime } = makeRuntime({ + states: { [`${ROOM_ID}:${AGENT_ID}`]: "MUTED" }, + rooms: [room(ROOM_ID, { metadata: { agentMuteUntilIso: untilIso } })], + worlds: [world()], + }); + expect( + await resolveEffectiveMuteState(runtime, { roomIds: [ROOM_ID] }), + ).toEqual({ muted: true, scope: "room", roomId: ROOM_ID }); + }); + + it("auto-unmutes a timed room mute at the ISO time and clears both stores", async () => { + const untilIso = new Date(Date.now() - 1_000).toISOString(); + const { runtime, states, rooms } = makeRuntime({ + states: { [`${ROOM_ID}:${AGENT_ID}`]: "MUTED" }, + rooms: [room(ROOM_ID, { metadata: { agentMuteUntilIso: untilIso } })], + worlds: [world()], + }); + expect( + await resolveEffectiveMuteState(runtime, { roomIds: [ROOM_ID] }), + ).toEqual({ muted: false }); + expect(states.get(`${ROOM_ID}:${AGENT_ID}`)).toBeNull(); + expect(rooms.get(ROOM_ID)?.metadata).not.toHaveProperty( + "agentMuteUntilIso", + ); + }); + + it("a server-wide world mute drops a child room with no room-level mute", async () => { + const { runtime } = makeRuntime({ + rooms: [room(ROOM_ID)], + worlds: [world({ metadata: { agentMuteState: "MUTED" } })], + }); + expect( + await resolveEffectiveMuteState(runtime, { roomIds: [ROOM_ID] }), + ).toEqual({ muted: true, scope: "server", worldId: WORLD_ID }); + }); + + it("consults an explicitly passed worldId without a room record", async () => { + const { runtime } = makeRuntime({ + worlds: [world({ metadata: { agentMuteState: "MUTED" } })], + }); + expect( + await resolveEffectiveMuteState(runtime, { + roomIds: [ROOM_ID], + worldId: WORLD_ID, + }), + ).toEqual({ muted: true, scope: "server", worldId: WORLD_ID }); + }); + + it("auto-unmutes a timed server mute at the ISO time and clears world metadata", async () => { + const untilIso = new Date(Date.now() - 1_000).toISOString(); + const { runtime, worlds } = makeRuntime({ + rooms: [room(ROOM_ID)], + worlds: [ + world({ + metadata: { + agentMuteState: "MUTED", + agentMuteUntilIso: untilIso, + }, + }), + ], + }); + expect( + await resolveEffectiveMuteState(runtime, { roomIds: [ROOM_ID] }), + ).toEqual({ muted: false }); + const metadata = worlds.get(WORLD_ID)?.metadata ?? {}; + expect(metadata).not.toHaveProperty("agentMuteState"); + expect(metadata).not.toHaveProperty("agentMuteUntilIso"); + }); + + it("a muted ancestor room (thread parent) mutes the child", async () => { + const { runtime } = makeRuntime({ + states: { [`${PARENT_ROOM_ID}:${AGENT_ID}`]: "MUTED" }, + rooms: [room(ROOM_ID), room(PARENT_ROOM_ID)], + worlds: [world()], + }); + expect( + await resolveEffectiveMuteState(runtime, { + roomIds: [ROOM_ID, PARENT_ROOM_ID], + }), + ).toEqual({ muted: true, scope: "room", roomId: PARENT_ROOM_ID }); + }); +}); + +describe("setWorldMuteState / worldMuteActive", () => { + it("mutes and unmutes a world in place", async () => { + const { runtime, worlds } = makeRuntime({ worlds: [world()] }); + await setWorldMuteState(runtime, WORLD_ID, {}); + expect(worldMuteActive(worlds.get(WORLD_ID))).toBe(true); + await setWorldMuteState(runtime, WORLD_ID, null); + expect(worldMuteActive(worlds.get(WORLD_ID))).toBe(false); + expect(worlds.get(WORLD_ID)?.metadata).not.toHaveProperty("agentMuteState"); + }); + + it("returns null for an unknown world", async () => { + const { runtime } = makeRuntime(); + expect(await setWorldMuteState(runtime, WORLD_ID, {})).toBeNull(); + }); + + it("an expired timed world mute reads as inactive", () => { + const past = new Date(Date.now() - 1_000).toISOString(); + expect( + worldMuteActive( + world({ + metadata: { agentMuteState: "MUTED", agentMuteUntilIso: past }, + }), + ), + ).toBe(false); + }); +}); + +describe("setRoomMuteUntil", () => { + it("stores and clears the expiry on room metadata", async () => { + const untilIso = new Date(Date.now() + 60_000).toISOString(); + const { runtime, rooms } = makeRuntime({ rooms: [room(ROOM_ID)] }); + await setRoomMuteUntil(runtime, ROOM_ID, untilIso); + expect(rooms.get(ROOM_ID)?.metadata?.agentMuteUntilIso).toBe(untilIso); + await setRoomMuteUntil(runtime, ROOM_ID, null); + expect(rooms.get(ROOM_ID)?.metadata).not.toHaveProperty( + "agentMuteUntilIso", + ); + }); + + it("throws when asked to store an expiry for a missing room", async () => { + const { runtime } = makeRuntime(); + await expect( + setRoomMuteUntil(runtime, ROOM_ID, new Date().toISOString()), + ).rejects.toThrow(/not found/); + }); +}); + +describe("resolveMutedTargetFlags", () => { + it("flags room-muted and server-muted targets, read-only", async () => { + // Target roomIds are explicit here; the createUniqueUuid fallback is + // exercised by the plugin-discord inbound gate tests. + const { runtime } = makeRuntime({ + states: { [`${ROOM_ID}:${AGENT_ID}`]: "MUTED" }, + rooms: [room(ROOM_ID), room(PARENT_ROOM_ID)], + worlds: [world({ metadata: { agentMuteState: "MUTED" } })], + }); + // Server flag resolves via createUniqueUuid(runtime, serverId); patch + // getWorld to answer for any id so the mapping itself stays opaque. + (runtime as { getWorld: unknown }).getWorld = async () => + world({ metadata: { agentMuteState: "MUTED" } }); + const targets: MessageConnectorTarget[] = [ + { target: { source: "discord", roomId: ROOM_ID } }, + { target: { source: "discord", roomId: PARENT_ROOM_ID } }, + { + target: { + source: "discord", + roomId: PARENT_ROOM_ID, + serverId: "guild-1", + }, + }, + ]; + expect(await resolveMutedTargetFlags(runtime, targets)).toEqual([ + true, + false, + true, + ]); + }); + + it("reports an expired timed room mute as unmuted without writing", async () => { + const past = new Date(Date.now() - 1_000).toISOString(); + const { runtime, states } = makeRuntime({ + states: { [`${ROOM_ID}:${AGENT_ID}`]: "MUTED" }, + rooms: [ + room(ROOM_ID, { + worldId: undefined, + metadata: { agentMuteUntilIso: past }, + }), + ], + }); + const targets: MessageConnectorTarget[] = [ + { target: { source: "discord", roomId: ROOM_ID } }, + ]; + expect(await resolveMutedTargetFlags(runtime, targets)).toEqual([false]); + // Read-only: the inbound due-check owns the expiry write. + expect(states.get(`${ROOM_ID}:${AGENT_ID}`)).toBe("MUTED"); + }); +}); diff --git a/packages/core/src/services/message/mute-state.ts b/packages/core/src/services/message/mute-state.ts new file mode 100644 index 0000000000000..83101c68c07e5 --- /dev/null +++ b/packages/core/src/services/message/mute-state.ts @@ -0,0 +1,248 @@ +/** + * Effective mute resolution for inbound message gating. The per-room store is + * the participant `room_state` ("MUTED") the ROOM action already writes; this + * module layers the two pieces that make it enforceable: a server-wide mute + * kept on `world.metadata` (`agentMuteState`, so one op silences every channel + * of a guild without a parallel store) and the timed-mute due-check + * (`agentMuteUntilIso` on room/world metadata) that auto-unmutes on the first + * inbound message at/after the ISO time — the structural consumer of the ROOM + * action's `scheduleAutoUnmuteIso` contract. + * + * One resolver = one truth for "is the agent muted here". Consulted by core + * `processMessage` (drops muted turns before the planner, independent of the + * mention path — a muted room drops even a direct @mention, because on + * mention-gated deployments every planner-reaching turn IS a mention), + * connector inbound paths (plugin-discord drops before ingestion), and the + * MESSAGE list ops (muted flags in list_channels / list_connections). + */ +import { createUniqueUuid } from "../../entities.ts"; +import type { Room, World } from "../../types/environment.ts"; +import type { UUID } from "../../types/primitives.ts"; +import type { + IAgentRuntime, + MessageConnectorTarget, +} from "../../types/runtime.ts"; + +type ParticipantUserState = "FOLLOWED" | "MUTED" | null; + +export type EffectiveMuteState = + | { muted: false } + | { muted: true; scope: "room"; roomId: UUID } + | { muted: true; scope: "server"; worldId: UUID }; + +function readMuteUntilIso( + metadata: Record | undefined, +): string | undefined { + const value = metadata?.agentMuteUntilIso; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** True when a timed mute carries an ISO expiry that has already passed. */ +export function muteExpiryDue( + untilIso: string | undefined, + now: number, +): boolean { + if (!untilIso) return false; + const expiry = Date.parse(untilIso); + return Number.isFinite(expiry) && expiry <= now; +} + +/** Read-only: is this world under an active (non-expired) server-wide mute? */ +export function worldMuteActive( + world: World | null | undefined, + now: number = Date.now(), +): boolean { + const metadata = world?.metadata; + if (metadata?.agentMuteState !== "MUTED") return false; + return !muteExpiryDue(readMuteUntilIso(metadata), now); +} + +/** Read-only: is this room under an active (non-expired) participant mute? */ +export function roomMuteActive( + participantState: ParticipantUserState, + room: Room | null | undefined, + now: number = Date.now(), +): boolean { + if (participantState !== "MUTED") return false; + return !muteExpiryDue( + readMuteUntilIso(room?.metadata as Record | undefined), + now, + ); +} + +/** + * Resolve whether the agent is muted for an inbound message, applying the + * timed-mute due-check as a side effect: a room or world whose + * `agentMuteUntilIso` has passed is unmuted in place (participant state / + * world metadata cleared) and no longer drops the turn. + * + * `roomIds` is the message's room first, then any ancestor rooms that should + * inherit the mute (e.g. a Discord thread's parent channel). `worldId` may be + * passed when the caller already knows it (connectors derive it without a DB + * read); otherwise it is read from the first room record. Likewise + * `primaryParticipantState` lets a caller that already fetched the first + * room's participant state skip the refetch — the message pipeline reads it + * for its LLM-off check just before this resolver runs. + */ +export async function resolveEffectiveMuteState( + runtime: IAgentRuntime, + args: { + roomIds: readonly UUID[]; + worldId?: UUID; + primaryParticipantState?: ParticipantUserState; + }, + now: number = Date.now(), +): Promise { + let worldId = args.worldId; + let primaryRoom: Room | null | undefined; + + for (const roomId of args.roomIds) { + const state = + roomId === args.roomIds[0] && args.primaryParticipantState !== undefined + ? args.primaryParticipantState + : await runtime.getParticipantUserState(roomId, runtime.agentId); + if (state !== "MUTED") continue; + const room = await runtime.getRoom(roomId); + if (roomId === args.roomIds[0]) primaryRoom = room; + const untilIso = readMuteUntilIso( + room?.metadata as Record | undefined, + ); + if (!muteExpiryDue(untilIso, now)) { + return { muted: true, scope: "room", roomId }; + } + // Timed mute reached its ISO expiry — auto-unmute and keep processing. + await runtime.updateParticipantUserState(roomId, runtime.agentId, null); + if (room?.metadata && "agentMuteUntilIso" in room.metadata) { + const { agentMuteUntilIso: _expired, ...rest } = room.metadata; + await runtime.updateRoom({ ...room, metadata: rest }); + } + } + + if (!worldId) { + if (primaryRoom === undefined) { + primaryRoom = await runtime.getRoom(args.roomIds[0]); + } + worldId = primaryRoom?.worldId; + } + if (!worldId) return { muted: false }; + + const world = await runtime.getWorld(worldId); + if (world?.metadata?.agentMuteState !== "MUTED") { + return { muted: false }; + } + if (muteExpiryDue(readMuteUntilIso(world.metadata), now)) { + const { + agentMuteState: _state, + agentMuteUntilIso: _until, + ...rest + } = world.metadata; + await runtime.updateWorld({ ...world, metadata: rest }); + return { muted: false }; + } + return { muted: true, scope: "server", worldId }; +} + +/** + * Write or clear the server-wide mute on a world. Passing `null` unmutes. + * Returns the updated world, or null when the world does not exist. + */ +export async function setWorldMuteState( + runtime: IAgentRuntime, + worldId: UUID, + mute: { untilIso?: string } | null, +): Promise { + const world = await runtime.getWorld(worldId); + if (!world) return null; + const { + agentMuteState: _state, + agentMuteUntilIso: _until, + ...rest + } = world.metadata ?? {}; + const updated: World = { + ...world, + metadata: mute + ? { + ...rest, + agentMuteState: "MUTED" as const, + ...(mute.untilIso ? { agentMuteUntilIso: mute.untilIso } : {}), + } + : rest, + }; + await runtime.updateWorld(updated); + return updated; +} + +/** + * Write or clear the timed-mute expiry on a room. Passing `null` clears any + * stale expiry (an untimed mute must not inherit a previous timed one). + * Throws when an expiry is requested for a room that does not exist — a + * silently-unstored expiry would make the timed mute permanent again. + */ +export async function setRoomMuteUntil( + runtime: IAgentRuntime, + roomId: UUID, + untilIso: string | null, +): Promise { + const room = await runtime.getRoom(roomId); + if (!room) { + if (untilIso === null) return; + throw new Error(`Cannot store mute expiry: room ${roomId} not found`); + } + if (untilIso === null) { + if (!room.metadata || !("agentMuteUntilIso" in room.metadata)) return; + const { agentMuteUntilIso: _cleared, ...rest } = room.metadata; + await runtime.updateRoom({ ...room, metadata: rest }); + return; + } + await runtime.updateRoom({ + ...room, + metadata: { ...(room.metadata ?? {}), agentMuteUntilIso: untilIso }, + }); +} + +/** + * Per-target muted flags for connector room listings (list_channels / + * list_connections). Read-only — the inbound due-check owns expiry writes, so + * an expired timed mute simply reports unmuted here. Targets map to rooms via + * their explicit roomId or the canonical `createUniqueUuid(runtime, channelId)` + * convention every connector uses for inbound messages; unknown mappings + * report unmuted. + */ +export async function resolveMutedTargetFlags( + runtime: IAgentRuntime, + targets: readonly MessageConnectorTarget[], + now: number = Date.now(), +): Promise { + const worldMuteCache = new Map(); + const isServerMuted = async (serverId: string): Promise => { + const cached = worldMuteCache.get(serverId); + if (cached !== undefined) return cached; + const world = await runtime.getWorld(createUniqueUuid(runtime, serverId)); + const active = worldMuteActive(world, now); + worldMuteCache.set(serverId, active); + return active; + }; + + return Promise.all( + targets.map(async (entry) => { + const roomId = + entry.target.roomId ?? + (entry.target.channelId + ? createUniqueUuid(runtime, entry.target.channelId) + : undefined); + if (roomId) { + const state = await runtime.getParticipantUserState( + roomId, + runtime.agentId, + ); + if (state === "MUTED") { + const room = await runtime.getRoom(roomId); + if (roomMuteActive(state, room, now)) return true; + } + } + return entry.target.serverId + ? isServerMuted(entry.target.serverId) + : false; + }), + ); +} diff --git a/packages/core/src/types/environment.ts b/packages/core/src/types/environment.ts index 52386f8da5396..5f27e5b2cc1b9 100644 --- a/packages/core/src/types/environment.ts +++ b/packages/core/src/types/environment.ts @@ -65,6 +65,10 @@ export interface WorldMetadata { chatType?: string; /** Whether Telegram forum mode is enabled for this world */ isForumEnabled?: boolean; + /** Server-wide agent mute (ROOM action, scope=server): while set the agent drops every inbound message in this world's rooms */ + agentMuteState?: "MUTED"; + /** ISO expiry for a timed server mute; the inbound due-check clears the mute once passed */ + agentMuteUntilIso?: string; /** Allow platform-specific extensions */ [key: string]: unknown; } diff --git a/plugins/plugin-discord/__tests__/discord-events-dm-dispatch.test.ts b/plugins/plugin-discord/__tests__/discord-events-dm-dispatch.test.ts index 1b34674a039ad..a1ed1a3d3e506 100644 --- a/plugins/plugin-discord/__tests__/discord-events-dm-dispatch.test.ts +++ b/plugins/plugin-discord/__tests__/discord-events-dm-dispatch.test.ts @@ -68,6 +68,11 @@ function makeService() { emitEvent: vi.fn(), getSetting: vi.fn(() => undefined), logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }, + reportError: vi.fn(), + // The inbound mute gate consults these; no mute state seeded here. + getParticipantUserState: vi.fn(async () => null), + getRoom: vi.fn(async () => null), + getWorld: vi.fn(async () => null), }, slashCommands: [], timeouts: [], diff --git a/plugins/plugin-discord/__tests__/discord-events-mute-gate.test.ts b/plugins/plugin-discord/__tests__/discord-events-mute-gate.test.ts new file mode 100644 index 0000000000000..9685615a24b66 --- /dev/null +++ b/plugins/plugin-discord/__tests__/discord-events-mute-gate.test.ts @@ -0,0 +1,218 @@ +/** + * Inbound mute gate in the messageCreate listener: a channel whose room (or + * world/guild, or thread parent) carries the persisted mute state is dropped + * BEFORE ingestion — no debouncer enqueue, no messageManager dispatch — even + * for direct @mentions. Fake discord.js client + map-backed runtime; the + * room/world stores are the same ones the ROOM action writes, so this locks + * the runtime-mutable, restart-surviving replacement for boot-frozen + * CHANNEL_IDS gating. + */ +import { EventEmitter } from "node:events"; +import { createUniqueUuid, type UUID } from "@elizaos/core"; +import { ChannelType as DiscordChannelType } from "discord.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const debouncerState = vi.hoisted(() => { + const channelEnqueue = vi.fn(); + return { + channelEnqueue, + createChannelDebouncer: vi.fn(() => ({ + destroy: vi.fn(), + enqueue: channelEnqueue, + flushAll: vi.fn(), + markResponded: vi.fn(), + pendingCount: vi.fn(() => 0), + })), + }; +}); + +vi.mock("../debouncer", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createChannelDebouncer: debouncerState.createChannelDebouncer, + }; +}); + +import { setupDiscordEventListeners } from "../discord-events"; + +const BOT_ID = "123"; +const AGENT_ID = "00000000-0000-0000-0000-0000000000a1" as UUID; +const GUILD_ID = "guild-1"; + +function makeService() { + const states = new Map(); + const rooms = new Map>(); + const worlds = new Map>(); + const client = new EventEmitter() as EventEmitter & { + user?: { id: string }; + }; + client.user = { id: BOT_ID }; + const runtime = { + agentId: AGENT_ID, + emitEvent: vi.fn(), + getSetting: vi.fn(() => undefined), + logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }, + reportError: vi.fn(), + getParticipantUserState: async (roomId: UUID, entityId: UUID) => + states.get(`${roomId}:${entityId}`) ?? null, + updateParticipantUserState: async ( + roomId: UUID, + entityId: UUID, + state: "FOLLOWED" | "MUTED" | null, + ) => { + states.set(`${roomId}:${entityId}`, state); + }, + getRoom: async (roomId: UUID) => rooms.get(roomId) ?? null, + updateRoom: async (room: { id: string }) => { + rooms.set(room.id, room); + }, + getWorld: async (worldId: UUID) => worlds.get(worldId) ?? null, + updateWorld: async (world: { id: string }) => { + worlds.set(world.id, world); + }, + }; + const service = { + accountId: "test", + allowAllSlashCommands: new Set(), + allowedChannelIds: undefined, + buildMemoryFromMessage: vi.fn(), + character: {}, + client, + channelDebouncer: undefined as unknown, + discordSettings: { + shouldIgnoreBotMessages: true, + shouldRespondOnlyToMentions: true, + }, + getChannelType: vi.fn(), + handleGuildCreate: vi.fn(), + handleGuildMemberAdd: vi.fn(), + handleInteractionCreate: vi.fn(), + handleReactionAdd: vi.fn(), + handleReactionRemove: vi.fn(), + isChannelAllowed: vi.fn(() => true), + messageManager: { handleMessage: vi.fn() }, + resolveDiscordEntityId: vi.fn(), + runtime, + slashCommands: [], + timeouts: [], + userSelections: new Map(), + voiceManager: undefined, + }; + return { service, runtime, states, rooms, worlds }; +} + +function makeChannelMessage(channelId: string, parentId?: string) { + return { + id: `msg-${channelId}`, + // A direct @mention of the bot: the gate must drop it anyway. + content: `<@${BOT_ID}> hello`, + author: { id: "user-1", bot: false, username: "alice" }, + guildId: GUILD_ID, + channel: { + id: channelId, + type: DiscordChannelType.GuildText, + ...(parentId ? { parentId } : {}), + }, + }; +} + +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +function wire(service: ReturnType["service"]) { + const { channelDebouncer } = setupDiscordEventListeners(service as never); + service.channelDebouncer = channelDebouncer as never; +} + +describe("messageCreate — persisted mute gate before ingestion", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("drops a direct @mention in a room-muted channel before the debouncer", async () => { + const { service, runtime, states } = makeService(); + const roomId = createUniqueUuid(runtime as never, "chan-1"); + states.set(`${roomId}:${AGENT_ID}`, "MUTED"); + wire(service); + + service.client.emit("messageCreate", makeChannelMessage("chan-1")); + await tick(); + + expect(debouncerState.channelEnqueue).not.toHaveBeenCalled(); + expect(service.messageManager.handleMessage).not.toHaveBeenCalled(); + }); + + it("passes an unmuted channel through to the debouncer", async () => { + const { service } = makeService(); + wire(service); + + service.client.emit("messageCreate", makeChannelMessage("chan-2")); + await tick(); + + expect(debouncerState.channelEnqueue).toHaveBeenCalledTimes(1); + }); + + it("a guild-wide world mute drops a child channel with no room-level mute", async () => { + const { service, runtime, worlds } = makeService(); + const worldId = createUniqueUuid(runtime as never, GUILD_ID); + worlds.set(worldId, { + id: worldId, + metadata: { agentMuteState: "MUTED" }, + }); + wire(service); + + service.client.emit("messageCreate", makeChannelMessage("chan-3")); + await tick(); + + expect(debouncerState.channelEnqueue).not.toHaveBeenCalled(); + }); + + it("a muted parent channel silences its thread", async () => { + const { service, runtime, states } = makeService(); + const parentRoomId = createUniqueUuid(runtime as never, "parent-1"); + states.set(`${parentRoomId}:${AGENT_ID}`, "MUTED"); + wire(service); + + service.client.emit( + "messageCreate", + makeChannelMessage("thread-1", "parent-1"), + ); + await tick(); + + expect(debouncerState.channelEnqueue).not.toHaveBeenCalled(); + }); + + it("auto-unmutes an expired timed mute and processes the message", async () => { + const { service, runtime, states, rooms } = makeService(); + const roomId = createUniqueUuid(runtime as never, "chan-4"); + states.set(`${roomId}:${AGENT_ID}`, "MUTED"); + rooms.set(roomId, { + id: roomId, + metadata: { + agentMuteUntilIso: new Date(Date.now() - 1_000).toISOString(), + }, + }); + wire(service); + + service.client.emit("messageCreate", makeChannelMessage("chan-4")); + await tick(); + + expect(states.get(`${roomId}:${AGENT_ID}`)).toBeNull(); + expect(debouncerState.channelEnqueue).toHaveBeenCalledTimes(1); + }); + + it("fails open (and reports) when the mute lookup throws", async () => { + const { service, runtime } = makeService(); + (runtime as { getParticipantUserState: unknown }).getParticipantUserState = + async () => { + throw new Error("db down"); + }; + wire(service); + + service.client.emit("messageCreate", makeChannelMessage("chan-5")); + await tick(); + + expect(runtime.reportError).toHaveBeenCalledTimes(1); + expect(debouncerState.channelEnqueue).toHaveBeenCalledTimes(1); + }); +}); diff --git a/plugins/plugin-discord/discord-events.ts b/plugins/plugin-discord/discord-events.ts index 8f32c215f43bc..6a930c2921d54 100644 --- a/plugins/plugin-discord/discord-events.ts +++ b/plugins/plugin-discord/discord-events.ts @@ -8,6 +8,7 @@ import { createUniqueUuid, type ChannelType as ElizaChannelType, type EventPayload, + resolveEffectiveMuteState, type UUID, } from "@elizaos/core"; import { @@ -362,6 +363,56 @@ export function setupDiscordEventListeners(service: DiscordServiceInternals): { ); } + // Persisted mute gate. Consults the same participant/world mute state + // the ROOM action writes (and the core message service enforces), but + // BEFORE ingestion: a muted channel costs zero memory writes and zero + // model calls, and — unlike the boot-frozen CHANNEL_IDS whitelist — + // the muted set is runtime-mutable and survives restarts. Drops even a + // direct @mention: on mention-gated deployments every processed turn + // is a mention, so any mention bypass makes mute a no-op. Threads + // inherit their parent channel's mute. + try { + const muteRoomIds = [ + createUniqueUuid(service.runtime, message.channel.id), + ]; + const parentChannelId = + "parentId" in message.channel && + typeof message.channel.parentId === "string" + ? message.channel.parentId + : undefined; + if (parentChannelId) { + muteRoomIds.push(createUniqueUuid(service.runtime, parentChannelId)); + } + const muteState = await resolveEffectiveMuteState(service.runtime, { + roomIds: muteRoomIds, + // Same world derivation as the message manager: guild id, or the + // channel id itself for DMs (messages.ts ensureConnection). + worldId: createUniqueUuid( + service.runtime, + message.guildId ?? message.channel.id, + ), + }); + if (muteState.muted) { + service.runtime.logger.debug( + { + src: "plugin:discord", + agentId: service.runtime.agentId, + channelId: message.channel.id, + scope: muteState.scope, + }, + "Dropping message for muted channel", + ); + return; + } + } catch (error) { + // error-policy:J7 a broken mute lookup must not take down inbound + // message handling; surface it and fail open (core's own mute check + // still guards the planner). + service.runtime.reportError("discord:mute-gate", error, { + channelId: message.channel.id, + }); + } + if (listenCids.includes(message.channel.id) && message) { const newMessage = await service.buildMemoryFromMessage(message);