@gguf/claw
Version:
WhatsApp gateway CLI (Baileys web) with Pi RPC agent
374 lines (314 loc) • 10.8 kB
text/typescript
/**
* 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 type { OpenClawConfig } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { twitchOutbound } from "./outbound.js";
// Mock dependencies
vi.mock("./config.js", () => ({
DEFAULT_ACCOUNT_ID: "default",
getAccountConfig: vi.fn(),
}));
vi.mock("./send.js", () => ({
sendMessageTwitchInternal: vi.fn(),
}));
vi.mock("./utils/markdown.js", () => ({
chunkTextForTwitch: vi.fn((text) => text.split(/(.{500})/).filter(Boolean)),
}));
vi.mock("./utils/twitch.js", () => ({
normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""),
missingTargetError: (channel: string, hint: string) =>
`Missing target for ${channel}. Provide ${hint}`,
}));
describe("outbound", () => {
const mockAccount = {
username: "testbot",
token: "oauth:test123",
clientId: "test-client-id",
channel: "#testchannel",
};
const mockConfig = {
channels: {
twitch: {
accounts: {
default: mockAccount,
},
},
},
} as unknown as OpenClawConfig;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
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 have chunker function", () => {
expect(twitchOutbound.chunker).toBeDefined();
expect(typeof twitchOutbound.chunker).toBe("function");
});
});
describe("resolveTarget", () => {
it("should normalize and return target in explicit mode", () => {
const result = twitchOutbound.resolveTarget({
to: "#MyChannel",
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(true);
expect(result.to).toBe("mychannel");
});
it("should return target in implicit mode with wildcard allowlist", () => {
const result = twitchOutbound.resolveTarget({
to: "#AnyChannel",
mode: "implicit",
allowFrom: ["*"],
});
expect(result.ok).toBe(true);
expect(result.to).toBe("anychannel");
});
it("should return target in implicit mode when in allowlist", () => {
const result = twitchOutbound.resolveTarget({
to: "#allowed",
mode: "implicit",
allowFrom: ["#allowed", "#other"],
});
expect(result.ok).toBe(true);
expect(result.to).toBe("allowed");
});
it("should fallback to first allowlist entry when target not in list", () => {
const result = twitchOutbound.resolveTarget({
to: "#notallowed",
mode: "implicit",
allowFrom: ["#primary", "#secondary"],
});
expect(result.ok).toBe(true);
expect(result.to).toBe("primary");
});
it("should accept any target when allowlist is empty", () => {
const result = twitchOutbound.resolveTarget({
to: "#anychannel",
mode: "heartbeat",
allowFrom: [],
});
expect(result.ok).toBe(true);
expect(result.to).toBe("anychannel");
});
it("should use first allowlist entry when no target provided", () => {
const result = twitchOutbound.resolveTarget({
to: undefined,
mode: "implicit",
allowFrom: ["#fallback", "#other"],
});
expect(result.ok).toBe(true);
expect(result.to).toBe("fallback");
});
it("should return error when no target and no allowlist", () => {
const result = twitchOutbound.resolveTarget({
to: undefined,
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toContain("Missing target");
});
it("should handle whitespace-only target", () => {
const result = twitchOutbound.resolveTarget({
to: " ",
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toContain("Missing target");
});
it("should filter wildcard from allowlist when checking membership", () => {
const result = twitchOutbound.resolveTarget({
to: "#mychannel",
mode: "implicit",
allowFrom: ["*", "#specific"],
});
// With wildcard, any target is accepted
expect(result.ok).toBe(true);
expect(result.to).toBe("mychannel");
});
});
describe("sendText", () => {
it("should send message successfully", async () => {
const { getAccountConfig } = await import("./config.js");
const { sendMessageTwitchInternal } = await import("./send.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true,
messageId: "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.to).toBe("testchannel");
expect(result.timestamp).toBeGreaterThan(0);
});
it("should throw when account not found", async () => {
const { getAccountConfig } = await import("./config.js");
vi.mocked(getAccountConfig).mockReturnValue(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 { getAccountConfig } = await import("./config.js");
const accountWithoutChannel = { ...mockAccount, channel: undefined as unknown as string };
vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannel);
await expect(
twitchOutbound.sendText({
cfg: mockConfig,
to: undefined,
text: "Hello!",
accountId: "default",
}),
).rejects.toThrow("No channel specified");
});
it("should use account channel when target not provided", async () => {
const { getAccountConfig } = await import("./config.js");
const { sendMessageTwitchInternal } = await import("./send.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true,
messageId: "msg-456",
});
await twitchOutbound.sendText({
cfg: mockConfig,
to: undefined,
text: "Hello!",
accountId: "default",
});
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
"testchannel",
"Hello!",
mockConfig,
"default",
true,
console,
);
});
it("should handle abort signal", async () => {
const abortController = new AbortController();
abortController.abort();
await expect(
twitchOutbound.sendText({
cfg: mockConfig,
to: "#testchannel",
text: "Hello!",
accountId: "default",
signal: abortController.signal,
}),
).rejects.toThrow("Outbound delivery aborted");
});
it("should throw on send failure", async () => {
const { getAccountConfig } = await import("./config.js");
const { sendMessageTwitchInternal } = await import("./send.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: false,
messageId: "failed-msg",
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");
const { getAccountConfig } = await import("./config.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true,
messageId: "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(sendMessageTwitchInternal).toHaveBeenCalledWith(
expect.anything(),
"Check this: https://example.com/image.png",
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
);
});
it("should send media URL only when no text", async () => {
const { sendMessageTwitchInternal } = await import("./send.js");
const { getAccountConfig } = await import("./config.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true,
messageId: "media-only-msg",
});
await twitchOutbound.sendMedia({
cfg: mockConfig,
to: "#testchannel",
text: undefined,
mediaUrl: "https://example.com/image.png",
accountId: "default",
});
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
expect.anything(),
"https://example.com/image.png",
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
);
});
it("should handle abort signal", async () => {
const abortController = new AbortController();
abortController.abort();
await expect(
twitchOutbound.sendMedia({
cfg: mockConfig,
to: "#testchannel",
text: "Check this:",
mediaUrl: "https://example.com/image.png",
accountId: "default",
signal: abortController.signal,
}),
).rejects.toThrow("Outbound delivery aborted");
});
});
});