Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
640
extensions/signal/src/monitor.ts
Normal file
640
extensions/signal/src/monitor.ts
Normal file
@@ -0,0 +1,640 @@
|
||||
// Signal plugin module implements monitor behavior.
|
||||
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
|
||||
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 {
|
||||
detectMime,
|
||||
estimateBase64DecodedBytes,
|
||||
saveMediaBuffer,
|
||||
} from "openclaw/plugin-sdk/media-runtime";
|
||||
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history";
|
||||
import {
|
||||
deliverTextOrMediaReply,
|
||||
resolveSendableOutboundReplyParts,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import {
|
||||
chunkTextWithMode,
|
||||
resolveChunkMode,
|
||||
resolveTextChunkLimit,
|
||||
} from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import {
|
||||
createNonExitingRuntime,
|
||||
type BackoffPolicy,
|
||||
type RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeStringEntries,
|
||||
} 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 { isSignalNativeApprovalHandlerConfigured } from "./approval-native.js";
|
||||
import {
|
||||
addSignalApprovalReactionHintToStructuredPayload,
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload,
|
||||
} from "./approval-reactions.js";
|
||||
import { signalRpcRequest, signalCheck } from "./client-adapter.js";
|
||||
import { formatSignalDaemonExit, spawnSignalDaemon, type SignalDaemonHandle } from "./daemon.js";
|
||||
import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js";
|
||||
import { createSignalEventHandler } from "./monitor/event-handler.js";
|
||||
import type {
|
||||
SignalAttachment,
|
||||
SignalReactionMessage,
|
||||
SignalReactionTarget,
|
||||
} from "./monitor/event-handler.types.js";
|
||||
import { sendMessageSignal } from "./send.js";
|
||||
import { runSignalSseLoop } from "./sse-reconnect.js";
|
||||
|
||||
export type MonitorSignalOpts = {
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
config?: OpenClawConfig;
|
||||
baseUrl?: string;
|
||||
channelRuntime?: ChannelRuntimeSurface;
|
||||
autoStart?: boolean;
|
||||
startupTimeoutMs?: number;
|
||||
cliPath?: string;
|
||||
configPath?: string;
|
||||
httpHost?: string;
|
||||
httpPort?: number;
|
||||
receiveMode?: "on-start" | "manual";
|
||||
ignoreAttachments?: boolean;
|
||||
ignoreStories?: boolean;
|
||||
sendReadReceipts?: boolean;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
mediaMaxMb?: number;
|
||||
reconnectPolicy?: Partial<BackoffPolicy>;
|
||||
waitForTransportReady?: typeof waitForTransportReady;
|
||||
};
|
||||
|
||||
function resolveRuntime(opts: MonitorSignalOpts): RuntimeEnv {
|
||||
return opts.runtime ?? createNonExitingRuntime();
|
||||
}
|
||||
|
||||
function createSignalMonitorTaskRunner(runtime: RuntimeEnv) {
|
||||
const inFlight = new Set<Promise<void>>();
|
||||
return {
|
||||
runEventTask(task: () => Promise<void>): void {
|
||||
const trackedTask = Promise.resolve()
|
||||
.then(task)
|
||||
.catch((err: unknown) => runtime.error?.(`event handler failed: ${String(err)}`))
|
||||
.finally(() => inFlight.delete(trackedTask));
|
||||
inFlight.add(trackedTask);
|
||||
},
|
||||
async waitForIdle(): Promise<void> {
|
||||
while (inFlight.size > 0) {
|
||||
await Promise.allSettled(inFlight);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergeAbortSignals(
|
||||
a?: AbortSignal,
|
||||
b?: AbortSignal,
|
||||
): { signal?: AbortSignal; dispose: () => void } {
|
||||
if (!a && !b) {
|
||||
return { signal: undefined, dispose: () => {} };
|
||||
}
|
||||
if (!a) {
|
||||
return { signal: b, dispose: () => {} };
|
||||
}
|
||||
if (!b) {
|
||||
return { signal: a, dispose: () => {} };
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const abortFrom = (source: AbortSignal) => {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort(source.reason);
|
||||
}
|
||||
};
|
||||
if (a.aborted) {
|
||||
abortFrom(a);
|
||||
return { signal: controller.signal, dispose: () => {} };
|
||||
}
|
||||
if (b.aborted) {
|
||||
abortFrom(b);
|
||||
return { signal: controller.signal, dispose: () => {} };
|
||||
}
|
||||
const onAbortA = () => abortFrom(a);
|
||||
const onAbortB = () => abortFrom(b);
|
||||
a.addEventListener("abort", onAbortA, { once: true });
|
||||
b.addEventListener("abort", onAbortB, { once: true });
|
||||
return {
|
||||
signal: controller.signal,
|
||||
dispose: () => {
|
||||
a.removeEventListener("abort", onAbortA);
|
||||
b.removeEventListener("abort", onAbortB);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSignalDaemonLifecycle(params: { abortSignal?: AbortSignal }) {
|
||||
let daemonHandle: SignalDaemonHandle | null = null;
|
||||
let daemonStopRequested = false;
|
||||
let daemonExitError: Error | undefined;
|
||||
const daemonAbortController = new AbortController();
|
||||
const mergedAbort = mergeAbortSignals(params.abortSignal, daemonAbortController.signal);
|
||||
const stop = () => {
|
||||
daemonStopRequested = true;
|
||||
daemonHandle?.stop();
|
||||
};
|
||||
const attach = (handle: SignalDaemonHandle) => {
|
||||
daemonHandle = handle;
|
||||
void handle.exited.then((exit) => {
|
||||
if (daemonStopRequested || params.abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
daemonExitError = new Error(formatSignalDaemonExit(exit));
|
||||
if (!daemonAbortController.signal.aborted) {
|
||||
daemonAbortController.abort(daemonExitError);
|
||||
}
|
||||
});
|
||||
};
|
||||
const getExitError = () => daemonExitError;
|
||||
return {
|
||||
attach,
|
||||
stop,
|
||||
getExitError,
|
||||
abortSignal: mergedAbort.signal,
|
||||
dispose: mergedAbort.dispose,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAllowList(raw?: Array<string | number>): string[] {
|
||||
return normalizeStringEntries(raw);
|
||||
}
|
||||
|
||||
function resolveSignalReactionTargets(reaction: SignalReactionMessage): SignalReactionTarget[] {
|
||||
const targets: SignalReactionTarget[] = [];
|
||||
const uuid = reaction.targetAuthorUuid?.trim();
|
||||
if (uuid) {
|
||||
targets.push({ kind: "uuid", id: uuid, display: `uuid:${uuid}` });
|
||||
}
|
||||
const author = reaction.targetAuthor?.trim();
|
||||
if (author) {
|
||||
const normalized = normalizeE164(author);
|
||||
targets.push({ kind: "phone", id: normalized, display: normalized });
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
function isSignalReactionMessage(
|
||||
reaction: SignalReactionMessage | null | undefined,
|
||||
): reaction is SignalReactionMessage {
|
||||
if (!reaction) {
|
||||
return false;
|
||||
}
|
||||
const emoji = reaction.emoji?.trim();
|
||||
const timestamp = reaction.targetSentTimestamp;
|
||||
const hasTarget = Boolean(
|
||||
normalizeOptionalString(reaction.targetAuthor) ||
|
||||
normalizeOptionalString(reaction.targetAuthorUuid),
|
||||
);
|
||||
return Boolean(emoji && typeof timestamp === "number" && timestamp > 0 && hasTarget);
|
||||
}
|
||||
|
||||
function shouldEmitSignalReactionNotification(params: {
|
||||
mode?: SignalReactionNotificationMode;
|
||||
account?: string | null;
|
||||
targets?: SignalReactionTarget[];
|
||||
sender?: ReturnType<typeof resolveSignalSender> | null;
|
||||
allowlist?: string[];
|
||||
}) {
|
||||
const { mode, account, targets, sender, allowlist } = params;
|
||||
const effectiveMode = mode ?? "own";
|
||||
if (effectiveMode === "off") {
|
||||
return false;
|
||||
}
|
||||
if (effectiveMode === "own") {
|
||||
const accountId = account?.trim();
|
||||
if (!accountId || !targets || targets.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const normalizedAccount = normalizeE164(accountId);
|
||||
return targets.some((target) => {
|
||||
if (target.kind === "uuid") {
|
||||
return accountId === target.id || accountId === `uuid:${target.id}`;
|
||||
}
|
||||
return normalizedAccount === target.id;
|
||||
});
|
||||
}
|
||||
if (effectiveMode === "allowlist") {
|
||||
if (!sender || !allowlist || allowlist.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return isSignalSenderAllowed(sender, allowlist);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildSignalReactionSystemEventText(params: {
|
||||
emojiLabel: string;
|
||||
actorLabel: string;
|
||||
messageId: string;
|
||||
targetLabel?: string;
|
||||
groupLabel?: string;
|
||||
}) {
|
||||
const base = `Signal reaction added: ${params.emojiLabel} by ${params.actorLabel} msg ${params.messageId}`;
|
||||
const withTarget = params.targetLabel ? `${base} from ${params.targetLabel}` : base;
|
||||
return params.groupLabel ? `${withTarget} in ${params.groupLabel}` : withTarget;
|
||||
}
|
||||
|
||||
async function waitForSignalDaemonReady(params: {
|
||||
baseUrl: string;
|
||||
abortSignal?: AbortSignal;
|
||||
timeoutMs: number;
|
||||
logAfterMs: number;
|
||||
logIntervalMs?: number;
|
||||
runtime: RuntimeEnv;
|
||||
waitForTransportReadyFn?: typeof waitForTransportReady;
|
||||
}): Promise<void> {
|
||||
const waitForTransportReadyFn = params.waitForTransportReadyFn ?? waitForTransportReady;
|
||||
await waitForTransportReadyFn({
|
||||
label: "signal daemon",
|
||||
timeoutMs: params.timeoutMs,
|
||||
logAfterMs: params.logAfterMs,
|
||||
logIntervalMs: params.logIntervalMs,
|
||||
pollIntervalMs: 150,
|
||||
abortSignal: params.abortSignal,
|
||||
runtime: params.runtime,
|
||||
check: async () => {
|
||||
const res = await signalCheck(params.baseUrl, 1000);
|
||||
if (res.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: res.error ?? (res.status ? `HTTP ${res.status}` : "unreachable"),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const SIGNAL_ATTACHMENT_RPC_RESPONSE_HEADROOM_BYTES = 64 * 1024;
|
||||
const SIGNAL_BASE64_OVERHEAD_NUMERATOR = 4;
|
||||
const SIGNAL_BASE64_OVERHEAD_DENOMINATOR = 3;
|
||||
|
||||
function deriveSignalAttachmentRpcMaxResponseBytes(maxBytes: number): number | undefined {
|
||||
if (!Number.isFinite(maxBytes) || maxBytes <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const base64Bytes = Math.ceil(
|
||||
(maxBytes * SIGNAL_BASE64_OVERHEAD_NUMERATOR) / SIGNAL_BASE64_OVERHEAD_DENOMINATOR,
|
||||
);
|
||||
return base64Bytes + SIGNAL_ATTACHMENT_RPC_RESPONSE_HEADROOM_BYTES;
|
||||
}
|
||||
|
||||
async function fetchAttachment(params: {
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
apiMode?: "native" | "container" | "auto";
|
||||
attachment: SignalAttachment;
|
||||
sender?: string;
|
||||
groupId?: string;
|
||||
maxBytes: number;
|
||||
}): Promise<{ path: string; contentType?: string } | null> {
|
||||
const { attachment } = params;
|
||||
if (!attachment?.id) {
|
||||
return null;
|
||||
}
|
||||
if (typeof attachment.size === "number" && attachment.size > params.maxBytes) {
|
||||
throw new Error(
|
||||
`Signal attachment ${attachment.id} exceeds ${(params.maxBytes / (1024 * 1024)).toFixed(0)}MB limit`,
|
||||
);
|
||||
}
|
||||
const rpcParams: Record<string, unknown> = {
|
||||
id: attachment.id,
|
||||
};
|
||||
if (params.account) {
|
||||
rpcParams.account = params.account;
|
||||
}
|
||||
if (params.groupId) {
|
||||
rpcParams.groupId = params.groupId;
|
||||
} else if (params.sender) {
|
||||
rpcParams.recipient = params.sender;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await signalRpcRequest<{ data?: string }>("getAttachment", rpcParams, {
|
||||
baseUrl: params.baseUrl,
|
||||
maxResponseBytes: deriveSignalAttachmentRpcMaxResponseBytes(params.maxBytes),
|
||||
apiMode: params.apiMode,
|
||||
});
|
||||
if (!result?.data) {
|
||||
return null;
|
||||
}
|
||||
if (estimateBase64DecodedBytes(result.data) > params.maxBytes) {
|
||||
throw new Error(
|
||||
`Signal attachment ${attachment.id} exceeds ${(params.maxBytes / (1024 * 1024)).toFixed(0)}MB limit`,
|
||||
);
|
||||
}
|
||||
const buffer = Buffer.from(result.data, "base64");
|
||||
const originalFilename = normalizeOptionalString(attachment.filename ?? undefined);
|
||||
const contentType =
|
||||
normalizeOptionalString(attachment.contentType ?? undefined) ??
|
||||
(await detectMime({ buffer, filePath: originalFilename }));
|
||||
const saved = await saveMediaBuffer(
|
||||
buffer,
|
||||
contentType,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
originalFilename,
|
||||
);
|
||||
return { path: saved.path, contentType: saved.contentType };
|
||||
}
|
||||
|
||||
export async function deliverReplies(params: {
|
||||
cfg: OpenClawConfig;
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
runtime: RuntimeEnv;
|
||||
maxBytes: number;
|
||||
textLimit: number;
|
||||
chunkMode: "length" | "newline";
|
||||
}) {
|
||||
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } =
|
||||
params;
|
||||
for (const payload of replies) {
|
||||
const deliveryResults: Array<{
|
||||
channel: "signal";
|
||||
messageId: string;
|
||||
meta: { signalVisibleText: string };
|
||||
}> = [];
|
||||
const deliveredPayload =
|
||||
addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
to: target,
|
||||
payload,
|
||||
targetAuthor: account,
|
||||
}) ?? payload;
|
||||
const reply = resolveSendableOutboundReplyParts(deliveredPayload);
|
||||
const recordDeliveryResult = (
|
||||
result: Awaited<ReturnType<typeof sendMessageSignal>>,
|
||||
visibleText: string,
|
||||
) => {
|
||||
const messageId =
|
||||
typeof result?.messageId === "string" && result.messageId.trim()
|
||||
? result.messageId.trim()
|
||||
: null;
|
||||
if (messageId) {
|
||||
deliveryResults.push({
|
||||
channel: "signal",
|
||||
messageId,
|
||||
meta: { signalVisibleText: visibleText },
|
||||
});
|
||||
}
|
||||
};
|
||||
const delivered = await deliverTextOrMediaReply({
|
||||
payload: deliveredPayload,
|
||||
text: reply.text,
|
||||
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
|
||||
sendText: async (chunk) => {
|
||||
recordDeliveryResult(
|
||||
await sendMessageSignal(target, chunk, {
|
||||
cfg: params.cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
maxBytes,
|
||||
accountId,
|
||||
}),
|
||||
chunk,
|
||||
);
|
||||
},
|
||||
sendMedia: async ({ mediaUrl, caption }) => {
|
||||
const visibleText = caption ?? "";
|
||||
recordDeliveryResult(
|
||||
await sendMessageSignal(target, visibleText, {
|
||||
cfg: params.cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
accountId,
|
||||
}),
|
||||
visibleText,
|
||||
);
|
||||
},
|
||||
});
|
||||
if (delivered !== "empty") {
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg: params.cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: target,
|
||||
accountId,
|
||||
},
|
||||
payload: deliveredPayload,
|
||||
results: deliveryResults,
|
||||
targetAuthor: account,
|
||||
});
|
||||
runtime.log?.(`delivered reply to ${target}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promise<void> {
|
||||
const runtime = resolveRuntime(opts);
|
||||
const cfg = opts.config ?? getRuntimeConfig();
|
||||
const accountInfo = resolveSignalAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const historyLimit = Math.max(
|
||||
0,
|
||||
accountInfo.config.historyLimit ??
|
||||
cfg.messages?.groupChat?.historyLimit ??
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
);
|
||||
const groupHistories = new Map<string, HistoryEntry[]>();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "signal", accountInfo.accountId);
|
||||
const chunkMode = resolveChunkMode(cfg, "signal", accountInfo.accountId);
|
||||
const baseUrl = normalizeOptionalString(opts.baseUrl) ?? accountInfo.baseUrl;
|
||||
const account =
|
||||
normalizeOptionalString(opts.account) ?? normalizeOptionalString(accountInfo.config.account);
|
||||
const dmPolicy = accountInfo.config.dmPolicy ?? "pairing";
|
||||
const allowFrom = normalizeAllowList(opts.allowFrom ?? accountInfo.config.allowFrom);
|
||||
const groupAllowFrom = normalizeAllowList(
|
||||
opts.groupAllowFrom ??
|
||||
accountInfo.config.groupAllowFrom ??
|
||||
(accountInfo.config.allowFrom && accountInfo.config.allowFrom.length > 0
|
||||
? accountInfo.config.allowFrom
|
||||
: []),
|
||||
);
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
||||
const { groupPolicy, providerMissingFallbackApplied } =
|
||||
resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: cfg.channels?.signal !== undefined,
|
||||
groupPolicy: accountInfo.config.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce({
|
||||
providerMissingFallbackApplied,
|
||||
providerKey: "signal",
|
||||
accountId: accountInfo.accountId,
|
||||
log: (message) => runtime.log?.(message),
|
||||
});
|
||||
const reactionMode = accountInfo.config.reactionNotifications ?? "own";
|
||||
const reactionAllowlist = normalizeAllowList(accountInfo.config.reactionAllowlist);
|
||||
const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||
const ignoreAttachments = opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false;
|
||||
const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts);
|
||||
const waitForTransportReadyFn = opts.waitForTransportReady ?? waitForTransportReady;
|
||||
|
||||
const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl;
|
||||
const configuredApiMode = cfg.channels?.signal?.apiMode ?? "auto";
|
||||
const startupTimeoutMs = Math.min(
|
||||
120_000,
|
||||
Math.max(1_000, opts.startupTimeoutMs ?? accountInfo.config.startupTimeoutMs ?? 30_000),
|
||||
);
|
||||
const readReceiptsViaDaemon = autoStart && sendReadReceipts;
|
||||
const daemonLifecycle = createSignalDaemonLifecycle({ abortSignal: opts.abortSignal });
|
||||
const monitorTaskRunner = createSignalMonitorTaskRunner(runtime);
|
||||
let daemonHandle: SignalDaemonHandle | null = null;
|
||||
|
||||
if (autoStart && configuredApiMode === "container") {
|
||||
throw new Error(
|
||||
"channels.signal.autoStart=true is incompatible with channels.signal.apiMode=container",
|
||||
);
|
||||
}
|
||||
|
||||
if (autoStart) {
|
||||
const cliPath = opts.cliPath ?? accountInfo.config.cliPath ?? "signal-cli";
|
||||
const configPath =
|
||||
normalizeOptionalString(opts.configPath) ??
|
||||
normalizeOptionalString(accountInfo.config.configPath);
|
||||
const httpHost = opts.httpHost ?? accountInfo.config.httpHost ?? "127.0.0.1";
|
||||
const httpPort = opts.httpPort ?? accountInfo.config.httpPort ?? 8080;
|
||||
daemonHandle = spawnSignalDaemon({
|
||||
cliPath,
|
||||
...(configPath ? { configPath } : {}),
|
||||
account,
|
||||
httpHost,
|
||||
httpPort,
|
||||
receiveMode: opts.receiveMode ?? accountInfo.config.receiveMode,
|
||||
ignoreAttachments: opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments,
|
||||
ignoreStories: opts.ignoreStories ?? accountInfo.config.ignoreStories,
|
||||
sendReadReceipts,
|
||||
runtime,
|
||||
});
|
||||
daemonLifecycle.attach(daemonHandle);
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
daemonLifecycle.stop();
|
||||
};
|
||||
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
if (daemonHandle) {
|
||||
await waitForSignalDaemonReady({
|
||||
baseUrl,
|
||||
abortSignal: daemonLifecycle.abortSignal,
|
||||
timeoutMs: startupTimeoutMs,
|
||||
logAfterMs: 10_000,
|
||||
logIntervalMs: 10_000,
|
||||
runtime,
|
||||
waitForTransportReadyFn,
|
||||
});
|
||||
const daemonExitError = daemonLifecycle.getExitError();
|
||||
if (daemonExitError) {
|
||||
throw daemonExitError;
|
||||
}
|
||||
}
|
||||
|
||||
registerChannelRuntimeContext({
|
||||
channelRuntime: opts.channelRuntime,
|
||||
channelId: "signal",
|
||||
accountId: accountInfo.accountId,
|
||||
capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
|
||||
context: isSignalNativeApprovalHandlerConfigured({
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
})
|
||||
? {
|
||||
accountId: accountInfo.accountId,
|
||||
baseUrl,
|
||||
account,
|
||||
accountUuid: accountInfo.config.accountUuid,
|
||||
}
|
||||
: null,
|
||||
abortSignal: opts.abortSignal,
|
||||
});
|
||||
|
||||
const handleEvent = createSignalEventHandler({
|
||||
runtime,
|
||||
cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
accountUuid: accountInfo.config.accountUuid,
|
||||
accountId: accountInfo.accountId,
|
||||
blockStreaming: accountInfo.config.blockStreaming,
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
textLimit,
|
||||
dmPolicy,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
groupPolicy,
|
||||
reactionMode,
|
||||
reactionAllowlist,
|
||||
mediaMaxBytes,
|
||||
ignoreAttachments,
|
||||
sendReadReceipts,
|
||||
readReceiptsViaDaemon,
|
||||
fetchAttachment: (params) => fetchAttachment({ ...params, apiMode: configuredApiMode }),
|
||||
deliverReplies: (params) => deliverReplies({ ...params, cfg, chunkMode }),
|
||||
resolveSignalReactionTargets,
|
||||
isSignalReactionMessage,
|
||||
shouldEmitSignalReactionNotification,
|
||||
buildSignalReactionSystemEventText,
|
||||
});
|
||||
|
||||
await runSignalSseLoop({
|
||||
baseUrl,
|
||||
account,
|
||||
abortSignal: daemonLifecycle.abortSignal,
|
||||
runtime,
|
||||
// signal-cli can keep the SSE event endpoint idle until the next inbound event.
|
||||
timeoutMs: 0,
|
||||
apiMode: configuredApiMode,
|
||||
policy: opts.reconnectPolicy,
|
||||
onEvent: (event) => {
|
||||
monitorTaskRunner.runEventTask(() => handleEvent(event));
|
||||
},
|
||||
});
|
||||
const daemonExitError = daemonLifecycle.getExitError();
|
||||
if (daemonExitError) {
|
||||
throw daemonExitError;
|
||||
}
|
||||
} catch (err) {
|
||||
const daemonExitError = daemonLifecycle.getExitError();
|
||||
if (opts.abortSignal?.aborted && !daemonExitError) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
await monitorTaskRunner.waitForIdle();
|
||||
daemonLifecycle.dispose();
|
||||
opts.abortSignal?.removeEventListener("abort", onAbort);
|
||||
daemonLifecycle.stop();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user