mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
de8db74058
commit
4112e3a0f3
20 changed files with 2262 additions and 38 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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: [] }),
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue