/** * Tests for outbound.ts module * * Tests cover: * - resolveTarget with various modes (explicit, implicit, heartbeat) * - sendText with markdown stripping * - sendMedia delegation to sendText * - Error handling for missing accounts/channels * - Abort signal handling */ import { createMessageReceiptFromOutboundResults, verifyChannelMessageAdapterCapabilityProofs, } from "openclaw/plugin-sdk/channel-outbound"; import { describe, expect, it, vi } from "vitest"; import { resolveTwitchAccountContext } from "./config.js"; import { twitchMessageAdapter, twitchOutbound } from "./outbound.js"; import { BASE_TWITCH_TEST_ACCOUNT, installTwitchTestHooks, makeTwitchTestConfig, } from "./test-fixtures.js"; // Mock dependencies vi.mock("./config.js", () => ({ DEFAULT_ACCOUNT_ID: "default", resolveTwitchAccountContext: vi.fn(), })); vi.mock("./send.js", () => ({ sendMessageTwitchInternal: vi.fn(), })); vi.mock("./utils/markdown.js", () => ({ chunkTextForTwitch: vi.fn(chunkMockTextForTwitch), })); vi.mock("./utils/twitch.js", () => ({ normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""), missingTargetError: (channel: string, hint: string) => new Error(`Missing target for ${channel}. Provide ${hint}`), })); function chunkMockTextForTwitch(text: string): string[] { const chunks: string[] = []; for (const chunk of text.split(/(.{500})/)) { if (chunk.length > 0) { chunks.push(chunk); } } return chunks; } function assertResolvedTarget( result: ReturnType>, ): string { if (!result.ok) { throw result.error; } return result.to; } function expectTargetError( resolveTarget: NonNullable, params: Parameters>[0], expectedMessage: string, ) { const result = resolveTarget(params); expect(result.ok).toBe(false); if (result.ok) { throw new Error("expected resolveTarget to fail"); } expect(result.error.message).toContain(expectedMessage); } function twitchTestReceipt(messageId: string) { return createMessageReceiptFromOutboundResults({ results: [ { channel: "twitch", conversationId: "testchannel", messageId, }, ], kind: "text", }); } describe("outbound", () => { const mockAccount = { ...BASE_TWITCH_TEST_ACCOUNT, accessToken: "oauth:test123", }; const resolveTarget = twitchOutbound.resolveTarget!; const mockConfig = makeTwitchTestConfig(mockAccount); installTwitchTestHooks(); function setupAccountContext(params?: { account?: typeof mockAccount | null; availableAccountIds?: string[]; }) { const account = params?.account === undefined ? mockAccount : params.account; vi.mocked(resolveTwitchAccountContext).mockImplementation((_cfg, accountId) => ({ accountId: accountId?.trim() || "default", account, tokenResolution: { source: "config", token: account?.accessToken ?? "" }, configured: account !== null, availableAccountIds: params?.availableAccountIds ?? ["default"], })); } const abortedSendCases = [ { name: "sendText", invoke: (signal: AbortSignal) => twitchOutbound.sendText!({ cfg: mockConfig, to: "#testchannel", text: "Hello!", accountId: "default", signal, } as Parameters>[0]), }, { name: "sendMedia", invoke: (signal: AbortSignal) => twitchOutbound.sendMedia!({ cfg: mockConfig, to: "#testchannel", text: "Check this:", mediaUrl: "https://example.com/image.png", accountId: "default", signal, } as Parameters>[0]), }, ]; describe("abort handling", () => { it.each(abortedSendCases)("$name should handle abort signal", async ({ invoke }) => { const abortController = new AbortController(); abortController.abort(); await expect(invoke(abortController.signal)).rejects.toThrow("Outbound delivery aborted"); }); }); describe("metadata", () => { it("should have direct delivery mode", () => { expect(twitchOutbound.deliveryMode).toBe("direct"); }); it("should have 500 character text chunk limit", () => { expect(twitchOutbound.textChunkLimit).toBe(500); }); it("should chunk long messages at 500 characters", () => { const chunker = twitchOutbound.chunker; if (!chunker) { throw new Error("twitch outbound.chunker unavailable"); } expect(chunker("a".repeat(600), 500)).toEqual(["a".repeat(500), "a".repeat(100)]); }); it("declares message adapter durable text and media with receipt proofs", async () => { const { sendMessageTwitchInternal } = await import("./send.js"); setupAccountContext(); vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ ok: true, messageId: "twitch-msg-123", receipt: twitchTestReceipt("twitch-msg-123"), }); const proofResults = await verifyChannelMessageAdapterCapabilityProofs({ adapterName: "twitch", adapter: twitchMessageAdapter, proofs: { text: async () => { const result = await twitchMessageAdapter.send?.text?.({ cfg: mockConfig, to: "#testchannel", text: "Hello Twitch!", accountId: "default", }); expect(result?.receipt?.platformMessageIds).toEqual(["twitch-msg-123"]); }, media: async () => { const result = await twitchMessageAdapter.send?.media?.({ cfg: mockConfig, to: "#testchannel", text: "image", mediaUrl: "https://example.com/image.png", accountId: "default", }); expect(result?.receipt?.platformMessageIds).toEqual(["twitch-msg-123"]); expect(sendMessageTwitchInternal).toHaveBeenLastCalledWith( "testchannel", "image https://example.com/image.png", mockConfig, "default", true, console, ); }, messageSendingHooks: () => { expect(twitchMessageAdapter.durableFinal?.capabilities?.messageSendingHooks).toBe(true); }, }, }); expect(proofResults).toEqual([ { capability: "text", status: "verified" }, { capability: "media", status: "verified" }, { capability: "poll", status: "not_declared" }, { capability: "payload", status: "not_declared" }, { capability: "silent", status: "not_declared" }, { capability: "replyTo", status: "not_declared" }, { capability: "thread", status: "not_declared" }, { capability: "nativeQuote", status: "not_declared" }, { capability: "messageSendingHooks", status: "verified" }, { capability: "batch", status: "not_declared" }, { capability: "reconcileUnknownSend", status: "not_declared" }, { capability: "afterSendSuccess", status: "not_declared" }, { capability: "afterCommit", status: "not_declared" }, ]); }); it("adapts outbound progress into message receipts", async () => { const progress = { channel: "twitch", messageId: "twitch-progress-1", receipt: twitchTestReceipt("twitch-progress-1"), }; const sendText = twitchOutbound.sendText; if (!sendText) { throw new Error("Twitch text sending is not available."); } const sendSpy = vi.spyOn(twitchOutbound, "sendText").mockImplementationOnce(async (ctx) => { await ctx.onDeliveryResult?.(progress); return progress; }); const onDeliveryResult = vi.fn(); try { await twitchMessageAdapter.send?.text?.({ cfg: mockConfig, to: "#testchannel", text: "Hello Twitch!", accountId: "default", onDeliveryResult, }); } finally { sendSpy.mockRestore(); } expect(onDeliveryResult).toHaveBeenCalledOnce(); expect(onDeliveryResult.mock.calls[0]?.[0]?.receipt.platformMessageIds).toEqual([ "twitch-progress-1", ]); }); }); describe("resolveTarget", () => { it("should normalize and return target in explicit mode", () => { const result = resolveTarget({ to: "#MyChannel", mode: "explicit", allowFrom: [], }); expect(result.ok).toBe(true); expect(assertResolvedTarget(result)).toBe("mychannel"); }); it("should return target in implicit mode with wildcard allowlist", () => { const result = resolveTarget({ to: "#AnyChannel", mode: "implicit", allowFrom: ["*"], }); expect(result.ok).toBe(true); expect(assertResolvedTarget(result)).toBe("anychannel"); }); it("should return target in implicit mode when in allowlist", () => { const result = resolveTarget({ to: "#allowed", mode: "implicit", allowFrom: ["#allowed", "#other"], }); expect(result.ok).toBe(true); expect(assertResolvedTarget(result)).toBe("allowed"); }); it("should error when target not in allowlist (implicit mode)", () => { expectTargetError( resolveTarget, { to: "#notallowed", mode: "implicit", allowFrom: ["#primary", "#secondary"], }, "Twitch", ); }); it("should accept any target when allowlist is empty", () => { const result = resolveTarget({ to: "#anychannel", mode: "heartbeat", allowFrom: [], }); expect(result.ok).toBe(true); expect(assertResolvedTarget(result)).toBe("anychannel"); }); it("should error when no target provided with allowlist", () => { expectTargetError( resolveTarget, { to: undefined, mode: "implicit", allowFrom: ["#fallback", "#other"], }, "Twitch", ); }); it("should return error when no target and no allowlist", () => { expectTargetError( resolveTarget, { to: undefined, mode: "explicit", allowFrom: [], }, "Missing target", ); }); it("should handle whitespace-only target", () => { expectTargetError( resolveTarget, { to: " ", mode: "explicit", allowFrom: [], }, "Missing target", ); }); it("should error when target normalizes to empty string", () => { expectTargetError( resolveTarget, { to: "#", mode: "explicit", allowFrom: [], }, "Twitch", ); }); it("should filter wildcard from allowlist when checking membership", () => { const result = resolveTarget({ to: "#mychannel", mode: "implicit", allowFrom: ["*", "#specific"], }); // With wildcard, any target is accepted expect(result.ok).toBe(true); expect(assertResolvedTarget(result)).toBe("mychannel"); }); }); describe("sendText", () => { it("should send message successfully", async () => { const { sendMessageTwitchInternal } = await import("./send.js"); setupAccountContext(); vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ ok: true, messageId: "twitch-msg-123", receipt: twitchTestReceipt("twitch-msg-123"), }); const result = await twitchOutbound.sendText!({ cfg: mockConfig, to: "#testchannel", text: "Hello Twitch!", accountId: "default", }); expect(result.channel).toBe("twitch"); expect(result.messageId).toBe("twitch-msg-123"); expect(result.receipt?.platformMessageIds).toEqual(["twitch-msg-123"]); expect(sendMessageTwitchInternal).toHaveBeenCalledWith( "testchannel", "Hello Twitch!", mockConfig, "default", true, console, ); expect(result.timestamp).toBeGreaterThan(0); }); it("should throw when account not found", async () => { setupAccountContext({ account: null }); await expect( twitchOutbound.sendText!({ cfg: mockConfig, to: "#testchannel", text: "Hello!", accountId: "nonexistent", }), ).rejects.toThrow("Twitch account not found: nonexistent"); }); it("should throw when no channel specified", async () => { const accountWithoutChannel = { ...mockAccount, channel: undefined as unknown as string }; setupAccountContext({ account: accountWithoutChannel }); await expect( twitchOutbound.sendText!({ cfg: mockConfig, to: "", text: "Hello!", accountId: "default", }), ).rejects.toThrow("No channel specified"); }); it("should use account channel when target not provided", async () => { const { sendMessageTwitchInternal } = await import("./send.js"); setupAccountContext(); vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ ok: true, messageId: "msg-456", receipt: twitchTestReceipt("msg-456"), }); await twitchOutbound.sendText!({ cfg: mockConfig, to: "", text: "Hello!", accountId: "default", }); expect(sendMessageTwitchInternal).toHaveBeenCalledWith( "testchannel", "Hello!", mockConfig, "default", true, console, ); }); it("uses configured defaultAccount when accountId is omitted", async () => { const { sendMessageTwitchInternal } = await import("./send.js"); vi.mocked(resolveTwitchAccountContext) .mockImplementationOnce(() => ({ accountId: "secondary", account: { ...mockAccount, channel: "secondary-channel", }, tokenResolution: { source: "config", token: mockAccount.accessToken }, configured: true, availableAccountIds: ["default", "secondary"], })) .mockImplementation((_cfg, accountId) => ({ accountId: accountId?.trim() || "secondary", account: { ...mockAccount, channel: "secondary-channel", }, tokenResolution: { source: "config", token: mockAccount.accessToken }, configured: true, availableAccountIds: ["default", "secondary"], })); vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ ok: true, messageId: "msg-secondary", receipt: twitchTestReceipt("msg-secondary"), }); const defaultAccountConfig = { channels: { twitch: { defaultAccount: "secondary", }, }, } as typeof mockConfig; await twitchOutbound.sendText!({ cfg: defaultAccountConfig, to: "#secondary-channel", text: "Hello!", }); expect(sendMessageTwitchInternal).toHaveBeenCalledWith( "secondary-channel", "Hello!", defaultAccountConfig, "secondary", true, console, ); }); it("should throw on send failure", async () => { const { sendMessageTwitchInternal } = await import("./send.js"); setupAccountContext(); vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ ok: false, messageId: "failed-msg", receipt: createMessageReceiptFromOutboundResults({ results: [] }), error: "Connection lost", }); await expect( twitchOutbound.sendText!({ cfg: mockConfig, to: "#testchannel", text: "Hello!", accountId: "default", }), ).rejects.toThrow("Connection lost"); }); }); describe("sendMedia", () => { it("should combine text and media URL", async () => { const { sendMessageTwitchInternal } = await import("./send.js"); setupAccountContext(); vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ ok: true, messageId: "media-msg-123", receipt: twitchTestReceipt("media-msg-123"), }); const result = await twitchOutbound.sendMedia!({ cfg: mockConfig, to: "#testchannel", text: "Check this:", mediaUrl: "https://example.com/image.png", accountId: "default", }); expect(result.channel).toBe("twitch"); expect(result.messageId).toBe("media-msg-123"); expect(result.receipt?.platformMessageIds).toEqual(["media-msg-123"]); expect(sendMessageTwitchInternal).toHaveBeenCalledWith( "testchannel", "Check this: https://example.com/image.png", mockConfig, "default", true, console, ); }); it("should send media URL only when no text", async () => { const { sendMessageTwitchInternal } = await import("./send.js"); setupAccountContext(); vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ ok: true, messageId: "media-only-msg", receipt: twitchTestReceipt("media-only-msg"), }); await twitchOutbound.sendMedia!({ cfg: mockConfig, to: "#testchannel", text: "", mediaUrl: "https://example.com/image.png", accountId: "default", }); expect(sendMessageTwitchInternal).toHaveBeenCalledWith( "testchannel", "https://example.com/image.png", mockConfig, "default", true, console, ); }); }); });