426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
284 lines
8.5 KiB
TypeScript
284 lines
8.5 KiB
TypeScript
/**
|
|
* Exercises the real Discord service registration method against an in-memory
|
|
* Discord API boundary so global and guild command scopes cannot overlap.
|
|
*/
|
|
|
|
import type { IAgentRuntime } from "@elizaos/core";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { DiscordService } from "../service";
|
|
import type { DiscordSlashCommand } from "../types";
|
|
|
|
const AGENT_ID = "11111111-1111-1111-1111-111111111111";
|
|
|
|
function command(
|
|
name: string,
|
|
scope: { guildOnly?: boolean; guildIds?: string[] } = {},
|
|
): DiscordSlashCommand {
|
|
return {
|
|
name,
|
|
description: `${name} command`,
|
|
...scope,
|
|
execute: vi.fn(async () => undefined),
|
|
} as unknown as DiscordSlashCommand;
|
|
}
|
|
|
|
function makeService() {
|
|
const globalAndGuildSet = vi.fn(async () => undefined);
|
|
const targetedCreate = vi.fn(async () => undefined);
|
|
const targetedFetch = vi.fn(async () => ({ find: () => undefined }));
|
|
const guild = {
|
|
id: "guild-a",
|
|
name: "Guild A",
|
|
fetch: vi.fn(async () => ({
|
|
commands: { fetch: targetedFetch, create: targetedCreate },
|
|
})),
|
|
};
|
|
const client = {
|
|
application: { commands: { set: globalAndGuildSet } },
|
|
guilds: { cache: new Map([[guild.id, guild]]) },
|
|
};
|
|
const state = {
|
|
accountId: "default",
|
|
clientReadyPromise: Promise.resolve(),
|
|
client,
|
|
};
|
|
const runtime = {
|
|
agentId: AGENT_ID,
|
|
getSetting: vi.fn(() => undefined),
|
|
logger: {
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
},
|
|
} as unknown as IAgentRuntime;
|
|
const service = Object.assign(Object.create(DiscordService.prototype), {
|
|
runtime,
|
|
slashCommands: [],
|
|
allowAllSlashCommands: new Set<string>(),
|
|
commandRegistrationQueue: Promise.resolve(),
|
|
requireAccountState: () => state,
|
|
}) as DiscordService;
|
|
|
|
return { globalAndGuildSet, service, targetedCreate };
|
|
}
|
|
|
|
describe("Discord slash-command registration scopes", () => {
|
|
// Regression (#deadlock): the account ready promise resolves only AFTER
|
|
// onReady returns, but onReady itself awaits this registration — waiting on
|
|
// the promise from that path hung forever and no command set ever reached
|
|
// Discord (live: registered commands frozen at a 2-day-old snapshot). With
|
|
// a ready client the push must proceed without touching the promise.
|
|
it("pushes commands while the ready promise is still pending when the client is already ready", async () => {
|
|
const { globalAndGuildSet, service } = makeService();
|
|
const state = service.requireAccountState() as unknown as {
|
|
clientReadyPromise: Promise<void>;
|
|
client: { isReady?: () => boolean };
|
|
};
|
|
state.clientReadyPromise = new Promise<void>(() => {}); // never resolves
|
|
state.client.isReady = () => true;
|
|
|
|
await service.registerSlashCommands([command("global")]);
|
|
|
|
expect(globalAndGuildSet).toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps global commands out of guild scope while retaining guild-only and targeted commands", async () => {
|
|
const { globalAndGuildSet, service, targetedCreate } = makeService();
|
|
|
|
await service.registerSlashCommands([
|
|
command("global"),
|
|
command("guild-only", { guildOnly: true }),
|
|
command("targeted", { guildIds: ["guild-a"] }),
|
|
]);
|
|
|
|
expect(globalAndGuildSet).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.arrayContaining([expect.objectContaining({ name: "global" })]),
|
|
);
|
|
expect(globalAndGuildSet.mock.calls[0]?.[0]).toHaveLength(1);
|
|
expect(globalAndGuildSet).toHaveBeenNthCalledWith(
|
|
2,
|
|
[expect.objectContaining({ name: "guild-only" })],
|
|
"guild-a",
|
|
);
|
|
expect(targetedCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({ name: "targeted" }),
|
|
);
|
|
});
|
|
|
|
it("writes an empty guild scope to clear stale copies of global commands", async () => {
|
|
const { globalAndGuildSet, service } = makeService();
|
|
|
|
await service.registerSlashCommands([command("global")]);
|
|
|
|
expect(globalAndGuildSet).toHaveBeenNthCalledWith(1, [
|
|
expect.objectContaining({ name: "global" }),
|
|
]);
|
|
expect(globalAndGuildSet).toHaveBeenNthCalledWith(2, [], "guild-a");
|
|
});
|
|
|
|
it("surfaces a failed guild write via reportError and still syncs the other guilds", async () => {
|
|
const guildSet = vi.fn(async (_cmds: unknown, guildId?: string) => {
|
|
if (guildId === "guild-a") throw new Error("50001: Missing Access");
|
|
return undefined;
|
|
});
|
|
const reportError = vi.fn();
|
|
const guildB = {
|
|
id: "guild-b",
|
|
name: "Guild B",
|
|
fetch: vi.fn(async () => ({
|
|
commands: { fetch: vi.fn(), create: vi.fn() },
|
|
})),
|
|
};
|
|
const guildA = { ...guildB, id: "guild-a", name: "Guild A" };
|
|
const client = {
|
|
application: { commands: { set: guildSet } },
|
|
guilds: {
|
|
cache: new Map([
|
|
["guild-a", guildA],
|
|
["guild-b", guildB],
|
|
]),
|
|
},
|
|
};
|
|
const state = {
|
|
accountId: "default",
|
|
clientReadyPromise: Promise.resolve(),
|
|
client,
|
|
};
|
|
const runtime = {
|
|
agentId: AGENT_ID,
|
|
getSetting: vi.fn(() => undefined),
|
|
reportError,
|
|
logger: {
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
},
|
|
} as unknown as IAgentRuntime;
|
|
const service = Object.assign(Object.create(DiscordService.prototype), {
|
|
runtime,
|
|
slashCommands: [],
|
|
allowAllSlashCommands: new Set<string>(),
|
|
commandRegistrationQueue: Promise.resolve(),
|
|
requireAccountState: () => state,
|
|
}) as DiscordService;
|
|
|
|
await service.registerSlashCommands([command("global")]);
|
|
|
|
// Both guilds were attempted — one guild's Missing Access must not
|
|
// abort the fan-out.
|
|
const guildScopeCalls = guildSet.mock.calls.filter(
|
|
(call) => typeof call[1] === "string",
|
|
);
|
|
expect(guildScopeCalls.map((call) => call[1]).sort()).toEqual([
|
|
"guild-a",
|
|
"guild-b",
|
|
]);
|
|
// The partial sync is observable, not a healthy-looking startup.
|
|
expect(reportError).toHaveBeenCalledWith(
|
|
"DiscordService.commandSync",
|
|
expect.any(Error),
|
|
expect.objectContaining({ guildId: "guild-a" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("handleGuildCreate registration scopes", () => {
|
|
async function runGuildCreate(opts: {
|
|
commands: DiscordSlashCommand[];
|
|
setImpl?: (cmds: unknown, guildId?: string) => Promise<unknown>;
|
|
}) {
|
|
const { handleGuildCreate } = await import("../discord-commands");
|
|
const guildScopeSet = vi.fn(opts.setImpl ?? (async () => undefined));
|
|
const reportError = vi.fn();
|
|
const fullGuild = {
|
|
id: "guild-a",
|
|
name: "Guild A",
|
|
ownerId: "owner-1",
|
|
channels: { cache: new Map() },
|
|
members: { cache: new Map() },
|
|
};
|
|
const guild = {
|
|
id: "guild-a",
|
|
name: "Guild A",
|
|
fetch: vi.fn(async () => fullGuild),
|
|
};
|
|
const runtime = {
|
|
agentId: AGENT_ID,
|
|
getSetting: vi.fn(() => undefined),
|
|
reportError,
|
|
emitEvent: vi.fn(async () => undefined),
|
|
logger: {
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
},
|
|
};
|
|
const service = {
|
|
runtime,
|
|
accountId: "default",
|
|
slashCommands: opts.commands,
|
|
client: { application: { commands: { set: guildScopeSet } } },
|
|
};
|
|
await handleGuildCreate(service as never, guild as never);
|
|
return { guildScopeSet, reportError, runtime };
|
|
}
|
|
|
|
it("registers guild-only and targeted commands, never globals, on guild join", async () => {
|
|
const { guildScopeSet, runtime } = await runGuildCreate({
|
|
commands: [
|
|
command("global"),
|
|
command("guild-only", { guildOnly: true }),
|
|
command("targeted", { guildIds: ["guild-a"] }),
|
|
],
|
|
});
|
|
|
|
expect(guildScopeSet).toHaveBeenCalledWith(
|
|
[
|
|
expect.objectContaining({ name: "guild-only" }),
|
|
expect.objectContaining({ name: "targeted" }),
|
|
],
|
|
"guild-a",
|
|
);
|
|
// Guild onboarding continued: the joined world is announced.
|
|
expect(runtime.emitEvent).toHaveBeenCalled();
|
|
});
|
|
|
|
it("writes an empty guild scope on join to clear stale global copies", async () => {
|
|
const { guildScopeSet } = await runGuildCreate({
|
|
commands: [command("global")],
|
|
});
|
|
|
|
expect(guildScopeSet).toHaveBeenCalledWith([], "guild-a");
|
|
});
|
|
|
|
it("keeps a targeted command for another guild out of this guild's scope", async () => {
|
|
const { guildScopeSet } = await runGuildCreate({
|
|
commands: [command("elsewhere", { guildIds: ["guild-z"] })],
|
|
});
|
|
|
|
expect(guildScopeSet).toHaveBeenCalledWith([], "guild-a");
|
|
});
|
|
|
|
it("surfaces a failed join-sync via reportError and still finishes onboarding", async () => {
|
|
const { reportError, runtime } = await runGuildCreate({
|
|
commands: [command("guild-only", { guildOnly: true })],
|
|
setImpl: async () => {
|
|
throw new Error("50001: Missing Access");
|
|
},
|
|
});
|
|
|
|
expect(reportError).toHaveBeenCalledWith(
|
|
"DiscordService.guildCreateCommandSync",
|
|
expect.any(Error),
|
|
expect.objectContaining({ guildId: "guild-a" }),
|
|
);
|
|
// The world-joined events still fire — a failed command write does not
|
|
// abort guild onboarding.
|
|
expect(runtime.emitEvent).toHaveBeenCalled();
|
|
});
|
|
});
|