/** * Unit tests for the Discord message-connector adapter — outbound send routing * through the account client pool, against a partially-constructed * `DiscordService` and mocked discord.js client. */ import type { IAgentRuntime, MessageConnectorTarget } from "@elizaos/core"; import type { Message } from "discord.js"; import { describe, expect, it, vi } from "vitest"; import { DiscordAccountClientPool } from "../account-client-pool"; import { DEFAULT_ACCOUNT_ID } from "../accounts"; import { buildMemoryFromMessage, type HistoryServiceInternals, } from "../discord-history"; import { DiscordService } from "../service"; function createDiscordConnectorTestService< TProperties extends Record, >(properties: TProperties): DiscordService & TProperties { return Object.assign( Object.create(DiscordService.prototype), properties, ) as DiscordService & TProperties; } function createRuntime() { return { agentId: "agent-1", registerMessageConnector: vi.fn(), registerSendHandler: vi.fn(), logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn(), }, ensureConnection: vi.fn().mockResolvedValue(undefined), createMemory: vi.fn().mockImplementation(async (memory) => memory.id), getMemoryById: vi.fn().mockResolvedValue(null), getRoom: vi.fn(), getEntityById: vi.fn(), getRelationships: vi.fn().mockResolvedValue([]), } as IAgentRuntime & { registerMessageConnector: ReturnType; registerSendHandler: ReturnType; }; } describe("Discord message connector adapter", () => { it("registers connector metadata and dispatches through the existing send handler", async () => { const runtime = createRuntime(); const service = Object.create( DiscordService.prototype, ) as DiscordService & { handleSendMessage: ReturnType; resolveConnectorTargets: ReturnType; listRecentConnectorTargets: ReturnType; listConnectorRooms: ReturnType; getConnectorChatContext: ReturnType; getConnectorUserContext: ReturnType; }; service.handleSendMessage = vi.fn().mockResolvedValue(undefined); service.resolveConnectorTargets = vi.fn(); service.listRecentConnectorTargets = vi.fn(); service.listConnectorRooms = vi.fn(); service.getConnectorChatContext = vi.fn(); service.getConnectorUserContext = vi.fn(); Object.assign(service, { accountPool: new DiscordAccountClientPool(), defaultAccountId: DEFAULT_ACCOUNT_ID, }); DiscordService.registerSendHandlers(runtime, service); expect(runtime.registerMessageConnector).toHaveBeenCalledOnce(); const registration = runtime.registerMessageConnector.mock.calls[0][0]; expect(registration).toMatchObject({ source: "discord", label: "Discord", capabilities: expect.arrayContaining([ "send_message", "resolve_targets", "chat_context", "user_context", ]), supportedTargetKinds: ["channel", "thread", "user"], contexts: ["social", "connectors"], }); await registration.sendHandler( runtime, { source: "discord", channelId: "123456789012345678" }, { text: "hello" }, ); expect(service.handleSendMessage).toHaveBeenCalledWith( runtime, { source: "discord", channelId: "123456789012345678" }, { text: "hello" }, ); }); it("resolves cached channels and users into unified message targets", async () => { const runtime = createRuntime(); const guild: Record = { id: "111111111111111111", name: "Eliza", }; const channel = { id: "222222222222222222", name: "general", guild, isTextBased: () => true, isVoiceBased: () => false, isThread: () => false, }; const member = { id: "333333333333333333", displayName: "Ada", user: { id: "333333333333333333", username: "ada", globalName: "Ada Lovelace", tag: "ada#0001", bot: false, }, }; guild.channels = { cache: new Map([[channel.id, channel]]) }; guild.members = { cache: new Map([[member.id, member]]), fetch: vi.fn().mockResolvedValue(new Map([[member.id, member]])), }; const service = createDiscordConnectorTestService({ runtime, allowedChannelIds: undefined, dynamicChannelIds: new Set(), accountPool: new DiscordAccountClientPool(), defaultAccountId: DEFAULT_ACCOUNT_ID, client: { guilds: { cache: new Map([[guild.id, guild]]) }, channels: { fetch: vi.fn().mockResolvedValue(channel) }, users: { fetch: vi.fn().mockResolvedValue(member.user) }, }, }); const channelTargets = await service.resolveConnectorTargets("general", { runtime, }); expect(channelTargets[0]).toMatchObject({ kind: "channel", label: "#general", target: { source: "discord", channelId: "222222222222222222", serverId: "111111111111111111", }, }); const userTargets: MessageConnectorTarget[] = await service.resolveConnectorTargets("ada", { runtime, }); expect(userTargets.some((target) => target.kind === "user")).toBe(true); expect( userTargets.find((target) => target.kind === "user")?.target.entityId, ).toBe("333333333333333333"); }); it("registers account-scoped connectors and routes sends with accountId", async () => { const runtime = createRuntime(); const service = createDiscordConnectorTestService({ getAccountIds: vi.fn(() => ["default", "team"]), getDefaultAccountId: vi.fn(() => "default"), getAccountLabel: vi.fn((accountId: string) => accountId === "team" ? "Team Bot" : "Default Bot", ), handleSendMessage: vi.fn().mockResolvedValue(undefined), resolveConnectorTargets: vi.fn().mockResolvedValue([]), listRecentConnectorTargets: vi.fn().mockResolvedValue([]), listConnectorRooms: vi.fn().mockResolvedValue([]), listConnectorServers: vi.fn().mockResolvedValue([]), fetchConnectorMessages: vi.fn().mockResolvedValue([]), searchConnectorMessages: vi.fn().mockResolvedValue([]), reactConnectorMessage: vi.fn().mockResolvedValue(undefined), editConnectorMessage: vi.fn(), deleteConnectorMessage: vi.fn().mockResolvedValue(undefined), pinConnectorMessage: vi.fn().mockResolvedValue(undefined), joinConnectorChannel: vi.fn(), leaveConnectorChannel: vi.fn().mockResolvedValue(undefined), getConnectorUser: vi.fn(), getConnectorChatContext: vi.fn(), getConnectorUserContext: vi.fn(), }); DiscordService.registerSendHandlers(runtime, service); expect(runtime.registerMessageConnector).toHaveBeenCalledTimes(3); const registrations = runtime.registerMessageConnector.mock.calls.map( (call) => call[0], ); expect(registrations.map((registration) => registration.accountId)).toEqual( [undefined, "default", "team"], ); const teamRegistration = registrations.find( (registration) => registration.accountId === "team", ); await teamRegistration.sendHandler( runtime, { source: "discord", channelId: "222222222222222222" }, { text: "team hello" }, ); expect(service.handleSendMessage).toHaveBeenCalledWith( runtime, { source: "discord", channelId: "222222222222222222", accountId: "team", }, { text: "team hello" }, ); }); it("stamps inbound Discord memories with the accountId", async () => { const runtime = { agentId: "00000000-0000-0000-0000-000000000001", logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), }, } as HistoryServiceInternals["runtime"]; const channel = { id: "222222222222222222", type: 0, guild: { id: "111111111111111111" }, }; const message = { id: "333333333333333333", content: "hello from team", createdTimestamp: 1_700_000_000_000, url: "https://discord.com/channels/111/222/333", author: { id: "444444444444444444", username: "ada", bot: false, displayAvatarURL: () => "https://cdn.example/avatar.png", }, channel, guild: { id: "111111111111111111" }, reference: null, }; const memory = await buildMemoryFromMessage( { accountId: "team", runtime, messageManager: undefined, client: {} as HistoryServiceInternals["client"], resolveDiscordEntityId: (userId: string) => `00000000-0000-0000-0000-${userId.slice(-12)}`, getChannelType: vi.fn().mockResolvedValue("GROUP"), isGuildTextBasedChannel: vi.fn(), }, message as Message, { processedContent: "hello from team" }, ); expect(memory?.metadata).toMatchObject({ accountId: "team", discord: { accountId: "team", channelId: "222222222222222222", messageId: "333333333333333333", }, }); }); it("uses an external message id as a Discord reply reference when sending through connector targets", async () => { const runtime = Object.assign(createRuntime(), { getRoom: vi.fn().mockResolvedValue({ channelId: "222222222222222222", }), ensureConnection: vi.fn().mockResolvedValue(undefined), createMemory: vi.fn().mockResolvedValue(undefined), }); const send = vi.fn(async (options: unknown) => ({ id: "444444444444444444", content: typeof options === "object" && options ? String((options as { content?: string }).content ?? "") : String(options ?? ""), attachments: { size: 0 }, url: "https://discord.com/channels/111/222/444", createdTimestamp: 123, })); const channel = { id: "222222222222222222", name: "general", guild: { id: "111111111111111111", name: "Eliza" }, isTextBased: () => true, isVoiceBased: () => false, send, }; const client = { isReady: () => true, channels: { fetch: vi.fn().mockResolvedValue(channel) }, user: { id: "999999999999999999", username: "bot", displayName: "Bot", }, }; const accountPool = new DiscordAccountClientPool(); accountPool.set({ accountId: DEFAULT_ACCOUNT_ID, account: { id: DEFAULT_ACCOUNT_ID, token: "token", enabled: true }, client, settings: {}, dynamicChannelIds: new Set(), clientReadyPromise: null, loginFailed: false, } as never); const service = createDiscordConnectorTestService({ runtime, accountPool, defaultAccountId: DEFAULT_ACCOUNT_ID, getChannelType: vi.fn().mockResolvedValue("GROUP"), }); await service.handleSendMessage( runtime, { source: "discord", roomId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", }, { text: "done", inReplyTo: "1506947499212935208", }, ); expect(send).toHaveBeenCalledWith({ content: "done", reply: { messageReference: "1506947499212935208" }, files: undefined, }); }); it("suppresses identical connector sends that race the inbound callback", async () => { const runtime = Object.assign(createRuntime(), { getRoom: vi.fn().mockResolvedValue({ channelId: "222222222222222223", }), }); const send = vi.fn(async (options: unknown) => ({ id: `44444444444444444${send.mock.calls.length}`, content: typeof options === "object" && options ? String((options as { content?: string }).content ?? "") : String(options ?? ""), attachments: { size: 0 }, url: "https://discord.com/channels/111/223/444", createdTimestamp: 123, })); const channel = { id: "222222222222222223", name: "general", guild: { id: "111111111111111111", name: "Eliza" }, isTextBased: () => true, isVoiceBased: () => false, send, }; const client = { isReady: () => true, channels: { fetch: vi.fn().mockResolvedValue(channel) }, user: { id: "999999999999999999", username: "bot", displayName: "Bot", }, }; const accountPool = new DiscordAccountClientPool(); accountPool.set({ accountId: DEFAULT_ACCOUNT_ID, account: { id: DEFAULT_ACCOUNT_ID, token: "token", enabled: true }, client, settings: {}, dynamicChannelIds: new Set(), clientReadyPromise: null, loginFailed: false, } as never); const service = createDiscordConnectorTestService({ runtime, accountPool, defaultAccountId: DEFAULT_ACCOUNT_ID, getChannelType: vi.fn().mockResolvedValue("GROUP"), }); const target = { source: "discord", roomId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", }; const content = { text: "Bitcoin is currently at $61,984 USD.", inReplyTo: "1506947499212935209", }; await service.handleSendMessage(runtime, target, content); await service.handleSendMessage(runtime, target, content); expect(send).toHaveBeenCalledOnce(); expect(runtime.logger.debug).toHaveBeenCalledWith( expect.objectContaining({ src: "plugin:discord", channelId: "222222222222222223", }), "Suppressing duplicate Discord connector delivery", ); }); });