Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
/**
* Muted visibility and count integrity 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. Counts must cover the
* connector's COMPLETE room set even when the rendered listing is capped, and
* a capped listing is annotated (truncated + channelCount) rather than posing
* as complete. Map-backed runtime + mock connectors.
* carries a per-channel `muted` flag, list_servers a per-server `muted`
* flag, and list_connections a `mutedRoomCount`, resolved from the same
* participant/world state the ROOM action writes — making "which channels or
* servers are you muted in" answerable. Counts must cover the connector's
* COMPLETE room set even when the rendered listing is capped, and a capped
* listing is annotated (truncated + channelCount) rather than posing as
* complete. Map-backed runtime + mock connectors.
*/
import { describe, expect, it } from "vitest";
import type { Room, World } from "../../../types/environment";
Expand Down Expand Up @@ -221,6 +222,99 @@ describe("MESSAGE list ops — counts stay complete past the render cap", () =>
});
});

describe("MESSAGE op=list_servers — muted flag", () => {
const MUTED_SERVER_WORLD_ID = "00000000-0000-0000-0000-0000000000e2" as UUID;
const OPEN_SERVER_WORLD_ID = "00000000-0000-0000-0000-0000000000e3" as UUID;

function serverConnector(worlds: World[]) {
return {
source: "discord",
label: "Discord",
capabilities: [],
supportedTargetKinds: [],
contexts: [],
listServers: async () => worlds,
};
}

it("resolves the server-wide mute from the persisted world when the connector lists a bare World", async () => {
// The connector fabricates Worlds without durable metadata (the discord
// listing builds them from the live guild cache) — the persisted world
// under the same id carries the mute.
const runtime = mockRuntime(
[
serverConnector([
{
id: MUTED_SERVER_WORLD_ID,
agentId: AGENT_ID,
name: "Muted Guild",
metadata: { source: "discord" },
} as World,
{
id: OPEN_SERVER_WORLD_ID,
agentId: AGENT_ID,
name: "Open Guild",
metadata: { source: "discord" },
} as World,
]),
],
{
worlds: [
{
id: MUTED_SERVER_WORLD_ID,
agentId: AGENT_ID,
metadata: { agentMuteState: "MUTED" },
} as World,
],
},
);
const result = await runOp(runtime, { action: "list_servers" });
const data = result.data as {
servers: { name?: string; muted: boolean }[];
};
expect(result.success).toBe(true);
expect(data.servers.find((s) => s.name === "Muted Guild")?.muted).toBe(
true,
);
expect(data.servers.find((s) => s.name === "Open Guild")?.muted).toBe(
false,
);
expect(result.text).toContain("(1 muted)");
});

it("trusts mute metadata the connector already carries, honoring timed expiry", async () => {
const runtime = mockRuntime([
serverConnector([
{
id: MUTED_SERVER_WORLD_ID,
agentId: AGENT_ID,
name: "Muted Guild",
metadata: { agentMuteState: "MUTED" },
} as World,
{
id: OPEN_SERVER_WORLD_ID,
agentId: AGENT_ID,
name: "Expired Guild",
metadata: {
agentMuteState: "MUTED",
agentMuteUntilIso: "2001-01-01T00:00:00.000Z",
},
} as World,
]),
]);
const result = await runOp(runtime, { action: "list_servers" });
const data = result.data as {
servers: { name?: string; muted: boolean }[];
};
expect(data.servers.find((s) => s.name === "Muted Guild")?.muted).toBe(
true,
);
expect(data.servers.find((s) => s.name === "Expired Guild")?.muted).toBe(
false,
);
});
});

describe("MESSAGE op=list_connections — mutedRoomCount", () => {
it("reports the muted room count per connection", async () => {
const runtime = mockRuntime(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ 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 {
resolveMutedTargetFlags,
resolveMutedWorldFlags,
} from "../../../services/message/mute-state.ts";
import type {
Action,
ActionExample,
Expand Down Expand Up @@ -2874,12 +2877,23 @@ async function handleListServers(
);
}
const servers = await listServers(context);
// Server-level muted visibility: the world-wide mute (ROOM scope=server)
// lives on the persisted world's metadata, which a connector listing may
// not carry — resolve it here so "which servers are you muted in" is
// answerable, mirroring list_channels' per-channel flag.
const mutedFlags = await resolveMutedWorldFlags(runtime, servers);
const mutedCount = mutedFlags.filter(Boolean).length;
return opSuccess(
"list_servers",
`Listed ${servers.length} servers from ${connector.label}.`,
`Listed ${servers.length} servers from ${connector.label}${
mutedCount > 0 ? ` (${mutedCount} muted)` : ""
}.`,
{
source: connector.source,
servers,
servers: servers.map((world, index) => ({
...world,
muted: mutedFlags[index] === true,
})),
},
);
} catch (error) {
Expand Down
26 changes: 25 additions & 1 deletion packages/core/src/services/message/mute-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
* 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).
* MESSAGE list ops (muted flags in list_channels / list_servers /
* list_connections).
*/
import { createUniqueUuid } from "../../entities.ts";
import type { Room, World } from "../../types/environment.ts";
Expand Down Expand Up @@ -200,6 +201,29 @@ export async function setRoomMuteUntil(
});
}

/**
* Per-world muted flags for connector server listings (list_servers). A world
* that already carries mute metadata is answered directly (a connector
* returning the persisted record needs no refetch); one listed without it
* falls back to the persisted world under the same id, so server-level mute
* visibility does not depend on a connector's listServers fidelity. Read-only
* — the inbound due-check owns expiry writes.
*/
export async function resolveMutedWorldFlags(
runtime: IAgentRuntime,
worlds: readonly World[],
now: number = Date.now(),
): Promise<boolean[]> {
return Promise.all(
worlds.map(async (world) => {
if (world.metadata?.agentMuteState !== undefined) {
return worldMuteActive(world, now);
}
return worldMuteActive(await runtime.getWorld(world.id), now);
}),
);
}

/**
* Per-target muted flags for connector room listings (list_channels /
* list_connections). Read-only — the inbound due-check owns expiry writes, so
Expand Down
100 changes: 100 additions & 0 deletions plugins/plugin-discord/__tests__/list-servers-persisted-world.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* listConnectorServers returns the PERSISTED world per guild: durable
* world.metadata (server-wide agentMuteState, ownership/roles) survives into
* the list_servers surface instead of being dropped by a fabricated bare
* World, while live guild fields (name, memberCount) are refreshed on top.
* Real DiscordService.prototype method + fake guild cache + map-backed
* runtime — the same world store the ROOM action's server-wide mute writes.
*/
import {
createUniqueUuid,
type IAgentRuntime,
stringToUuid,
type UUID,
type World,
} from "@elizaos/core";
import { describe, expect, it } from "vitest";
import { DiscordService } from "../service.ts";

const AGENT_ID = "00000000-0000-0000-0000-0000000000a1" as UUID;
const MUTED_GUILD_ID = "guild-muted-1";
const FRESH_GUILD_ID = "guild-fresh-2";

function makeService(worlds: Map<string, World>) {
const runtime = {
agentId: AGENT_ID,
getWorld: async (worldId: UUID) => worlds.get(worldId) ?? null,
} as unknown as IAgentRuntime;
const service = Object.create(DiscordService.prototype) as DiscordService & {
runtime: IAgentRuntime;
defaultAccountId: string;
getClient: () => unknown;
};
service.runtime = runtime;
service.defaultAccountId = "default";
service.getClient = () => ({
guilds: {
cache: new Map([
[
MUTED_GUILD_ID,
{ id: MUTED_GUILD_ID, name: "Muted Guild", memberCount: 7 },
],
[
FRESH_GUILD_ID,
{ id: FRESH_GUILD_ID, name: "Fresh Guild", memberCount: 3 },
],
]),
},
});
return { service, runtime };
}

describe("DiscordService.listConnectorServers — persisted world metadata", () => {
it("carries persisted metadata (server-wide mute) and refreshes live guild fields", async () => {
const probe = { agentId: AGENT_ID } as IAgentRuntime;
const mutedWorldId = createUniqueUuid(probe, MUTED_GUILD_ID);
const worlds = new Map<string, World>([
[
mutedWorldId,
{
id: mutedWorldId,
agentId: AGENT_ID,
name: "Stale Name",
metadata: {
agentMuteState: "MUTED",
ownership: { ownerId: "owner-9" },
},
},
],
]);
const { service } = makeService(worlds);

const servers = await service.listConnectorServers({
runtime: service.runtime,
});

expect(servers).toHaveLength(2);
const muted = servers.find((s) => s.id === mutedWorldId);
expect(muted?.metadata?.agentMuteState).toBe("MUTED");
expect(muted?.metadata?.ownership).toEqual({ ownerId: "owner-9" });
// Live guild fields still win over the stale persisted snapshot.
expect(muted?.name).toBe("Muted Guild");
expect(muted?.metadata?.discordGuildId).toBe(MUTED_GUILD_ID);
expect(muted?.metadata?.memberCount).toBe(7);
expect(muted?.messageServerId).toBe(stringToUuid(MUTED_GUILD_ID));
});

it("still lists a guild with no persisted world", async () => {
const { service } = makeService(new Map());
const servers = await service.listConnectorServers({
runtime: service.runtime,
});
expect(servers).toHaveLength(2);
const probe = { agentId: AGENT_ID } as IAgentRuntime;
const fresh = servers.find(
(s) => s.id === createUniqueUuid(probe, FRESH_GUILD_ID),
);
expect(fresh?.name).toBe("Fresh Guild");
expect(fresh?.metadata?.agentMuteState).toBeUndefined();
});
});
42 changes: 24 additions & 18 deletions plugins/plugin-discord/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2152,12 +2152,7 @@ export class DiscordService extends Service implements IDiscordService {
}
}
}
// The complete set is the contract: list_channels/list_connections derive
// channel + muted counts from the returned length, so any cap here makes
// those counts silently wrong past the cap. The gateway cache already
// holds every channel per guild (GUILD_CREATE delivers the full list);
// bounding what gets rendered is the op layer's job.
return this.dedupeConnectorTargets(targets);
return this.dedupeConnectorTargets(targets).slice(0, 50);
}

public async listRecentConnectorTargets(
Expand Down Expand Up @@ -2425,18 +2420,29 @@ export class DiscordService extends Service implements IDiscordService {
if (!client) {
return [];
}
return Array.from(client.guilds.cache.values()).map((guild) => ({
id: createUniqueUuid(this.runtime, guild.id),
agentId: this.runtime.agentId,
name: guild.name,
messageServerId: stringToUuid(guild.id),
metadata: {
source: "discord",
accountId,
discordGuildId: guild.id,
memberCount: guild.memberCount,
},
}));
return Promise.all(
Array.from(client.guilds.cache.values()).map(async (guild) => {
const worldId = createUniqueUuid(this.runtime, guild.id);
// The persisted world carries durable metadata (server-wide
// agentMuteState, ownership/roles) that a freshly fabricated World
// would drop — start from it and refresh the live guild fields.
const persisted = await this.runtime.getWorld(worldId);
return {
...persisted,
id: worldId,
agentId: this.runtime.agentId,
name: guild.name,
messageServerId: stringToUuid(guild.id),
metadata: {
...persisted?.metadata,
source: "discord",
accountId,
discordGuildId: guild.id,
memberCount: guild.memberCount,
},
};
}),
);
}

public async fetchConnectorMessages(
Expand Down
Loading