mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
741d9524be
* fix(outbound): avoid replaying sent recovery deliveries * fix(outbound): persist recovery send state before ack Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com> * fix(outbound): preserve partial send evidence across adapters * test(line): cover multi-send delivery progress * fix(outbound): preserve repeated receipt result multiplicity * test(heartbeat): expect delivery progress callback --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
608 lines
18 KiB
TypeScript
608 lines
18 KiB
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 {
|
|
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<NonNullable<typeof twitchOutbound.resolveTarget>>,
|
|
): string {
|
|
if (!result.ok) {
|
|
throw result.error;
|
|
}
|
|
return result.to;
|
|
}
|
|
|
|
function expectTargetError(
|
|
resolveTarget: NonNullable<typeof twitchOutbound.resolveTarget>,
|
|
params: Parameters<NonNullable<typeof twitchOutbound.resolveTarget>>[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<NonNullable<typeof twitchOutbound.sendText>>[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<NonNullable<typeof twitchOutbound.sendMedia>>[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,
|
|
);
|
|
});
|
|
});
|
|
});
|