Add native Signal reply quotes (#95718)

* feat(signal): quote native replies

* Fix Signal native reply edge cases

* fix(signal): tighten native reply quotes

* fix(signal): harden native reply quotes

* fix(signal): preserve approval reaction state

* fix(signal): target native replies to edited messages

* fix(signal): preserve plugin approval reaction kind

* fix(signal): keep edit event reply ids distinct
This commit is contained in:
Jesse Merhi 2026-07-07 13:49:13 +10:00 committed by GitHub
parent de8db74058
commit 4112e3a0f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2262 additions and 38 deletions

View file

@ -250,6 +250,8 @@ Groups:
- Container mode: the gateway sends via REST API and receives via WebSocket.
- Inbound messages are normalized into the shared channel envelope.
- Replies always route back to the same number or group.
- Replies to inbound messages include native Signal quote metadata when the backend accepts the inbound timestamp and author; if quote metadata is missing or rejected, OpenClaw sends the reply as a normal message.
- Configure native quote use with `channels.signal.replyToMode = off | first | all | batched`, or `channels.signal.replyToModeByChatType.direct/group` for per-chat-type overrides. Account-level values under `channels.signal.accounts.<id>` take precedence.
## Media + limits
@ -432,6 +434,9 @@ Provider options:
- `channels.signal.groups`: per-group overrides keyed by Signal group ID (or `"*"`). Supported fields: `requireMention`, `tools`, `toolsBySender`.
- `channels.signal.accounts.<id>.groups`: per-account version of `channels.signal.groups` for multi-account setups.
- `channels.signal.accounts.<id>.aliases`: per-account aliases, merged with top-level aliases.
- `channels.signal.replyToMode`: native reply quote mode, `off | first | all | batched` (default: `all`).
- `channels.signal.replyToModeByChatType.direct`, `channels.signal.replyToModeByChatType.group`: per-chat-type native reply quote overrides.
- `channels.signal.accounts.<id>.replyToMode`, `channels.signal.accounts.<id>.replyToModeByChatType.direct`, `channels.signal.accounts.<id>.replyToModeByChatType.group`: per-account reply quote overrides.
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
- `channels.signal.textChunkLimit`: outbound chunk size in characters (default 4000).

View file

@ -2,9 +2,11 @@
import {
createAccountListHelpers,
normalizeAccountId,
resolveAccountEntry,
resolveMergedAccountConfig,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-resolution";
import type { ReplyToMode } from "openclaw/plugin-sdk/config-contracts";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { SignalAccountConfig } from "./account-types.js";
@ -74,3 +76,49 @@ export function listEnabledSignalAccounts(cfg: OpenClawConfig): ResolvedSignalAc
.map((accountId) => resolveSignalAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}
function normalizeSignalReplyToMode(value: unknown): ReplyToMode | undefined {
return value === "off" || value === "first" || value === "all" || value === "batched"
? value
: undefined;
}
export function resolveSignalReplyToMode(params: {
cfg: OpenClawConfig;
accountId?: string | null;
chatType?: string | null;
}): ReplyToMode {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultSignalAccountId(params.cfg),
);
const signalConfig = params.cfg.channels?.signal;
const accountConfig = resolveAccountEntry(
signalConfig?.accounts as Record<string, SignalAccountConfig> | undefined,
accountId,
);
const chatType =
params.chatType === "direct" || params.chatType === "group" ? params.chatType : undefined;
if (chatType) {
const accountScoped = normalizeSignalReplyToMode(
accountConfig?.replyToModeByChatType?.[chatType],
);
if (accountScoped) {
return accountScoped;
}
const accountDefault = normalizeSignalReplyToMode(accountConfig?.replyToMode);
if (accountDefault) {
return accountDefault;
}
const channelScoped = normalizeSignalReplyToMode(
signalConfig?.replyToModeByChatType?.[chatType],
);
if (channelScoped) {
return channelScoped;
}
}
return (
normalizeSignalReplyToMode(accountConfig?.replyToMode) ??
normalizeSignalReplyToMode(signalConfig?.replyToMode) ??
"all"
);
}

View file

@ -371,6 +371,70 @@ export function addSignalApprovalReactionHintToText(params: {
return addApprovalReactionHintToText(params);
}
function resolveStandaloneApprovalPromptKind(text: string): ApprovalKind | null {
const firstLine = text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
if (/^(?:🔒\s*)?Exec approval required$/.test(firstLine ?? "")) {
return "exec";
}
if (/^(?:(?:🛡️|🛡|🚨||)\s*)?Plugin approval required$/.test(firstLine ?? "")) {
return "plugin";
}
return null;
}
function isStandaloneApprovalPromptText(text: string): boolean {
return resolveStandaloneApprovalPromptKind(text) !== null;
}
function normalizeApprovalDecision(value: string): ExecApprovalReplyDecision | null {
const normalized = value.trim().toLowerCase();
if (normalized === "always") {
return "allow-always";
}
if (normalized === "allow-once" || normalized === "allow-always" || normalized === "deny") {
return normalized;
}
return null;
}
const APPROVAL_ID_LINE_RE = /^\s*ID:\s*([A-Za-z0-9][A-Za-z0-9._:-]*)\s*$/i;
const APPROVE_REPLY_COMMAND_LINE_RE =
/^\s*Reply with:\s*\/approve(?:@[^\s]+)?\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s+(.+)$/i;
export function extractSignalApprovalPromptBinding(text: string): {
approvalId: string;
approvalKind: ApprovalKind;
allowedDecisions: ExecApprovalReplyDecision[];
} | null {
const lines = text.split(/\r?\n/);
const idHeaderMatch = lines
.map((line) => line.match(APPROVAL_ID_LINE_RE))
.find((match): match is RegExpMatchArray => Boolean(match));
if (!idHeaderMatch) {
return null;
}
const approvalId = idHeaderMatch[1];
const approvalKind =
resolveStandaloneApprovalPromptKind(text) ?? resolveApprovalKindFromId(approvalId);
const allowedDecisions: ExecApprovalReplyDecision[] = [];
for (const line of lines) {
const match = line.match(APPROVE_REPLY_COMMAND_LINE_RE);
if (!match || match[1] !== approvalId) {
continue;
}
for (const decisionText of match[2].split(/[\s|,]+/)) {
const decision = normalizeApprovalDecision(decisionText);
if (decision && !allowedDecisions.includes(decision)) {
allowedDecisions.push(decision);
}
}
}
return allowedDecisions.length > 0 ? { approvalId, approvalKind, allowedDecisions } : null;
}
function buildTargetRoute(params: {
cfg: OpenClawConfig;
accountId?: string | null;
@ -412,6 +476,68 @@ function buildTargetRoute(params: {
: null;
}
export function shouldAppendSignalApprovalReactionHintForOutboundMessage(params: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
text: string;
targetAuthor?: string | null;
targetAuthorUuid?: string | null;
agentId?: string | null;
sessionKey?: string | null;
}): boolean {
const binding = extractSignalApprovalPromptBinding(params.text);
if (!binding) {
return false;
}
if (resolveSignalApprovalTargetAuthorKeys(params).length === 0) {
return false;
}
if (!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.accountId })) {
return false;
}
return Boolean(
buildTargetRoute({
cfg: params.cfg,
accountId: params.accountId,
to: params.to,
approvalId: binding.approvalId,
approvalKind: binding.approvalKind,
agentId: params.agentId,
sessionKey: params.sessionKey,
}),
);
}
export function appendSignalApprovalReactionHintForOutboundMessage(params: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
text: string;
targetAuthor?: string | null;
targetAuthorUuid?: string | null;
agentId?: string | null;
sessionKey?: string | null;
}): string {
if (!isStandaloneApprovalPromptText(params.text)) {
return params.text;
}
const binding = extractSignalApprovalPromptBinding(params.text);
if (
!binding ||
!shouldAppendSignalApprovalReactionHintForOutboundMessage({
...params,
text: params.text,
})
) {
return params.text;
}
return addSignalApprovalReactionHintToText({
text: params.text,
allowedDecisions: binding.allowedDecisions,
});
}
export function hasSignalApprovalReactionApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
@ -524,8 +650,7 @@ export function addSignalApprovalReactionHintToStructuredPayload(params: {
}
function readSignalDeliveryVisibleText(result: SignalApprovalDeliveryResult): string | null {
const meta = result.meta;
const visibleText = meta?.signalVisibleText ?? meta?.visibleText;
const visibleText = result.meta?.signalVisibleText ?? result.meta?.visibleText;
return typeof visibleText === "string" ? visibleText : null;
}
@ -626,6 +751,64 @@ export function registerSignalApprovalReactionTargetForDeliveredPayload(params:
return registered;
}
export function registerSignalApprovalReactionTargetForOutboundMessage(params: {
cfg: OpenClawConfig;
accountId: string;
to: string;
messageId: string;
text: string;
targetAuthor?: string | null;
targetAuthorUuid?: string | null;
agentId?: string | null;
sessionKey?: string | null;
ttlMs?: number;
}): boolean {
if (!isStandaloneApprovalPromptText(params.text)) {
return false;
}
const binding = extractSignalApprovalPromptBinding(params.text);
if (!binding) {
return false;
}
if (!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.accountId })) {
return false;
}
const targetAuthorKeys = resolveSignalApprovalTargetAuthorKeys(params);
if (targetAuthorKeys.length === 0) {
return false;
}
const conversationKey = resolveSignalApprovalConversationKey(params.to);
if (!conversationKey) {
return false;
}
const route = buildTargetRoute({
cfg: params.cfg,
accountId: params.accountId,
to: params.to,
approvalId: binding.approvalId,
approvalKind: binding.approvalKind,
agentId: params.agentId,
sessionKey: params.sessionKey,
});
if (!route) {
return false;
}
return Boolean(
registerSignalApprovalReactionTarget({
accountId: params.accountId,
conversationKey,
messageId: params.messageId,
approvalId: binding.approvalId,
approvalKind: binding.approvalKind,
allowedDecisions: binding.allowedDecisions,
targetAuthorKeys,
route,
routeAllowed: true,
ttlMs: params.ttlMs,
}),
);
}
export function unregisterSignalApprovalReactionTarget(params: {
accountId: string;
conversationKey: string;

View file

@ -19,9 +19,16 @@ import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js";
import {
resolveSignalAccount,
resolveSignalReplyToMode,
type ResolvedSignalAccount,
} from "./accounts.js";
import { listSignalAliasDirectoryEntries, resolveSignalTarget } from "./aliases.js";
import {
shouldSuppressLocalSignalExecApprovalPrompt,
@ -318,17 +325,20 @@ async function registerDeliveredSignalApprovalPayloadForReactions(
cfg: params.cfg,
accountId: params.target.accountId ?? undefined,
});
if (!account.config.account) {
const targetAuthor = normalizeOptionalString(account.config.account);
const targetAuthorUuid = normalizeOptionalString(account.config.accountUuid);
if (!targetAuthor && !targetAuthorUuid) {
return;
}
const { registerSignalApprovalReactionTargetForDeliveredPayload } =
await loadSignalApprovalReactionsModule();
registerSignalApprovalReactionTargetForDeliveredPayload({
cfg: params.cfg,
target: params.target,
target: { ...params.target, accountId: account.accountId },
payload: params.payload,
results: params.results,
targetAuthor: account.config.account,
targetAuthor,
targetAuthorUuid,
});
}
@ -339,7 +349,9 @@ async function renderSignalApprovalPayloadForReactions(
cfg: params.ctx.cfg,
accountId: params.ctx.accountId ?? undefined,
});
if (!account.config.account) {
const targetAuthor = normalizeOptionalString(account.config.account);
const targetAuthorUuid = normalizeOptionalString(account.config.accountUuid);
if (!targetAuthor && !targetAuthorUuid) {
return null;
}
const { addSignalApprovalReactionHintToStructuredPayload } =
@ -349,7 +361,8 @@ async function renderSignalApprovalPayloadForReactions(
accountId: params.ctx.accountId ?? undefined,
to: params.ctx.to,
payload: params.payload,
targetAuthor: account.config.account,
targetAuthor,
targetAuthorUuid,
});
}
@ -509,6 +522,9 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
},
},
security: signalSecurityAdapter,
threading: {
resolveReplyToMode: (params) => resolveSignalReplyToMode(params),
},
outbound: {
base: {
deliveryMode: "direct",

View file

@ -318,6 +318,19 @@ describe("containerRestRequest", () => {
).rejects.toThrow("Signal REST 500: Server error details");
});
it("bounds REST error response bodies before reporting failures", async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
statusText: "Internal Server Error",
...bodyStream("x".repeat(20_000)),
});
await expect(
containerRestRequest("/v2/send", { baseUrl: "http://localhost:8080" }, "POST"),
).rejects.toThrow(`Signal REST 500: ${"x".repeat(16 * 1024)}`);
});
it("handles empty response body", async () => {
mockFetch.mockResolvedValue({
ok: true,
@ -399,6 +412,29 @@ describe("containerSendMessage", () => {
);
});
it("passes quote metadata through v2 send using container field names", async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
...bodyStream(JSON.stringify({ timestamp: "1700000000000" })),
});
await containerSendMessage({
baseUrl: "http://localhost:8080",
account: "+14259798283",
recipients: ["+15550001111"],
message: "Hello world",
quoteTimestamp: 1699999999999,
quoteAuthor: "+15550002222",
quoteMessage: "original",
});
const body = parseFetchBody();
expect(body.quote_timestamp).toBe(1699999999999);
expect(body.quote_author).toBe("+15550002222");
expect(body.quote_message).toBe("original");
});
it("normalizes invalid send timestamps before returning", async () => {
mockFetch.mockResolvedValue({
ok: true,
@ -602,6 +638,88 @@ describe("containerRpcRequest typing", () => {
});
});
describe("containerRpcRequest send", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("translates native quote params to container send fields", async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
...bodyStream(JSON.stringify({ timestamp: "1700000000000" })),
});
await containerRpcRequest(
"send",
{
account: "+14259798283",
recipient: ["+15550001111"],
message: "Hello world",
quoteTimestamp: 1699999999999,
quoteAuthor: "+15550002222",
quoteMessage: "original",
},
{ baseUrl: "http://localhost:8080" },
);
const body = parseFetchBody();
expect(body.quote_timestamp).toBe(1699999999999);
expect(body.quote_author).toBe("+15550002222");
expect(body.quote_message).toBe("original");
});
it("strips uuid prefixes from native quote authors", async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
...bodyStream(JSON.stringify({ timestamp: "1700000000000" })),
});
await containerRpcRequest(
"send",
{
account: "+14259798283",
recipient: ["+15550001111"],
message: "Hello world",
quoteTimestamp: 1699999999999,
quoteAuthor: "uuid:author-uuid",
quoteMessage: "original",
},
{ baseUrl: "http://localhost:8080" },
);
const body = parseFetchBody();
expect(body.quote_author).toBe("author-uuid");
});
it("ignores malformed native quote params at the container boundary", async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
...bodyStream(JSON.stringify({ timestamp: "1700000000000" })),
});
await containerRpcRequest(
"send",
{
account: "+14259798283",
recipient: ["+15550001111"],
message: "Hello world",
quoteTimestamp: "not-a-timestamp",
quoteAuthor: ["+15550002222"],
quoteMessage: { text: "original" },
},
{ baseUrl: "http://localhost:8080" },
);
const body = parseFetchBody();
expect(body).not.toHaveProperty("quote_timestamp");
expect(body).not.toHaveProperty("quote_author");
expect(body).not.toHaveProperty("quote_message");
});
});
describe("containerSendReceipt", () => {
beforeEach(() => {
vi.clearAllMocks();

View file

@ -466,6 +466,14 @@ function parseContainerSendTimestamp(raw: unknown): number | undefined {
return timestamp;
}
function normalizeContainerQuoteTimestamp(raw: unknown): number | undefined {
return parseStrictNonNegativeInteger(raw) ?? undefined;
}
function normalizeContainerQuoteText(raw: unknown): string | undefined {
return typeof raw === "string" ? raw : undefined;
}
/**
* Send message via bbernhard container REST API.
*/
@ -476,6 +484,9 @@ export async function containerSendMessage(params: {
message: string;
textStyles?: Array<{ start: number; length: number; style: string }>;
attachments?: string[];
quoteTimestamp?: number;
quoteAuthor?: string;
quoteMessage?: string;
timeoutMs?: number;
}): Promise<{ timestamp?: number }> {
const payload: Record<string, unknown> = {
@ -493,6 +504,11 @@ export async function containerSendMessage(params: {
// Container API only accepts base64-encoded attachments, not file paths.
payload.base64_attachments = await filesToBase64DataUris(params.attachments);
}
if (params.quoteTimestamp !== undefined && params.quoteAuthor) {
payload.quote_timestamp = params.quoteTimestamp;
payload.quote_author = params.quoteAuthor;
payload.quote_message = params.quoteMessage ?? "";
}
const result = await containerRestRequest<{ timestamp?: unknown }>(
"/v2/send",
@ -668,6 +684,10 @@ export async function containerRpcRequest<T = unknown>(
return { start: Number(start), length: Number(length), style };
});
const quoteTimestamp = normalizeContainerQuoteTimestamp(
p.quoteTimestamp ?? p["quote-timestamp"],
);
const quoteAuthor = normalizeContainerQuoteText(p.quoteAuthor ?? p["quote-author"]);
const result = await containerSendMessage({
baseUrl: opts.baseUrl,
account: (p.account as string) ?? "",
@ -675,6 +695,9 @@ export async function containerRpcRequest<T = unknown>(
message: (p.message as string) ?? "",
textStyles,
attachments: p.attachments as string[] | undefined,
quoteTimestamp,
quoteAuthor: quoteAuthor ? stripUuidPrefix(quoteAuthor) : undefined,
quoteMessage: normalizeContainerQuoteText(p.quoteMessage ?? p["quote-message"]),
timeoutMs: opts.timeoutMs,
});
return result as T;

View file

@ -6,6 +6,7 @@ import {
} from "openclaw/plugin-sdk/channel-outbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createPluginSetupWizardStatus } 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,
@ -546,6 +547,34 @@ describe("signal outbound", () => {
]);
});
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("chunks outbound text without requiring Signal runtime initialization", () => {
clearSignalRuntime();
const chunker = signalPlugin.outbound?.chunker;
@ -556,6 +585,20 @@ describe("signal outbound", () => {
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) {
@ -735,6 +778,97 @@ describe("signal outbound", () => {
).toBeNull();
});
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("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";

View file

@ -39,14 +39,23 @@ const cfg = {
},
} as OpenClawConfig;
async function deliverReplyPayload(payload: ReplyPayload) {
async function deliverReplyPayload(
payload: ReplyPayload,
options: {
config?: OpenClawConfig;
account?: string;
accountUuid?: string;
accountId?: string;
} = {},
) {
await deliverReplies({
cfg,
cfg: options.config ?? cfg,
replies: [payload],
target: approver,
baseUrl: "http://127.0.0.1:8080",
account: botAccount,
accountId: "default",
account: Object.hasOwn(options, "account") ? options.account : botAccount,
accountUuid: options.accountUuid,
accountId: options.accountId ?? "default",
runtime: { log: vi.fn() } as never,
maxBytes: 8 * 1024 * 1024,
textLimit: 4000,
@ -99,6 +108,55 @@ describe("Signal monitor approval reply delivery", () => {
});
});
it("registers monitor approval replies for UUID-only linked accounts", async () => {
const accountUuid = "123e4567-e89b-12d3-a456-426614174000";
const payload = buildExecApprovalPendingReplyPayload({
approvalId: "exec-monitor-uuid",
approvalSlug: "exec-uuid",
allowedDecisions: ["allow-once", "deny"],
command: "printf uuid",
host: "gateway",
agentId: "main",
sessionKey: "agent:main:signal:direct:+15551230000",
});
const uuidOnlyConfig = {
channels: {
signal: {
accounts: {
default: {
accountUuid,
allowFrom: [approver],
},
},
},
},
approvals: cfg.approvals,
} as OpenClawConfig;
await deliverReplyPayload(payload, {
config: uuidOnlyConfig,
account: undefined,
accountUuid,
accountId: "default",
});
const sentText = String(sendMocks.sendMessageSignal.mock.calls[0]?.[1] ?? "");
expect(sentText).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: approver,
messageId: "1700000000200",
reactionKey: "👍",
targetAuthorUuid: accountUuid,
}),
).resolves.toMatchObject({
approvalId: "exec-monitor-uuid",
approvalKind: "exec",
decision: "allow-once",
});
});
it("does not bind ordinary monitor replies that quote approval commands", async () => {
const payload = {
text: [

View file

@ -143,6 +143,637 @@ describe("monitorSignalProvider tool results", () => {
expect(sendMock.mock.calls[0]?.[1]).toBe("PFX final reply");
});
it("passes inbound Signal quote metadata to final replies", async () => {
replyMock.mockResolvedValue({ text: "final reply" });
await receiveSignalPayloads({
payloads: [
{
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1700000000001,
dataMessage: {
message: "quote me",
},
},
},
],
});
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledTimes(1);
});
expect(sendMock.mock.calls[0]?.[2]).toMatchObject({
replyToId: "1700000000001",
replyToAuthor: "+15550001111",
replyToBody: "quote me",
});
});
it("passes UUID-only inbound Signal quote metadata to final replies", async () => {
replyMock.mockResolvedValue({ text: "final reply" });
await receiveSignalPayloads({
payloads: [
{
envelope: {
sourceUuid: "123e4567-e89b-12d3-a456-426614174000",
sourceName: "Ada",
timestamp: 1700000000001,
dataMessage: {
message: "quote me",
},
},
},
],
});
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledTimes(1);
});
expect(sendMock.mock.calls[0]?.[2]).toMatchObject({
replyToId: "1700000000001",
replyToAuthor: "123e4567-e89b-12d3-a456-426614174000",
replyToBody: "quote me",
});
});
it("passes group inbound quote metadata through group reply mode overrides", async () => {
setSignalToolResultTestConfig(
createSignalToolResultConfig({
autoStart: false,
groupPolicy: "open",
replyToMode: "off",
replyToModeByChatType: { group: "all" },
}),
);
replyMock.mockResolvedValue({ text: "group reply" });
await receiveSignalPayloads({
payloads: [
{
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1700000000001,
dataMessage: {
message: "group quote me",
groupInfo: {
groupId: "signal-group-id",
groupName: "Testing realm",
},
},
},
},
],
});
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledTimes(1);
});
expect(sendMock.mock.calls[0]?.[0]).toBe("group:signal-group-id");
expect(sendMock.mock.calls[0]?.[2]).toMatchObject({
replyToId: "1700000000001",
replyToAuthor: "+15550001111",
replyToBody: "group quote me",
});
});
it("uses native quote metadata on every implicit chunk when configured for all replies", async () => {
setSignalToolResultTestConfig(
createSignalToolResultConfig({
autoStart: false,
replyToMode: "all",
textChunkLimit: 8,
}),
);
replyMock.mockResolvedValue({ text: "chunked Signal reply" });
await receiveSignalPayloads({
payloads: [
{
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1700000000001,
dataMessage: {
message: "quote me",
},
},
},
],
});
await vi.waitFor(() => {
expect(sendMock.mock.calls.length).toBeGreaterThan(1);
});
for (const call of sendMock.mock.calls) {
expect(call[2]).toMatchObject({
replyToId: "1700000000001",
replyToAuthor: "+15550001111",
replyToBody: "quote me",
});
}
});
it("uses native quote metadata only on the first implicit chunk when configured", async () => {
setSignalToolResultTestConfig(
createSignalToolResultConfig({
autoStart: false,
replyToMode: "first",
textChunkLimit: 8,
}),
);
replyMock.mockResolvedValue({ text: "chunked Signal reply" });
await receiveSignalPayloads({
payloads: [
{
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1700000000001,
dataMessage: {
message: "quote me",
},
},
},
],
});
await vi.waitFor(() => {
expect(sendMock.mock.calls.length).toBeGreaterThan(1);
});
expect(sendMock.mock.calls[0]?.[2]).toMatchObject({
replyToId: "1700000000001",
replyToAuthor: "+15550001111",
replyToBody: "quote me",
});
for (const call of sendMock.mock.calls.slice(1)) {
expect(call[2]).not.toHaveProperty("replyToId");
expect(call[2]).not.toHaveProperty("replyToAuthor");
expect(call[2]).not.toHaveProperty("replyToBody");
}
});
it("uses native quote metadata only on the first implicit payload when configured", async () => {
setSignalToolResultTestConfig(
createSignalToolResultConfig({
autoStart: false,
replyToMode: "first",
}),
);
replyMock.mockResolvedValue([{ text: "first reply" }, { text: "second reply" }]);
await receiveSignalPayloads({
payloads: [
{
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1700000000001,
dataMessage: {
message: "quote me",
},
},
},
],
});
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledTimes(2);
});
expect(sendMock.mock.calls[0]?.[2]).toMatchObject({
replyToId: "1700000000001",
replyToAuthor: "+15550001111",
replyToBody: "quote me",
});
expect(sendMock.mock.calls[1]?.[2]).not.toHaveProperty("replyToId");
expect(sendMock.mock.calls[1]?.[2]).not.toHaveProperty("replyToAuthor");
expect(sendMock.mock.calls[1]?.[2]).not.toHaveProperty("replyToBody");
});
it.each([
["status", { isStatusNotice: true }],
["fallback", { isFallbackNotice: true }],
["compaction", { isCompactionNotice: true }],
] as const)(
"does not let %s notices consume the first native quote slot",
async (_name, flag) => {
setSignalToolResultTestConfig(
createSignalToolResultConfig({
autoStart: false,
replyToMode: "first",
}),
);
replyMock.mockResolvedValue([{ text: "working", ...flag }, { text: "final reply" }]);
await receiveSignalPayloads({
payloads: [
{
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1700000000001,
dataMessage: {
message: "quote me",
},
},
},
],
});
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledTimes(2);
});
for (const call of sendMock.mock.calls) {
expect(call[2]).toMatchObject({
replyToId: "1700000000001",
replyToAuthor: "+15550001111",
replyToBody: "quote me",
});
}
},
);
it.each([
["status", { isStatusNotice: true }],
["fallback", { isFallbackNotice: true }],
["compaction", { isCompactionNotice: true }],
] as const)(
"keeps %s notices quoted after the first normal native reply",
async (_name, flag) => {
setSignalToolResultTestConfig(
createSignalToolResultConfig({
autoStart: false,
replyToMode: "first",
}),
);
replyMock.mockResolvedValue([{ text: "final reply" }, { text: "still working", ...flag }]);
await receiveSignalPayloads({
payloads: [
{
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1700000000001,
dataMessage: {
message: "quote me",
},
},
},
],
});
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledTimes(2);
});
for (const call of sendMock.mock.calls) {
expect(call[2]).toMatchObject({
replyToId: "1700000000001",
replyToAuthor: "+15550001111",
replyToBody: "quote me",
});
}
},
);
it.each([
["status", { isStatusNotice: true }],
["fallback", { isFallbackNotice: true }],
["compaction", { isCompactionNotice: true }],
] as const)("does not quote %s notices when native quote mode is off", async (_name, flag) => {
setSignalToolResultTestConfig(
createSignalToolResultConfig({
autoStart: false,
replyToMode: "off",
}),
);
replyMock.mockResolvedValue([{ text: "working", ...flag }]);
await receiveSignalPayloads({
payloads: [
{
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1700000000001,
dataMessage: {
message: "quote me",
},
},
},
],
});
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledTimes(1);
});
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToId");
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToAuthor");
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToBody");
});
it("does not implicitly quote a single-message batched-mode turn", async () => {
setSignalToolResultTestConfig(
createSignalToolResultConfig({
autoStart: false,
replyToMode: "batched",
}),
);
replyMock.mockResolvedValue({ text: "final reply" });
await receiveSignalPayloads({
payloads: [
{
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1700000000001,
dataMessage: {
message: "quote me",
},
},
},
],
});
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledTimes(1);
});
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToId");
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToAuthor");
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToBody");
});
it("quotes the last inbound message for multi-message batched-mode turns", async () => {
vi.useFakeTimers();
try {
setSignalToolResultTestConfig({
...createSignalToolResultConfig({
autoStart: false,
replyToMode: "batched",
}),
messages: { inbound: { debounceMs: 10 } },
});
replyMock.mockResolvedValue([{ text: "first reply" }, { text: "second reply" }]);
const abortController = new AbortController();
streamMock.mockImplementation(async ({ onEvent }) => {
for (const [timestamp, message] of [
[1700000000001, "first debounced message"],
[1700000000002, "second debounced message"],
] as const) {
await onEvent({
event: "receive",
data: JSON.stringify({
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp,
dataMessage: { message },
},
}),
});
}
await vi.advanceTimersByTimeAsync(10);
abortController.abort();
});
await runMonitorWithMocks({
autoStart: false,
baseUrl: SIGNAL_BASE_URL,
abortSignal: abortController.signal,
});
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledTimes(2);
});
expect(sendMock.mock.calls[0]?.[2]).toMatchObject({
replyToId: "1700000000002",
replyToAuthor: "+15550001111",
replyToBody: "second debounced message",
});
expect(sendMock.mock.calls[1]?.[2]).not.toHaveProperty("replyToId");
expect(sendMock.mock.calls[1]?.[2]).not.toHaveProperty("replyToAuthor");
expect(sendMock.mock.calls[1]?.[2]).not.toHaveProperty("replyToBody");
} finally {
vi.useRealTimers();
}
});
it("passes inbound Signal quote metadata to media replies", async () => {
replyMock.mockResolvedValue({ text: "caption", mediaUrl: "file:///tmp/reply.png" });
await receiveSignalPayloads({
payloads: [
{
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1700000000001,
dataMessage: {
message: "quote me",
},
},
},
],
});
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledTimes(1);
});
expect(sendMock.mock.calls[0]?.[2]).toMatchObject({
mediaUrl: "file:///tmp/reply.png",
replyToId: "1700000000001",
replyToAuthor: "+15550001111",
replyToBody: "quote me",
});
});
it("does not attach native quote metadata for a different explicit reply target", async () => {
replyMock.mockResolvedValue({ text: "final reply", replyToId: "1700000000999" });
await receiveSignalPayloads({
payloads: [
{
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1700000000001,
dataMessage: {
message: "quote me",
},
},
},
],
});
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledTimes(1);
});
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToId");
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToAuthor");
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToBody");
});
it("does not attach native quote metadata when the reply opts out of the current message", async () => {
replyMock.mockResolvedValue({ text: "status reply", replyToCurrent: false });
await receiveSignalPayloads({
payloads: [
{
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1700000000001,
dataMessage: {
message: "quote me",
},
},
},
],
});
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledTimes(1);
});
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToId");
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToAuthor");
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToBody");
});
it("does not reconstruct native quote metadata when replyToMode strips threading", async () => {
setSignalToolResultTestConfig(
createSignalToolResultConfig({ autoStart: false, replyToMode: "off" }),
);
replyMock.mockResolvedValue({ text: "final reply" });
await receiveSignalPayloads({
payloads: [
{
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1700000000001,
dataMessage: {
message: "quote me",
},
},
},
],
});
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledTimes(1);
});
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToId");
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToAuthor");
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToBody");
});
it("keeps explicit current-message native quote metadata when reply mode is off", async () => {
setSignalToolResultTestConfig(
createSignalToolResultConfig({ autoStart: false, replyToMode: "off" }),
);
replyMock.mockResolvedValue({ text: "final reply", replyToCurrent: true });
await receiveSignalPayloads({
payloads: [
{
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1700000000001,
dataMessage: {
message: "quote me",
},
},
},
],
});
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledTimes(1);
});
expect(sendMock.mock.calls[0]?.[2]).toMatchObject({
replyToId: "1700000000001",
replyToAuthor: "+15550001111",
replyToBody: "quote me",
});
});
it("lets direct chat replyToMode override channel default quote settings", async () => {
setSignalToolResultTestConfig(
createSignalToolResultConfig({
autoStart: false,
replyToMode: "all",
replyToModeByChatType: { direct: "off" },
}),
);
replyMock.mockResolvedValue({ text: "final reply" });
await receiveSignalPayloads({
payloads: [
{
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1700000000001,
dataMessage: {
message: "quote me",
},
},
},
],
});
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledTimes(1);
});
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToId");
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToAuthor");
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToBody");
});
it("lets account replyToMode override channel chat-type quote settings", async () => {
setSignalToolResultTestConfig(
createSignalToolResultConfig({
autoStart: false,
replyToModeByChatType: { direct: "all" },
accounts: {
default: {
replyToMode: "off",
},
},
}),
);
replyMock.mockResolvedValue({ text: "final reply" });
await receiveSignalPayloads({
payloads: [
{
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1700000000001,
dataMessage: {
message: "quote me",
},
},
},
],
});
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledTimes(1);
});
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToId");
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToAuthor");
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("replyToBody");
});
it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => {
setSignalToolResultTestConfig(
createSignalToolResultConfig({ autoStart: false, dmPolicy: "pairing", allowFrom: [] }),

View file

@ -128,21 +128,73 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
ctx: unknown;
cfg: unknown;
dispatcher: {
sendFinalReply: (payload: { text: string }) => boolean;
sendFinalReply: (payload: {
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
}) => boolean;
markComplete?: () => void;
waitForIdle?: () => Promise<void>;
};
}) => {
type TestReplyPayload = {
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
isCompactionNotice?: boolean;
isFallbackNotice?: boolean;
isStatusNotice?: boolean;
replyToId?: string;
replyToTag?: boolean;
replyToCurrent?: boolean;
};
const resolved = (await replyMock(params.ctx, {}, params.cfg)) as
| { text?: string }
| {
replies?: TestReplyPayload[];
}
| TestReplyPayload
| TestReplyPayload[]
| undefined;
const text = typeof resolved?.text === "string" ? resolved.text.trim() : "";
if (text) {
params.dispatcher.sendFinalReply({ text });
const contextReplyToId =
typeof (params.ctx as { ReplyToId?: unknown }).ReplyToId === "string"
? (params.ctx as { ReplyToId: string }).ReplyToId
: undefined;
const replyThreading = (params.ctx as { ReplyThreading?: unknown }).ReplyThreading;
const implicitCurrentMessage =
typeof replyThreading === "object" &&
replyThreading !== null &&
"implicitCurrentMessage" in replyThreading
? (replyThreading as { implicitCurrentMessage?: unknown }).implicitCurrentMessage
: undefined;
const allowImplicitCurrentMessage = implicitCurrentMessage !== "deny";
const resolvedPayloads = Array.isArray(resolved)
? resolved
: Array.isArray((resolved as { replies?: unknown })?.replies)
? (resolved as { replies: TestReplyPayload[] }).replies
: resolved
? [resolved as TestReplyPayload]
: [];
let queuedFinal = false;
for (const resolvedPayload of resolvedPayloads) {
const shouldResolveCurrentMessage =
resolvedPayload.replyToCurrent === true ||
(resolvedPayload.replyToCurrent !== false && allowImplicitCurrentMessage);
const deliverable =
!resolvedPayload.replyToId && shouldResolveCurrentMessage && contextReplyToId
? { ...resolvedPayload, replyToId: contextReplyToId }
: resolvedPayload;
const text = typeof resolvedPayload.text === "string" ? resolvedPayload.text.trim() : "";
const hasMedia =
typeof resolvedPayload.mediaUrl === "string" ||
(Array.isArray(resolvedPayload.mediaUrls) && resolvedPayload.mediaUrls.length > 0);
if (text || hasMedia) {
queuedFinal = true;
params.dispatcher.sendFinalReply(deliverable);
}
}
params.dispatcher.markComplete?.();
await params.dispatcher.waitForIdle?.();
return { queuedFinal: Boolean(text) };
return { queuedFinal };
},
};
});

View file

@ -3,7 +3,10 @@ import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plu
import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract";
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config-contracts";
import type {
ReplyToMode,
SignalReactionNotificationMode,
} from "openclaw/plugin-sdk/config-contracts";
import {
detectMime,
estimateBase64DecodedBytes,
@ -17,6 +20,7 @@ import {
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import {
chunkTextWithMode,
createReplyReferencePlanner,
resolveChunkMode,
resolveTextChunkLimit,
} from "openclaw/plugin-sdk/reply-runtime";
@ -37,7 +41,7 @@ import {
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { normalizeE164 } from "openclaw/plugin-sdk/text-utility-runtime";
import { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime";
import { resolveSignalAccount } from "./accounts.js";
import { resolveSignalAccount, resolveSignalReplyToMode } from "./accounts.js";
import { isSignalNativeApprovalHandlerConfigured } from "./approval-native.js";
import {
addSignalApprovalReactionHintToStructuredPayload,
@ -49,6 +53,7 @@ import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js";
import { createSignalEventHandler } from "./monitor/event-handler.js";
import type {
SignalAttachment,
SignalNativeReplyContext,
SignalReactionMessage,
SignalReactionTarget,
} from "./monitor/event-handler.types.js";
@ -364,14 +369,32 @@ export async function deliverReplies(params: {
target: string;
baseUrl: string;
account?: string;
accountUuid?: string;
accountId?: string;
runtime: RuntimeEnv;
maxBytes: number;
textLimit: number;
chunkMode: "length" | "newline";
replyContext?: SignalNativeReplyContext;
chatType?: "direct" | "group";
}) {
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } =
params;
const {
replies,
target,
baseUrl,
account,
accountUuid,
accountId,
runtime,
maxBytes,
textLimit,
chunkMode,
} = params;
const replyToMode = resolveSignalReplyToMode({
cfg: params.cfg,
accountId,
chatType: params.chatType,
});
for (const payload of replies) {
const deliveryResults: Array<{
channel: "signal";
@ -385,8 +408,14 @@ export async function deliverReplies(params: {
to: target,
payload,
targetAuthor: account,
targetAuthorUuid: accountUuid,
}) ?? payload;
const reply = resolveSendableOutboundReplyParts(deliveredPayload);
const nextNativeReply = createSignalNativeReplyResolver({
payload: deliveredPayload,
replyContext: params.replyContext,
replyToMode,
});
const recordDeliveryResult = (
result: Awaited<ReturnType<typeof sendMessageSignal>>,
visibleText: string,
@ -415,6 +444,7 @@ export async function deliverReplies(params: {
account,
maxBytes,
accountId,
...nextNativeReply(),
}),
chunk,
);
@ -429,6 +459,7 @@ export async function deliverReplies(params: {
mediaUrl,
maxBytes,
accountId,
...nextNativeReply(),
}),
visibleText,
);
@ -445,12 +476,78 @@ export async function deliverReplies(params: {
payload: deliveredPayload,
results: deliveryResults,
targetAuthor: account,
targetAuthorUuid: accountUuid,
});
runtime.log?.(`delivered reply to ${target}`);
}
}
}
function resolveSignalNativeReplyOptions(params: {
payload: ReplyPayload;
replyContext?: SignalNativeReplyContext;
}): Pick<Parameters<typeof sendMessageSignal>[2], "replyToId" | "replyToAuthor" | "replyToBody"> {
if (params.payload.replyToCurrent === false) {
return {};
}
const payloadReplyToId = normalizeOptionalString(params.payload.replyToId);
const contextReplyToId = normalizeOptionalString(params.replyContext?.replyToId);
if (!payloadReplyToId || !contextReplyToId || payloadReplyToId !== contextReplyToId) {
return {};
}
const replyToAuthor = normalizeOptionalString(params.replyContext?.author);
if (!replyToAuthor) {
return { replyToId: payloadReplyToId };
}
return {
replyToId: payloadReplyToId,
replyToAuthor,
replyToBody: params.replyContext?.body ?? "",
};
}
function isSignalStatusNoticePayload(payload: ReplyPayload): boolean {
return Boolean(payload.isCompactionNotice || payload.isFallbackNotice || payload.isStatusNotice);
}
function createSignalNativeReplyResolver(params: {
payload: ReplyPayload;
replyContext?: SignalNativeReplyContext;
replyToMode: ReplyToMode;
}): () => Pick<
Parameters<typeof sendMessageSignal>[2],
"replyToId" | "replyToAuthor" | "replyToBody"
> {
const nativeReply = resolveSignalNativeReplyOptions(params);
if (!nativeReply.replyToId) {
return () => ({});
}
const isExplicitReply =
params.payload.replyToTag === true || params.payload.replyToCurrent === true;
const isStatusNotice = isSignalStatusNoticePayload(params.payload);
if (isStatusNotice && params.replyToMode === "off") {
return () => ({});
}
if (isExplicitReply) {
return () => nativeReply;
}
if (isStatusNotice) {
return () => nativeReply;
}
const planner = createReplyReferencePlanner({
replyToMode: params.replyToMode,
existingId: nativeReply.replyToId,
hasReplied: params.replyContext?.state?.hasReplied,
});
return () => {
const replyToId = planner.use();
if (params.replyContext?.state && !isStatusNotice) {
params.replyContext.state.hasReplied = planner.hasReplied();
}
return replyToId ? { ...nativeReply, replyToId } : {};
};
}
export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promise<void> {
const runtime = resolveRuntime(opts);
const cfg = opts.config ?? getRuntimeConfig();

View file

@ -124,6 +124,7 @@ function nextTimerTick(): Promise<void> {
describe("signal createSignalEventHandler inbound context", () => {
beforeEach(() => {
vi.useRealTimers();
delete capture.ctx;
sendTypingMock.mockReset().mockResolvedValue(true);
sendReadReceiptMock.mockReset().mockResolvedValue(true);
@ -187,6 +188,172 @@ describe("signal createSignalEventHandler inbound context", () => {
expect(context.OriginatingTo).toBe("+15550002222");
});
it("sets ReplyToId from the inbound Signal timestamp", async () => {
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: { messages: { inbound: { debounceMs: 0 } } } as any,
historyLimit: 0,
}),
);
await handler(
createSignalReceiveEvent({
sourceNumber: "+15550002222",
sourceName: "Bob",
timestamp: 1700000000001,
dataMessage: {
message: "hello",
attachments: [],
},
}),
);
const context = requireCapturedContext();
expect(context.MessageSid).toBe("1700000000001");
expect(context.ReplyToId).toBe("1700000000001");
});
it.each([
{
name: "dataMessage",
envelope: {
dataMessage: {
timestamp: 1700000000002,
message: "hello",
attachments: [],
},
},
},
{
name: "editMessage.dataMessage",
envelope: {
editMessage: {
dataMessage: {
timestamp: 1700000000002,
message: "hello",
attachments: [],
},
},
},
},
])("falls back to $name timestamp for native reply metadata", async ({ envelope }) => {
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: { messages: { inbound: { debounceMs: 0 } } } as any,
historyLimit: 0,
}),
);
await handler(
createSignalReceiveEvent({
sourceNumber: "+15550002222",
sourceName: "Bob",
timestamp: undefined,
...envelope,
}),
);
const context = requireCapturedContext();
expect(context.MessageSid).toBe("1700000000002");
expect(context.ReplyToId).toBe("1700000000002");
expect(context.Timestamp).toBe(1700000000002);
});
it("uses editMessage.targetSentTimestamp as the native reply target", async () => {
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: { messages: { inbound: { debounceMs: 0 } } } as any,
historyLimit: 0,
}),
);
await handler(
createSignalReceiveEvent({
sourceNumber: "+15550002222",
sourceName: "Bob",
timestamp: 1700000000999,
editMessage: {
targetSentTimestamp: 1700000000002,
dataMessage: {
timestamp: 1700000000999,
message: "edited hello",
attachments: [],
},
},
}),
);
const context = requireCapturedContext();
expect(context.MessageSid).toBe("1700000000999");
expect(context.ReplyToId).toBe("1700000000002");
expect(context.Timestamp).toBe(1700000000999);
});
it("preserves the last debounced message body for native reply quote metadata", async () => {
vi.useFakeTimers();
const deliverRepliesMock = vi.fn().mockResolvedValue(undefined);
dispatchInboundMessageMock.mockImplementationOnce(async (params: any) => {
capture.ctx = params.ctx;
await Promise.resolve(params.replyOptions?.onReplyStart?.());
params.dispatcher.sendFinalReply({ text: "debounced reply" });
params.dispatcher.markComplete?.();
await params.dispatcher.waitForIdle?.();
return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } };
});
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: {
messages: { inbound: { debounceMs: 10 } },
channels: { signal: { replyToMode: "batched" } },
} as any,
deliverReplies: deliverRepliesMock,
historyLimit: 0,
}),
);
try {
await handler(
createSignalReceiveEvent({
timestamp: 1700000000001,
dataMessage: {
message: "first debounced message",
attachments: [],
},
}),
);
await handler(
createSignalReceiveEvent({
timestamp: 1700000000002,
dataMessage: {
message: "second debounced message",
attachments: [],
},
}),
);
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(10);
await vi.waitFor(() => {
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
});
const context = requireCapturedContext();
expect(context.BodyForAgent).toBe("first debounced message\\nsecond debounced message");
expect(context.ReplyToId).toBe("1700000000002");
expect(context.ReplyThreading).toEqual({ implicitCurrentMessage: "allow" });
expect(deliverRepliesMock.mock.calls[0]?.[0]).toMatchObject({
replyContext: {
replyToId: "1700000000002",
author: "+15550001111",
body: "second debounced message",
},
});
} finally {
vi.useRealTimers();
}
});
it("keeps per-channel-peer direct-message last-route writes on the isolated session", async () => {
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({

View file

@ -41,6 +41,7 @@ import {
} from "openclaw/plugin-sdk/hook-runtime";
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import { createChannelHistoryWindow } from "openclaw/plugin-sdk/reply-history";
import { resolveBatchedReplyThreadingPolicy } from "openclaw/plugin-sdk/reply-reference";
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
import { settleReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime";
@ -56,6 +57,7 @@ import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/sess
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime";
import { normalizeE164, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { resolveSignalReplyToMode } from "../accounts.js";
import {
maybeResolveSignalApprovalReaction,
resolveSignalApprovalConversationKey,
@ -205,9 +207,12 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
groupName?: string;
isGroup: boolean;
bodyText: string;
nativeReplyBody?: string;
commandBody: string;
timestamp?: number;
messageId?: string;
replyToId?: string;
isBatched?: boolean;
mediaPath?: string;
mediaType?: string;
mediaPaths?: string[];
@ -288,6 +293,14 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
limit: deps.historyLimit,
})
: undefined;
const replyThreading = resolveBatchedReplyThreadingPolicy(
resolveSignalReplyToMode({
cfg: deps.cfg,
accountId: deps.accountId,
chatType: entry.isGroup ? "group" : "direct",
}),
entry.isBatched === true,
);
const media =
entry.mediaPaths && entry.mediaPaths.length > 0
? entry.mediaPaths.map((path, index) => ({
@ -330,6 +343,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
},
reply: {
to: signalTo,
replyToId: entry.replyToId ?? entry.messageId,
},
message: {
body: combinedBody,
@ -354,6 +368,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
media,
extra: {
GroupSubject: entry.isGroup ? (entry.groupName ?? undefined) : undefined,
ReplyThreading: replyThreading,
},
});
@ -481,6 +496,12 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
},
});
const nativeReplyContext = {
replyToId: ctxPayload.ReplyToId,
author: entry.senderRecipient,
body: entry.nativeReplyBody ?? entry.bodyText,
state: { hasReplied: false },
};
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
...replyPipeline,
humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId),
@ -492,10 +513,13 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
target: ctxPayload.To,
baseUrl: deps.baseUrl,
account: deps.account,
accountUuid: deps.accountUuid,
accountId: deps.accountId,
runtime: deps.runtime,
maxBytes: deps.mediaMaxBytes,
textLimit: deps.textLimit,
replyContext: nativeReplyContext,
chatType: entry.isGroup ? "group" : "direct",
});
},
onError: (err, info) => {
@ -674,6 +698,8 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
...last,
bodyText: combinedText,
commandBody: combinedCommandBody,
isBatched: true,
nativeReplyBody: last.nativeReplyBody ?? last.bodyText,
mediaPath: undefined,
mediaType: undefined,
mediaPaths: undefined,
@ -1134,6 +1160,10 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
: typeof dataMessage.timestamp === "number"
? dataMessage.timestamp
: undefined;
const nativeReplyTargetTimestamp =
typeof envelope.editMessage?.targetSentTimestamp === "number"
? envelope.editMessage.targetSentTimestamp
: inboundTimestamp;
if (deps.sendReadReceipts && !deps.readReceiptsViaDaemon && !isGroup && inboundTimestamp) {
try {
await sendReadReceiptSignal(`signal:${senderRecipient}`, inboundTimestamp, {
@ -1156,6 +1186,10 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
const senderName = envelope.sourceName ?? senderDisplay;
const messageId = typeof inboundTimestamp === "number" ? String(inboundTimestamp) : undefined;
const replyToId =
typeof nativeReplyTargetTimestamp === "number"
? String(nativeReplyTargetTimestamp)
: undefined;
await inboundDebouncer.enqueue({
senderName,
senderDisplay,
@ -1168,6 +1202,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
commandBody: messageText,
timestamp: inboundTimestamp,
messageId,
replyToId,
mediaPath,
mediaType,
mediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,

View file

@ -16,7 +16,10 @@ export type SignalEnvelope = {
sourceName?: string | null;
timestamp?: number | null;
dataMessage?: SignalDataMessage | null;
editMessage?: { dataMessage?: SignalDataMessage | null } | null;
editMessage?: {
targetSentTimestamp?: number | null;
dataMessage?: SignalDataMessage | null;
} | null;
syncMessage?: unknown;
reactionMessage?: SignalReactionMessage | null;
};
@ -76,6 +79,15 @@ export type SignalReceivePayload = {
exception?: { message?: string } | null;
};
export type SignalNativeReplyContext = {
replyToId?: string;
author?: string;
body?: string;
state?: {
hasReplied: boolean;
};
};
export type SignalEventHandlerDeps = {
runtime: RuntimeEnv;
cfg: OpenClawConfig;
@ -111,10 +123,13 @@ export type SignalEventHandlerDeps = {
target: string;
baseUrl: string;
account?: string;
accountUuid?: string;
accountId?: string;
runtime: RuntimeEnv;
maxBytes: number;
textLimit: number;
replyContext?: SignalNativeReplyContext;
chatType?: "direct" | "group";
}) => Promise<void>;
resolveSignalReactionTargets: (reaction: SignalReactionMessage) => SignalReactionTarget[];
isSignalReactionMessage: (

View file

@ -21,6 +21,10 @@ vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
};
});
const {
clearSignalApprovalReactionTargetsForTest,
resolveSignalApprovalReactionTargetWithPersistence,
} = await import("./approval-reactions.js");
const { sendMessageSignal } = await import("./send.js");
const SIGNAL_TEST_CFG = {
@ -38,6 +42,7 @@ const SIGNAL_TEST_CFG = {
describe("sendMessageSignal receipts", () => {
beforeEach(() => {
clearSignalApprovalReactionTargetsForTest();
signalRpcRequestMock.mockReset();
resolveOutboundAttachmentFromUrlMock.mockClear();
});
@ -161,6 +166,15 @@ describe("sendMessageSignal receipts", () => {
expect.objectContaining({ message: text }),
expect.any(Object),
);
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: "+15551234567",
messageId: "1234567892",
reactionKey: "👍",
targetAuthor: "+15550001111",
}),
).resolves.toBeNull();
});
it("does not add approval reactions to ordinary outbound text quoting a full prompt", async () => {
@ -197,5 +211,472 @@ describe("sendMessageSignal receipts", () => {
expect.objectContaining({ message: text }),
expect.any(Object),
);
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: "+15551234567",
messageId: "1234567893",
reactionKey: "👍",
targetAuthor: "+15550001111",
}),
).resolves.toBeNull();
});
it("does not register ordinary outbound text that includes approval reaction snippets", async () => {
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567894 });
const text = [
"The docs show this example:",
"Exec approval required",
"ID: exec-live-approval",
"",
"React with:",
"👍 allow once",
"👎 deny",
"",
"Reply with: /approve exec-live-approval allow-once|deny",
].join("\n");
await sendMessageSignal("+15551234567", text, {
cfg: {
...SIGNAL_TEST_CFG,
channels: {
signal: {
...SIGNAL_TEST_CFG.channels.signal,
allowFrom: ["+15551234567"],
},
},
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "signal", to: "+15551234567" }],
},
},
},
});
expect(signalRpcRequestMock).toHaveBeenCalledWith(
"send",
expect.objectContaining({ message: text }),
expect.any(Object),
);
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: "+15551234567",
messageId: "1234567894",
reactionKey: "👍",
targetAuthor: "+15550001111",
}),
).resolves.toBeNull();
});
it("passes native quote metadata when replying to a Signal timestamp", async () => {
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567892 });
const result = await sendMessageSignal("+15551234567", "hello", {
cfg: SIGNAL_TEST_CFG,
replyToId: "1700000000001",
replyToAuthor: "+15550002222",
replyToBody: "original",
});
expect(signalRpcRequestMock).toHaveBeenCalledTimes(1);
expect(signalRpcRequestMock).toHaveBeenCalledWith(
"send",
expect.objectContaining({
quoteTimestamp: 1700000000001,
quoteAuthor: "+15550002222",
quoteMessage: "original",
}),
expect.any(Object),
);
expect(result.receipt.replyToId).toBe("1700000000001");
expect(result.receipt.parts[0]?.replyToId).toBe("1700000000001");
expect(result.receipt.raw?.[0]?.meta).toEqual({
targetType: "recipient",
replyToId: "1700000000001",
nativeReplyStatus: "sent",
});
});
it("does not add approval reaction hints without explicit approvers", async () => {
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567895 });
const text =
"Exec approval required\nID: exec-live-approval\n\nReply with: /approve exec-live-approval allow-once|deny";
await sendMessageSignal("+15551234567", text, {
cfg: {
channels: {
signal: {
accounts: {
default: {
httpUrl: "http://signal.test",
account: "+15550001111",
},
},
},
},
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "signal", to: "+15551234567" }],
},
},
},
});
expect(signalRpcRequestMock.mock.calls[0]?.[1]).toMatchObject({ message: text });
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: "+15551234567",
messageId: "1234567895",
reactionKey: "👍",
targetAuthor: "+15550001111",
}),
).resolves.toBeNull();
});
it("adds reaction approval hints for non-presentation approval payload text", async () => {
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567896 });
const cfg = {
channels: {
signal: {
accounts: {
default: {
httpUrl: "http://signal.test",
account: "+15550001111",
allowFrom: ["+15551234567"],
},
},
},
},
approvals: {
plugin: {
enabled: true,
mode: "targets" as const,
targets: [{ channel: "signal", to: "+15551234567" }],
},
},
};
await sendMessageSignal(
"+15551234567",
"Plugin approval required\nID: plugin:abc\n\nReply with: /approve plugin:abc allow-once|deny",
{ cfg },
);
expect(signalRpcRequestMock.mock.calls[0]?.[1]).toMatchObject({
message: expect.stringContaining("React with:\n\n👍 Allow Once\n👎 Deny"),
});
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: "+15551234567",
messageId: "1234567896",
reactionKey: "👍",
targetAuthor: "+15550001111",
}),
).resolves.toMatchObject({
approvalId: "plugin:abc",
approvalKind: "plugin",
decision: "allow-once",
});
});
it("keeps standalone plugin approval prompts on plugin reaction config without a plugin-prefixed id", async () => {
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567897 });
const cfg = {
channels: {
signal: {
accounts: {
default: {
httpUrl: "http://signal.test",
account: "+15550001111",
allowFrom: ["+15551234567"],
},
},
},
},
approvals: {
plugin: {
enabled: true,
mode: "targets" as const,
targets: [{ channel: "signal", to: "+15551234567" }],
},
},
};
await sendMessageSignal(
"+15551234567",
"Plugin approval required\nID: abc\n\nReply with: /approve abc allow-once|deny",
{ cfg },
);
expect(signalRpcRequestMock.mock.calls[0]?.[1]).toMatchObject({
message: expect.stringContaining("React with:\n\n👍 Allow Once\n👎 Deny"),
});
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: "+15551234567",
messageId: "1234567897",
reactionKey: "👍",
targetAuthor: "+15550001111",
}),
).resolves.toMatchObject({
approvalId: "abc",
approvalKind: "plugin",
decision: "allow-once",
});
});
it.each([
{
name: "exec",
approvalKind: "exec",
text: "🔒 Exec approval required\nID: exec:abc\n\nReply with: /approve exec:abc allow-once|deny",
approvalId: "exec:abc",
},
{
name: "plugin",
approvalKind: "plugin",
text: "🛡️ Plugin approval required\nID: plugin:abc\n\nReply with: /approve plugin:abc allow-once|deny",
approvalId: "plugin:abc",
},
])("adds reaction approval hints for icon-prefixed $name approval text", async (testCase) => {
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567898 });
const cfg = {
channels: {
signal: {
accounts: {
default: {
httpUrl: "http://signal.test",
account: "+15550001111",
allowFrom: ["+15551234567"],
},
},
},
},
approvals: {
[testCase.approvalKind]: {
enabled: true,
mode: "targets" as const,
targets: [{ channel: "signal", to: "+15551234567" }],
},
},
};
await sendMessageSignal("+15551234567", testCase.text, { cfg });
expect(signalRpcRequestMock.mock.calls[0]?.[1]).toMatchObject({
message: expect.stringContaining("React with:\n\n👍 Allow Once\n👎 Deny"),
});
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: "+15551234567",
messageId: "1234567898",
reactionKey: "👍",
targetAuthor: "+15550001111",
}),
).resolves.toMatchObject({
approvalId: testCase.approvalId,
approvalKind: testCase.approvalKind,
decision: "allow-once",
});
});
it("binds approval reactions to the canonical prompt id", async () => {
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567898 });
const cfg = {
channels: {
signal: {
accounts: {
default: {
httpUrl: "http://signal.test",
account: "+15550001111",
allowFrom: ["+15551234567"],
},
},
},
},
approvals: {
exec: {
enabled: true,
mode: "targets" as const,
targets: [{ channel: "signal", to: "+15551234567" }],
},
},
};
const text = [
"Exec approval required",
"ID: exec-real",
"Command: printf '/approve fake allow-once'",
"",
"Reply with: /approve exec-real allow-once|deny",
].join("\n");
await sendMessageSignal("+15551234567", text, { cfg });
expect(signalRpcRequestMock.mock.calls[0]?.[1]).toMatchObject({
message: expect.stringContaining("React with:\n\n👍 Allow Once\n👎 Deny"),
});
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: "+15551234567",
messageId: "1234567898",
reactionKey: "👍",
targetAuthor: "+15550001111",
}),
).resolves.toMatchObject({
approvalId: "exec-real",
approvalKind: "exec",
decision: "allow-once",
});
});
it("adds reaction approval hints for non-presentation approval text with UUID-only accounts", async () => {
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567899 });
const cfg = {
channels: {
signal: {
accounts: {
default: {
httpUrl: "http://signal.test",
accountUuid: "123e4567-e89b-12d3-a456-426614174000",
allowFrom: ["+15551234567"],
},
},
},
},
approvals: {
plugin: {
enabled: true,
mode: "targets" as const,
targets: [{ channel: "signal", to: "+15551234567" }],
},
},
};
await sendMessageSignal(
"+15551234567",
"Plugin approval required\nID: plugin:abc\n\nReply with: /approve plugin:abc allow-once|deny",
{ cfg },
);
expect(signalRpcRequestMock.mock.calls[0]?.[1]).toMatchObject({
message: expect.stringContaining("React with:\n\n👍 Allow Once\n👎 Deny"),
});
await expect(
resolveSignalApprovalReactionTargetWithPersistence({
accountId: "default",
conversationKey: "+15551234567",
messageId: "1234567899",
reactionKey: "👍",
targetAuthorUuid: "123e4567-e89b-12d3-a456-426614174000",
}),
).resolves.toMatchObject({
approvalId: "plugin:abc",
approvalKind: "plugin",
decision: "allow-once",
});
});
it.each([
"Signal RPC -32602: quote rejected",
'Signal RPC -32602: Unrecognized field "quoteTimestamp"',
])("falls back to an ordinary send when native quote metadata fails: %s", async (message) => {
signalRpcRequestMock
.mockRejectedValueOnce(new Error(message))
.mockResolvedValueOnce({ timestamp: 1234567893 });
const result = await sendMessageSignal("+15551234567", "hello", {
cfg: SIGNAL_TEST_CFG,
replyToId: "1700000000001",
replyToAuthor: "+15550002222",
replyToBody: "original",
});
expect(signalRpcRequestMock).toHaveBeenCalledTimes(2);
expect(signalRpcRequestMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
quoteTimestamp: 1700000000001,
quoteAuthor: "+15550002222",
}),
);
expect(signalRpcRequestMock.mock.calls[1]?.[1]).not.toHaveProperty("quoteTimestamp");
expect(signalRpcRequestMock.mock.calls[1]?.[1]).not.toHaveProperty("quoteAuthor");
expect(signalRpcRequestMock.mock.calls[1]?.[1]).not.toHaveProperty("quoteMessage");
expect(result.messageId).toBe("1234567893");
expect(result.receipt.replyToId).toBe("1700000000001");
expect(result.receipt.parts[0]?.replyToId).toBe("1700000000001");
expect(result.receipt.raw?.[0]?.meta).toEqual({
targetType: "recipient",
replyToId: "1700000000001",
nativeReplyStatus: "fallback",
});
});
it("keeps media sends when native quote metadata is rejected", async () => {
signalRpcRequestMock
.mockRejectedValueOnce(new Error("Signal RPC -32602: quote invalid"))
.mockResolvedValueOnce({ timestamp: 1234567894 });
const result = await sendMessageSignal("+15551234567", "caption", {
cfg: SIGNAL_TEST_CFG,
mediaUrl: "/tmp/image.png",
mediaLocalRoots: ["/tmp"],
replyToId: "1700000000001",
replyToAuthor: "+15550002222",
replyToBody: "original",
});
expect(resolveOutboundAttachmentFromUrlMock).toHaveBeenCalled();
expect(signalRpcRequestMock).toHaveBeenCalledTimes(2);
expect(signalRpcRequestMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
attachments: ["/tmp/image.png"],
quoteTimestamp: 1700000000001,
quoteAuthor: "+15550002222",
}),
);
expect(signalRpcRequestMock.mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
attachments: ["/tmp/image.png"],
message: "caption",
}),
);
expect(signalRpcRequestMock.mock.calls[1]?.[1]).not.toHaveProperty("quoteTimestamp");
expect(result.messageId).toBe("1234567894");
expect(result.receipt.parts[0]?.kind).toBe("media");
expect(result.receipt.raw?.[0]?.meta).toEqual({
targetType: "recipient",
replyToId: "1700000000001",
nativeReplyStatus: "fallback",
});
});
it("does not retry ordinary send failures as quote fallback", async () => {
signalRpcRequestMock.mockRejectedValueOnce(new Error("Signal HTTP timed out after 10000ms"));
await expect(
sendMessageSignal("+15551234567", "hello", {
cfg: SIGNAL_TEST_CFG,
replyToId: "1700000000001",
replyToAuthor: "+15550002222",
replyToBody: "original",
}),
).rejects.toThrow("Signal HTTP timed out");
expect(signalRpcRequestMock).toHaveBeenCalledTimes(1);
});
});

View file

@ -10,8 +10,15 @@ import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-run
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runtime";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveSignalAccount } from "./accounts.js";
import {
appendSignalApprovalReactionHintForOutboundMessage,
registerSignalApprovalReactionTargetForOutboundMessage,
} from "./approval-reactions.js";
import { signalRpcRequest } from "./client-adapter.js";
import { markdownToSignalText, type SignalTextStyleRange } from "./format.js";
import { resolveSignalRpcContext } from "./rpc-context.js";
@ -32,6 +39,9 @@ export type SignalSendOpts = {
timeoutMs?: number;
textMode?: "markdown" | "plain";
textStyles?: SignalTextStyleRange[];
replyToId?: string | null;
replyToAuthor?: string | null;
replyToBody?: string | null;
};
export type SignalSendResult = {
@ -135,6 +145,8 @@ function createSignalSendReceipt(params: {
timestamp?: number;
target: SignalTarget;
kind: MessageReceiptPartKind;
replyToId?: string;
nativeReplyStatus?: "sent" | "fallback";
}): MessageReceipt {
const messageId = params.messageId.trim();
const results: MessageReceiptSourceResult[] =
@ -145,6 +157,12 @@ function createSignalSendReceipt(params: {
messageId,
meta: {
targetType: params.target.type,
...(params.replyToId
? {
replyToId: params.replyToId,
nativeReplyStatus: params.nativeReplyStatus ?? "sent",
}
: {}),
},
},
]
@ -164,9 +182,60 @@ function createSignalSendReceipt(params: {
return createMessageReceiptFromOutboundResults({
results,
kind: params.kind,
...(params.replyToId ? { replyToId: params.replyToId } : {}),
});
}
function parseSignalReplyTimestamp(raw: string | null | undefined): number | undefined {
const value = normalizeOptionalString(raw);
if (!value || !/^\d+$/.test(value)) {
return undefined;
}
const timestamp = Number(value);
if (!Number.isSafeInteger(timestamp) || timestamp <= 0) {
return undefined;
}
return timestamp;
}
function resolveSignalQuoteParams(opts: SignalSendOpts):
| {
replyToId: string;
params: Record<string, unknown>;
}
| undefined {
const timestamp = parseSignalReplyTimestamp(opts.replyToId);
const author = normalizeOptionalString(opts.replyToAuthor);
if (timestamp === undefined || !author) {
return undefined;
}
return {
replyToId: String(timestamp),
params: {
quoteTimestamp: timestamp,
quoteAuthor: author,
quoteMessage: opts.replyToBody ?? "",
},
};
}
function isSignalQuoteMetadataRejection(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const normalized = normalizeLowercaseStringOrEmpty(message);
if (!normalized.includes("quote")) {
return false;
}
return (
normalized.includes("reject") ||
normalized.includes("invalid") ||
normalized.includes("unrecognized") ||
normalized.includes("unsupported") ||
normalized.includes("not found") ||
normalized.includes("no such") ||
normalized.includes("unknown")
);
}
export async function sendMessageSignal(
to: string,
text: string,
@ -180,7 +249,17 @@ export async function sendMessageSignal(
});
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
const target = parseTarget(to);
let message = text ?? "";
const targetAuthor = normalizeOptionalString(account);
const targetAuthorUuid = normalizeOptionalString(accountInfo.config.accountUuid);
const outboundText = appendSignalApprovalReactionHintForOutboundMessage({
cfg,
accountId: accountInfo.accountId,
to,
text: text ?? "",
targetAuthor,
targetAuthorUuid,
});
let message = outboundText;
let messageFromPlaceholder = false;
let textStyles: SignalTextStyleRange[] = [];
const textMode = opts.textMode ?? "markdown";
@ -255,13 +334,43 @@ export async function sendMessageSignal(
}
Object.assign(params, targetParams);
const result = await signalRpcRequest<{ timestamp?: number }>("send", params, {
const quote = resolveSignalQuoteParams(opts);
const sendOpts = {
baseUrl,
timeoutMs: opts.timeoutMs,
apiMode,
});
};
let nativeReplyStatus: "sent" | "fallback" | undefined;
let result: { timestamp?: number } | undefined;
if (quote) {
try {
result = await signalRpcRequest<{ timestamp?: number }>(
"send",
{ ...params, ...quote.params },
sendOpts,
);
nativeReplyStatus = "sent";
} catch (error) {
if (!isSignalQuoteMetadataRejection(error)) {
throw error;
}
result = await signalRpcRequest<{ timestamp?: number }>("send", params, sendOpts);
nativeReplyStatus = "fallback";
}
} else {
result = await signalRpcRequest<{ timestamp?: number }>("send", params, sendOpts);
}
const timestamp = result?.timestamp;
const messageId = timestamp ? String(timestamp) : "unknown";
registerSignalApprovalReactionTargetForOutboundMessage({
cfg,
accountId: accountInfo.accountId,
to,
messageId,
text: outboundText,
targetAuthor,
targetAuthorUuid,
});
return {
messageId,
timestamp,
@ -269,6 +378,7 @@ export async function sendMessageSignal(
messageId,
target,
kind: attachments && attachments.length > 0 ? "media" : "text",
...(quote ? { replyToId: quote.replyToId, nativeReplyStatus } : {}),
...(timestamp != null ? { timestamp } : {}),
}),
};

File diff suppressed because one or more lines are too long

View file

@ -133,6 +133,43 @@ describe('account dmPolicy="allowlist" uses inherited allowFrom', () => {
});
});
describe("signal reply-to config", () => {
it("accepts channel and account scoped reply-to modes", () => {
const result = SignalConfigSchema.safeParse({
replyToMode: "first",
replyToModeByChatType: { direct: "all", group: "first" },
accounts: {
work: {
replyToMode: "off",
replyToModeByChatType: { direct: "first", group: "off" },
},
},
});
expect(result.success).toBe(true);
});
it("rejects unreachable Signal channel reply-to overrides", () => {
const result = SignalConfigSchema.safeParse({
replyToModeByChatType: { channel: "off" },
});
expect(result.success).toBe(false);
});
it("rejects unreachable Signal account reply-to overrides", () => {
const result = SignalConfigSchema.safeParse({
accounts: {
work: {
replyToModeByChatType: { channel: "off" },
},
},
});
expect(result.success).toBe(false);
});
});
describe("Discord mentionAliases schema", () => {
it("accepts stable outbound mention aliases on top-level and account config", () => {
expect(

View file

@ -1,4 +1,5 @@
// Defines Signal channel configuration types.
import type { ReplyToMode } from "./types.base.js";
import type { CommonChannelMessagingConfig } from "./types.channel-messaging-common.js";
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
@ -43,6 +44,10 @@ export type SignalAccountConfig = CommonChannelMessagingConfig & {
groups?: Record<string, SignalGroupConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Control native reply quoting when replies target an inbound Signal message. */
replyToMode?: ReplyToMode;
/** Optional per-chat-type native reply quoting overrides. */
replyToModeByChatType?: Partial<Record<"direct" | "group", ReplyToMode>>;
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
reactionNotifications?: SignalReactionNotificationMode;
/** Allowlist for reaction notifications when mode is allowlist. */

View file

@ -942,7 +942,7 @@ export const SlackThreadSchema = z
})
.strict();
const SlackReplyToModeByChatTypeSchema = z
const ReplyToModeByChatTypeSchema = z
.object({
direct: ReplyToModeSchema.optional(),
group: ReplyToModeSchema.optional(),
@ -950,6 +950,13 @@ const SlackReplyToModeByChatTypeSchema = z
})
.strict();
const DirectGroupReplyToModeByChatTypeSchema = z
.object({
direct: ReplyToModeSchema.optional(),
group: ReplyToModeSchema.optional(),
})
.strict();
export const SlackSocketModeSchema = z
.object({
clientPingTimeout: z.number().int().positive().optional(),
@ -1011,7 +1018,7 @@ export const SlackAccountSchema = z
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
replyToMode: ReplyToModeSchema.optional(),
replyToModeByChatType: SlackReplyToModeByChatTypeSchema.optional(),
replyToModeByChatType: ReplyToModeByChatTypeSchema.optional(),
thread: SlackThreadSchema.optional(),
actions: z
.object({
@ -1209,6 +1216,8 @@ export const SignalAccountSchemaBase = z
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaMaxMb: z.number().int().positive().optional(),
replyToMode: ReplyToModeSchema.optional(),
replyToModeByChatType: DirectGroupReplyToModeByChatTypeSchema.optional(),
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
actions: z