Files
openclaw/extensions/signal/src/core.test.ts
2026-07-13 23:43:46 -07:00

1267 lines
38 KiB
TypeScript

import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime";
// Signal tests cover core plugin behavior.
import {
createMessageReceiptFromOutboundResults,
verifyChannelMessageAdapterCapabilityProofs,
} from "openclaw/plugin-sdk/channel-outbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
createPluginSetupWizardStatus,
createTestWizardPrompter,
type WizardPrompter,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { describe, expect, it, vi } from "vitest";
import {
clearSignalApprovalReactionTargetsForTest,
resolveSignalApprovalReactionTargetWithPersistence,
} from "./approval-reactions.js";
import { signalPlugin } from "./channel.js";
import * as clientModule from "./client-adapter.js";
import {
looksLikeUuid,
normalizeSignalAllowRecipient,
resolveSignalPeerId,
resolveSignalRecipient,
resolveSignalSender,
} from "./identity.js";
import { probeSignal } from "./probe.js";
import {
clearSignalReplyAuthorsForTest,
registerSignalReplyContext,
resolveSignalReplyContextWithPersistence,
} from "./reply-authors.js";
import {
createSignalCliPathTextInput,
normalizeSignalAccountInput,
signalDmPolicy,
} from "./setup-core.js";
const getSignalSetupStatus = createPluginSetupWizardStatus(signalPlugin);
describe("looksLikeUuid", () => {
it("accepts hyphenated UUIDs", () => {
expect(looksLikeUuid("123e4567-e89b-12d3-a456-426614174000")).toBe(true);
});
it("accepts compact UUIDs", () => {
expect(looksLikeUuid("123e4567e89b12d3a456426614174000")).toBe(true); // pragma: allowlist secret
});
it("accepts uuid-like hex values with letters", () => {
expect(looksLikeUuid("abcd-1234")).toBe(true);
});
it("rejects numeric ids and phone-like values", () => {
expect(looksLikeUuid("1234567890")).toBe(false);
expect(looksLikeUuid("+15555551212")).toBe(false);
});
});
describe("signal sender identity", () => {
it("prefers sourceNumber over sourceUuid", () => {
const sender = resolveSignalSender({
sourceNumber: " +15550001111 ",
sourceUuid: "123e4567-e89b-12d3-a456-426614174000",
});
expect(sender).toEqual({
kind: "phone",
raw: "+15550001111",
e164: "+15550001111",
});
});
it("uses sourceUuid when sourceNumber is missing", () => {
const sender = resolveSignalSender({
sourceUuid: "123e4567-e89b-12d3-a456-426614174000",
});
expect(sender).toEqual({
kind: "uuid",
raw: "123e4567-e89b-12d3-a456-426614174000",
});
});
it("falls back to sourceUuid when sourceNumber has no digits", () => {
const sender = resolveSignalSender({
sourceNumber: "not a phone number",
sourceUuid: "123e4567-e89b-12d3-a456-426614174000",
});
expect(sender).toEqual({
kind: "uuid",
raw: "123e4567-e89b-12d3-a456-426614174000",
});
});
it("normalizes noisy allowlist numbers and rejects digit-free entries", () => {
expect(normalizeSignalAllowRecipient("signal:++1 (555) 000-1111")).toBe("+15550001111");
expect(normalizeSignalAllowRecipient("signal:not a phone number")).toBeUndefined();
});
it("maps uuid senders to recipient and peer ids", () => {
const sender = { kind: "uuid", raw: "123e4567-e89b-12d3-a456-426614174000" } as const;
expect(resolveSignalRecipient(sender)).toBe("123e4567-e89b-12d3-a456-426614174000");
expect(resolveSignalPeerId(sender)).toBe("uuid:123e4567-e89b-12d3-a456-426614174000");
});
});
describe("probeSignal", () => {
it("falls back to the direct probe helper when runtime is not initialized", async () => {
vi.spyOn(clientModule, "signalCheck")
.mockResolvedValueOnce({
ok: true,
status: 200,
error: null,
})
.mockResolvedValueOnce({
ok: true,
status: 200,
error: null,
});
vi.spyOn(clientModule, "signalRpcRequest")
.mockResolvedValueOnce({ version: "0.13.22" })
.mockResolvedValueOnce({ version: "0.13.22" });
const params = {
cfg: {} as never,
account: {
accountId: "default",
enabled: true,
configured: true,
baseUrl: "http://127.0.0.1:8080",
} as never,
timeoutMs: 1000,
};
const expected = await probeSignal("http://127.0.0.1:8080", 1000);
const result = await signalPlugin.status!.probeAccount!(params);
expect(result.ok).toBe(expected.ok);
expect(result.status).toBe(expected.status);
expect(result.error).toBe(expected.error);
expect(result.version).toBe(expected.version);
expect(result.elapsedMs).toBeGreaterThanOrEqual(0);
});
it("extracts version from {version} result", async () => {
vi.spyOn(clientModule, "signalCheck").mockResolvedValueOnce({
ok: true,
status: 200,
error: null,
});
vi.spyOn(clientModule, "signalRpcRequest").mockResolvedValueOnce({ version: "0.13.22" });
const res = await probeSignal("http://127.0.0.1:8080", 1000);
expect(res.ok).toBe(true);
expect(res.version).toBe("0.13.22");
expect(res.status).toBe(200);
});
it("returns ok=false when /check fails", async () => {
vi.spyOn(clientModule, "signalCheck").mockResolvedValueOnce({
ok: false,
status: 503,
error: "HTTP 503",
});
const res = await probeSignal("http://127.0.0.1:8080", 1000);
expect(res.ok).toBe(false);
expect(res.status).toBe(503);
expect(res.version).toBe(null);
});
it("setup status lines use the selected account cliPath", async () => {
const status = await getSignalSetupStatus({
cfg: {
channels: {
signal: {
cliPath: "/tmp/root-signal-cli",
accounts: {
work: {
cliPath: "/tmp/work-signal-cli",
},
},
},
},
} as never,
accountOverrides: { signal: "work" },
});
expect(status.statusLines).toContain("signal-cli: missing (/tmp/work-signal-cli)");
});
it("setup status uses configured defaultAccount for omitted cliPath lookup", async () => {
const status = await getSignalSetupStatus({
cfg: {
channels: {
signal: {
cliPath: "/tmp/root-signal-cli",
defaultAccount: "work",
accounts: {
work: {
cliPath: "/tmp/work-signal-cli",
},
},
},
},
} as never,
accountOverrides: {},
});
expect(status.statusLines).toContain("signal-cli: missing (/tmp/work-signal-cli)");
});
it("uses configured defaultAccount for omitted setup configured state", async () => {
const status = await getSignalSetupStatus({
cfg: {
channels: {
signal: {
defaultAccount: "work",
cliPath: "/tmp/root-signal-cli",
accounts: {
alerts: {
cliPath: "/tmp/alerts-signal-cli",
},
work: {
cliPath: "",
account: "",
httpHost: "",
httpUrl: "",
},
},
},
},
} as OpenClawConfig,
accountOverrides: {},
});
expect(status.configured).toBe(true);
});
it("does not show a second missing-binary note before the cliPath prompt", () => {
const input = createSignalCliPathTextInput(async () => true);
expect(input.helpLines).toBeUndefined();
expect(input.helpTitle).toBeUndefined();
});
});
describe("signal outbound", () => {
it("resolves aliases through the message target resolver", async () => {
const resolved = await signalPlugin.messaging?.targetResolver?.resolveTarget?.({
cfg: {
channels: {
signal: {
aliases: {
ops: "signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
},
},
},
} as OpenClawConfig,
input: "signal:ops",
normalized: "ops",
preferredKind: "group",
});
expect(resolved).toEqual({
to: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
kind: "group",
display: "ops",
source: "directory",
});
});
it("resolves aliases through sync outbound target resolution", () => {
const resolved = signalPlugin.outbound?.resolveTarget?.({
cfg: {
channels: {
signal: {
aliases: {
me: "+15551234567",
},
},
},
} as OpenClawConfig,
to: "signal:me",
accountId: "default",
});
expect(resolved).toEqual({ ok: true, to: "+15551234567" });
});
it("keeps Signal outbound text sanitization enabled", () => {
expect(
signalPlugin.outbound?.sanitizeText?.({
text: "<think>private reasoning</think>\nVisible answer",
payload: { text: "Visible answer" },
}),
).toBe("Visible answer");
});
it("resolves aliases before durable Signal message sends", async () => {
const send = vi.fn(async () => ({
messageId: "signal-1",
receipt: createMessageReceiptFromOutboundResults({
results: [{ channel: "signal", messageId: "signal-1" }],
kind: "text",
}),
}));
await signalPlugin.message?.send?.text?.({
cfg: {
channels: {
signal: {
aliases: {
me: "+15551234567",
},
},
},
} as OpenClawConfig,
to: "signal:me",
text: "approval",
deps: { signal: send },
});
expect(send).toHaveBeenCalledWith(
"+15551234567",
"approval",
expect.objectContaining({
cfg: expect.any(Object),
}),
);
});
it("resolves aliases before formatted Signal sends", async () => {
const send = vi.fn(async () => ({
messageId: "signal-1",
receipt: createMessageReceiptFromOutboundResults({
results: [{ channel: "signal", messageId: "signal-1" }],
kind: "text",
}),
}));
await signalPlugin.outbound?.sendFormattedText?.({
cfg: {
channels: {
signal: {
aliases: {
ops: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
},
},
},
} as OpenClawConfig,
to: "signal:ops",
text: "approval",
deps: { signal: send },
});
expect(send).toHaveBeenCalledWith(
"group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
"approval",
expect.objectContaining({
cfg: expect.any(Object),
}),
);
});
it("reports a formatted Signal chunk before a later chunk fails", async () => {
const send = vi
.fn()
.mockResolvedValueOnce({ messageId: "signal-1" })
.mockRejectedValueOnce(new Error("second Signal chunk failed"));
const onDeliveryResult = vi.fn();
await expect(
signalPlugin.outbound?.sendFormattedText?.({
cfg: { channels: { signal: { replyToMode: "first" } } } as OpenClawConfig,
to: "+15551234567",
text: "a".repeat(5000),
deps: { signal: send },
replyToId: "1700000000004",
replyToIdSource: "implicit",
onDeliveryResult,
}),
).rejects.toThrow("second Signal chunk failed");
expect(send).toHaveBeenCalledTimes(2);
expect(send).toHaveBeenNthCalledWith(
2,
"+15551234567",
expect.any(String),
expect.not.objectContaining({
replyToId: "1700000000004",
}),
);
expect(onDeliveryResult).toHaveBeenCalledTimes(1);
expect(onDeliveryResult).toHaveBeenCalledWith(
expect.objectContaining({ channel: "signal", messageId: "signal-1" }),
);
});
it("resolves aliases before formatted Signal media sends", async () => {
const send = vi.fn(async () => ({
messageId: "signal-1",
receipt: createMessageReceiptFromOutboundResults({
results: [{ channel: "signal", messageId: "signal-1" }],
kind: "media",
}),
}));
await signalPlugin.outbound?.sendFormattedMedia?.({
cfg: {
channels: {
signal: {
aliases: {
ops: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
},
},
},
} as OpenClawConfig,
to: "signal:ops",
text: "approval",
mediaUrl: "file:///tmp/signal-proof.png",
deps: { signal: send },
});
expect(send).toHaveBeenCalledWith(
"group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
"approval",
expect.objectContaining({
cfg: expect.any(Object),
mediaUrl: "file:///tmp/signal-proof.png",
}),
);
});
it("returns clear outbound errors for recursive aliases", () => {
const resolved = signalPlugin.outbound?.resolveTarget?.({
cfg: {
channels: {
signal: {
aliases: {
home: "signal:me",
me: "home",
},
},
},
} as OpenClawConfig,
to: "signal:home",
});
expect(resolved?.ok).toBe(false);
if (resolved?.ok === false) {
expect(resolved.error.message).toBe(
'Signal alias "home" resolves recursively through "home".',
);
}
});
it("returns target resolver misses for recursive aliases", async () => {
const resolved = await signalPlugin.messaging?.targetResolver?.resolveTarget?.({
cfg: {
channels: {
signal: {
aliases: {
home: "signal:me",
me: "home",
},
},
},
} as OpenClawConfig,
input: "signal:home",
normalized: "home",
preferredKind: "user",
});
expect(resolved).toBeNull();
});
it("returns clear outbound errors for recursive defaultTo aliases", () => {
const cfg = {
channels: {
signal: {
aliases: {
home: "signal:me",
me: "home",
},
defaultTo: "signal:home",
},
},
} as OpenClawConfig;
const defaultTo = signalPlugin.config.resolveDefaultTo?.({
cfg,
accountId: "default",
});
expect(defaultTo).toBe("signal:home");
const resolved = signalPlugin.outbound?.resolveTarget?.({
cfg,
to: defaultTo,
accountId: "default",
});
expect(resolved?.ok).toBe(false);
if (resolved?.ok === false) {
expect(resolved.error.message).toBe(
'Signal alias "home" resolves recursively through "home".',
);
}
});
it("builds canonical session routes for aliases", async () => {
const route = await signalPlugin.messaging?.resolveOutboundSessionRoute?.({
cfg: {
channels: {
signal: {
aliases: {
ops: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
},
},
},
} as OpenClawConfig,
agentId: "main",
target: "signal:ops",
resolvedTarget: {
to: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
kind: "group",
source: "directory",
},
});
expect(route?.to).toBe("group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=");
expect(route?.baseSessionKey).toContain(
"signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
);
});
it("lists configured aliases through the Signal directory", async () => {
const cfg = {
channels: {
signal: {
aliases: {
me: "+15551234567",
ops: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
},
},
},
} as OpenClawConfig;
await expect(
signalPlugin.directory?.listPeers?.({ cfg, query: "me", runtime: {} as never }),
).resolves.toEqual([{ kind: "user", id: "+15551234567", name: "me" }]);
await expect(
signalPlugin.directory?.listGroups?.({ cfg, query: "ops", runtime: {} as never }),
).resolves.toEqual([
{
kind: "group",
id: "group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
name: "ops",
},
]);
});
it("resolves account and chat-type scoped reply-to modes through plugin threading", () => {
const resolveReplyToMode = signalPlugin.threading?.resolveReplyToMode;
if (!resolveReplyToMode) {
throw new Error("signal threading.resolveReplyToMode unavailable");
}
const cfg = {
channels: {
signal: {
replyToMode: "first",
replyToModeByChatType: { direct: "all", group: "off" },
accounts: {
Work: {
replyToMode: "off",
replyToModeByChatType: { group: "all" },
},
},
},
},
} as OpenClawConfig;
expect(resolveReplyToMode({ cfg, accountId: "work", chatType: "group" })).toBe("all");
expect(resolveReplyToMode({ cfg, accountId: "work", chatType: "direct" })).toBe("off");
expect(resolveReplyToMode({ cfg, accountId: "default", chatType: "direct" })).toBe("all");
expect(resolveReplyToMode({ cfg, accountId: "default", chatType: "group" })).toBe("off");
expect(resolveReplyToMode({ cfg, accountId: "default" })).toBe("first");
});
it("builds same-conversation reply context for message tool sends", () => {
const buildToolContext = signalPlugin.threading?.buildToolContext;
if (!buildToolContext) {
throw new Error("signal threading.buildToolContext unavailable");
}
const hasRepliedRef = { value: false };
const context = buildToolContext({
cfg: {
channels: {
signal: {
replyToModeByChatType: { direct: "first" },
},
},
} as OpenClawConfig,
accountId: "default",
context: {
Channel: "signal",
To: "signal:+15550001111",
ChatType: "direct",
CurrentMessageId: "1783831798122",
ReplyToId: "1783831798000",
},
hasRepliedRef,
});
if (!context) {
throw new Error("signal threading tool context unavailable");
}
expect(context).toEqual({
currentChannelId: "signal:+15550001111",
currentChatType: "direct",
currentMessagingTarget: "signal:+15550001111",
currentMessageId: "1783831798000",
replyToMode: "first",
hasRepliedRef,
});
expect(
signalPlugin.threading?.matchesToolContextTarget?.({
target: "+15550001111",
toolContext: context,
}),
).toBe(true);
});
it("chunks outbound text without requiring Signal runtime initialization", () => {
const chunker = signalPlugin.outbound?.chunker;
if (!chunker) {
throw new Error("signal outbound.chunker unavailable");
}
expect(chunker("alpha beta", 5)).toEqual(["alpha", "beta"]);
});
it("sanitizes internal assistant scaffolding before outbound delivery", () => {
const sanitizeText = signalPlugin.outbound?.sanitizeText;
if (!sanitizeText) {
throw new Error("signal outbound sanitizer unavailable");
}
expect(
sanitizeText({
text: "<think>private</think>Visible answer",
payload: { text: "<think>private</think>Visible answer" },
}),
).toBe("Visible answer");
});
it("preserves the local approval prompt suppressor through attached-result composition", () => {
const suppressor = signalPlugin.outbound?.shouldSuppressLocalPayloadPrompt;
if (!suppressor) {
throw new Error("signal outbound approval suppressor unavailable");
}
expect(
suppressor({
cfg: {
channels: {
signal: {
enabled: true,
allowFrom: ["+15551230000"],
},
},
approvals: {
exec: {
enabled: true,
},
},
} as OpenClawConfig,
accountId: "default",
payload: {
text: "Approval required.",
channelData: {
execApproval: {
approvalId: "exec-1",
approvalSlug: "exec-1",
approvalKind: "exec",
sessionKey: "agent:main:signal:+15551230000",
},
},
},
hint: {
kind: "approval-pending",
approvalKind: "exec",
nativeRouteActive: true,
},
}),
).toBe(true);
});
it("registers structured approval payloads for reactions after delivery", async () => {
clearSignalApprovalReactionTargetsForTest();
const cfg = {
channels: {
signal: {
account: "+15550009999",
allowFrom: ["+15551230000"],
},
},
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "signal", to: "+15551230000" }],
},
},
} as OpenClawConfig;
const payload = buildExecApprovalPendingReplyPayload({
approvalId: "exec-after-delivery",
approvalSlug: "exec-aft",
allowedDecisions: ["allow-once", "deny"],
command: "printf test",
host: "gateway",
agentId: "main",
sessionKey: "agent:main:signal:direct:+15551230000",
});
const rendered = await signalPlugin.outbound?.renderPresentation?.({
payload,
presentation: payload.presentation!,
ctx: {
cfg,
to: "+15551230000",
text: payload.text ?? "",
accountId: "default",
payload,
},
});
expect(rendered?.text).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
await signalPlugin.outbound?.afterDeliverPayload?.({
cfg,
target: {
channel: "signal",
to: "+15551230000",
accountId: "default",
},
payload: rendered!,
results: [
{
channel: "signal",
messageId: "1700000000099",
},
],
});
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: "+15551230000",
messageId: "1700000000099",
reactionKey: "👍",
targetAuthor: "+15550009999",
}),
).resolves.toEqual({
approvalId: "exec-after-delivery",
approvalKind: "exec",
decision: "allow-once",
route: {
deliveryMode: "target",
to: "+15551230000",
accountId: "default",
agentId: "main",
sessionKey: "agent:main:signal:direct:+15551230000",
},
});
});
it("renders reaction hints only from structured approval payloads", async () => {
const cfg = {
channels: {
signal: {
account: "+15550009999",
allowFrom: ["+15551230000"],
},
},
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "signal", to: "+15551230000" }],
},
},
} as OpenClawConfig;
const payload = buildExecApprovalPendingReplyPayload({
approvalId: "exec-rendered-approval",
approvalSlug: "exec-ren",
allowedDecisions: ["allow-once", "deny"],
command: "printf test",
host: "gateway",
});
const rendered = await signalPlugin.outbound?.renderPresentation?.({
payload,
presentation: payload.presentation!,
ctx: {
cfg,
to: "+15551230000",
text: payload.text ?? "",
accountId: "default",
payload,
},
});
expect(rendered?.text).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
expect(
await signalPlugin.outbound?.renderPresentation?.({
payload: {
text: [
"The docs show this example:",
"Exec approval required",
"ID: exec-rendered-approval",
"",
"Reply with: /approve exec-rendered-approval allow-once|deny",
].join("\n"),
presentation: payload.presentation,
},
presentation: payload.presentation!,
ctx: {
cfg,
to: "+15551230000",
text: payload.text ?? "",
accountId: "default",
payload,
},
}),
).toBeNull();
});
it("materializes mixed approval presentation before adding reaction guidance", async () => {
const cfg = {
channels: {
signal: {
account: "+15550009999",
allowFrom: ["+15551230000"],
},
},
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "signal", to: "+15551230000" }],
},
},
} as OpenClawConfig;
const payload = buildExecApprovalPendingReplyPayload({
approvalId: "exec-mixed-presentation",
approvalSlug: "exec-mixed-presentation",
allowedDecisions: ["allow-once", "deny"],
command: "printf test",
host: "gateway",
});
const presentation = {
...payload.presentation!,
blocks: [
{ type: "context" as const, text: "Deployment audit context" },
{
type: "table" as const,
caption: "Targets",
headers: ["Host", "State"],
rows: [
["alpha", "ready"],
["omega", "waiting"],
],
},
...payload.presentation!.blocks,
],
};
const rendered = await signalPlugin.outbound?.renderPresentation?.({
payload: { ...payload, presentation },
presentation,
ctx: {
cfg,
to: "+15551230000",
text: payload.text ?? "",
accountId: "default",
payload,
},
});
expect(rendered?.text).toContain("Deployment audit context");
expect(rendered?.text).toContain("- Host: alpha; State: ready");
expect(rendered?.text).toContain("- Host: omega; State: waiting");
expect(rendered?.text?.match(/React with:/g)).toHaveLength(1);
expect(rendered?.text?.match(/\/approve exec-mixed-presentation allow-once/g)).toHaveLength(1);
expect(rendered?.text?.match(/\/approve exec-mixed-presentation deny/g)).toHaveLength(1);
expect(rendered?.text).not.toContain("- Allow Once:");
expect(rendered?.text).not.toContain("- Deny:");
});
it("registers delivered approval reactions under the resolved default account", async () => {
const renderPresentation = signalPlugin.outbound?.renderPresentation;
const afterDeliverPayload = signalPlugin.outbound?.afterDeliverPayload;
if (!renderPresentation || !afterDeliverPayload) {
throw new Error("signal outbound approval delivery hooks unavailable");
}
clearSignalApprovalReactionTargetsForTest();
const cfg = {
channels: {
signal: {
defaultAccount: "work",
accounts: {
work: {
accountUuid: "123e4567-e89b-12d3-a456-426614174000",
allowFrom: ["+15551230000"],
},
},
},
},
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "signal", to: "+15551230000" }],
},
},
} as OpenClawConfig;
const payload: ReplyPayload = {
text: ["Exec approval required", "ID: exec-1"].join("\n"),
channelData: {
execApproval: {
approvalId: "exec-1",
approvalSlug: "exec-1",
approvalKind: "exec",
allowedDecisions: ["allow-once", "deny"],
},
},
};
const renderedPayload =
(await renderPresentation({
ctx: {
cfg,
to: "+15551230000",
text: payload.text ?? "",
accountId: "work",
payload,
},
presentation: { blocks: [] },
payload,
})) ?? payload;
expect(renderedPayload.text).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
await afterDeliverPayload({
cfg,
target: { channel: "signal", to: "+15551230000" },
payload: renderedPayload,
results: [
{
channel: "signal",
messageId: "1700000000001",
meta: { signalVisibleText: renderedPayload.text },
},
],
});
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "work",
conversationKey: "+15551230000",
messageId: "1700000000001",
reactionKey: "👍",
targetAuthorUuid: "123e4567-e89b-12d3-a456-426614174000",
}),
).resolves.toMatchObject({
approvalId: "exec-1",
decision: "allow-once",
});
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: "+15551230000",
messageId: "1700000000001",
reactionKey: "👍",
targetAuthor: "+15550009999",
}),
).resolves.toBeNull();
clearSignalApprovalReactionTargetsForTest();
});
it("resolves only proven direct reply authors", async () => {
const replyContext = { to: "signal:+15551234567", replyToId: "1700000000001" };
await registerSignalReplyContext({
...replyContext,
author: "+15551234567",
sourceTimestamp: 100,
});
await registerSignalReplyContext({
...replyContext,
author: "+15550001111",
sourceTimestamp: 200,
});
await registerSignalReplyContext({
...replyContext,
author: "+15551234567",
sourceTimestamp: 300,
});
await expect(resolveSignalReplyContextWithPersistence(replyContext)).resolves.toEqual({
ambiguous: true,
});
await clearSignalReplyAuthorsForTest();
});
it("keeps newer reply context when older events arrive out of order", async () => {
const replyContext = { to: "signal:+15551234567", replyToId: "1700000000002" };
await registerSignalReplyContext({
...replyContext,
author: "+15551234567",
body: "newer",
sourceTimestamp: 200,
});
await registerSignalReplyContext({
...replyContext,
author: "+15551234567",
body: "older",
sourceTimestamp: 100,
});
await expect(resolveSignalReplyContextWithPersistence(replyContext)).resolves.toEqual({
author: "+15551234567",
body: "newer",
});
await clearSignalReplyAuthorsForTest();
});
it("hydrates durable Signal sends with stored native quote context", async () => {
await clearSignalReplyAuthorsForTest();
await registerSignalReplyContext({
to: "signal:+15555550123",
replyToId: "1700000000001",
author: "+15555550123",
body: "original message",
});
const send = vi.fn(async () => ({ messageId: "signal-text-1" }));
await signalPlugin.message?.send?.text?.({
cfg: {} as OpenClawConfig,
to: "signal:+15555550123",
text: "reply",
replyToId: "1700000000001",
deps: { signal: send },
} as Parameters<NonNullable<typeof signalPlugin.message.send.text>>[0] & {
deps: { signal: typeof send };
});
expect(send).toHaveBeenCalledWith("+15555550123", "reply", {
cfg: {},
maxBytes: undefined,
accountId: undefined,
replyToId: "1700000000001",
replyToAuthor: "+15555550123",
replyToBody: "original message",
});
await clearSignalReplyAuthorsForTest();
});
it("declares message adapter durable text and media with receipt proofs", async () => {
const send = vi.fn(async (_to: string, _text: string, opts: { mediaUrl?: string } = {}) => {
const messageId = opts.mediaUrl ? "signal-media-1" : "signal-text-1";
return {
messageId,
receipt: createMessageReceiptFromOutboundResults({
results: [{ channel: "signal", messageId }],
kind: opts.mediaUrl ? "media" : "text",
}),
};
});
const deps = { signal: send };
const proofResults = await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "signal",
adapter: signalPlugin.message!,
proofs: {
text: async () => {
const result = await signalPlugin.message?.send?.text?.({
cfg: {} as OpenClawConfig,
to: "signal:+15555550123",
text: "hello",
deps,
} as Parameters<NonNullable<typeof signalPlugin.message.send.text>>[0] & {
deps: typeof deps;
});
expect(send).toHaveBeenCalledWith("+15555550123", "hello", {
cfg: {},
maxBytes: undefined,
accountId: undefined,
});
expect(result?.receipt.platformMessageIds).toEqual(["signal-text-1"]);
},
media: async () => {
const result = await signalPlugin.message?.send?.media?.({
cfg: {} as OpenClawConfig,
to: "signal:+15555550123",
text: "image",
mediaUrl: "https://example.com/image.png",
deps,
} as Parameters<NonNullable<typeof signalPlugin.message.send.media>>[0] & {
deps: typeof deps;
});
expect(send).toHaveBeenCalledWith("+15555550123", "image", {
cfg: {},
mediaUrl: "https://example.com/image.png",
maxBytes: undefined,
accountId: undefined,
});
expect(result?.receipt.platformMessageIds).toEqual(["signal-media-1"]);
},
},
});
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: "not_declared" },
{ capability: "batch", status: "not_declared" },
{ capability: "reconcileUnknownSend", status: "not_declared" },
{ capability: "afterSendSuccess", status: "not_declared" },
{ capability: "afterCommit", status: "not_declared" },
]);
});
});
describe("signal setup parsing", () => {
it("accepts already normalized numbers", () => {
expect(normalizeSignalAccountInput("+15555550123")).toBe("+15555550123");
});
it("normalizes valid E.164 numbers", () => {
expect(normalizeSignalAccountInput(" +1 (555) 555-0123 ")).toBe("+15555550123");
});
it("rejects empty input", () => {
expect(normalizeSignalAccountInput(" ")).toBeNull();
});
it("rejects invalid values", () => {
expect(normalizeSignalAccountInput("abc")).toBeNull();
expect(normalizeSignalAccountInput("++--")).toBeNull();
});
it("rejects inputs with stray + characters", () => {
expect(normalizeSignalAccountInput("++12345")).toBeNull();
expect(normalizeSignalAccountInput("+1+2345")).toBeNull();
});
it("rejects numbers that are too short or too long", () => {
expect(normalizeSignalAccountInput("+1234")).toBeNull();
expect(normalizeSignalAccountInput("+1234567890123456")).toBeNull();
});
it("validates and applies allowlist entries through the DM policy prompt", async () => {
const text = vi.fn(async (params: Parameters<WizardPrompter["text"]>[0]) => {
expect(params.validate?.("uuid:")).toBe("Invalid uuid entry");
expect(params.validate?.("invalid")).toBe("Invalid entry: invalid");
return "signal:+15555550123, 123e4567-e89b-12d3-a456-426614174000, *";
}) as WizardPrompter["text"];
const next = await signalDmPolicy.promptAllowFrom({
cfg: { channels: { signal: {} } },
prompter: createTestWizardPrompter({ text }),
});
expect(next.channels?.signal?.allowFrom).toEqual([
"+15555550123",
"uuid:123e4567-e89b-12d3-a456-426614174000",
"*",
]);
});
it("reads the named-account DM policy instead of the channel root", () => {
expect(
signalDmPolicy.getCurrent(
{
channels: {
signal: {
dmPolicy: "disabled",
accounts: {
work: {
account: "+15555550123",
dmPolicy: "allowlist",
},
},
},
},
},
"work",
),
).toBe("allowlist");
});
it("reports account-scoped config keys for named accounts", () => {
expect(signalDmPolicy.resolveConfigKeys?.({ channels: { signal: {} } }, "work")).toEqual({
policyKey: "channels.signal.accounts.work.dmPolicy",
allowFromKey: "channels.signal.accounts.work.allowFrom",
});
});
it("uses configured defaultAccount for omitted DM policy account context", () => {
const cfg: OpenClawConfig = {
channels: {
signal: {
defaultAccount: "work",
dmPolicy: "disabled",
allowFrom: ["+15555550123"],
accounts: {
work: {
account: "+15555550999",
dmPolicy: "allowlist",
},
},
},
},
};
expect(signalDmPolicy.getCurrent(cfg)).toBe("allowlist");
expect(signalDmPolicy.resolveConfigKeys?.(cfg)).toEqual({
policyKey: "channels.signal.accounts.work.dmPolicy",
allowFromKey: "channels.signal.accounts.work.allowFrom",
});
const next = signalDmPolicy.setPolicy(cfg, "open");
expect(next.channels?.signal?.dmPolicy).toBe("disabled");
expect(next.channels?.signal?.allowFrom).toEqual(["+15555550123"]);
expect(next.channels?.signal?.accounts?.work?.dmPolicy).toBe("open");
expect(next.channels?.signal?.accounts?.work?.allowFrom).toEqual(["+15555550123", "*"]);
});
it('writes open policy state to the named account and stores inherited allowFrom with "*"', () => {
const cfg: OpenClawConfig = {
channels: {
signal: {
allowFrom: ["+15555550123"],
accounts: {
work: {
account: "+15555550999",
},
},
},
},
};
const next = signalDmPolicy.setPolicy(cfg, "open", "work");
expect(next.channels?.signal?.dmPolicy).toBeUndefined();
expect(next.channels?.signal?.allowFrom).toEqual(["+15555550123"]);
expect(next.channels?.signal?.accounts?.work?.dmPolicy).toBe("open");
expect(next.channels?.signal?.accounts?.work?.allowFrom).toEqual(["+15555550123", "*"]);
});
});