523 lines
16 KiB
TypeScript
523 lines
16 KiB
TypeScript
import {
|
|
buildAccountScopedAllowlistConfigEditor,
|
|
buildAccountScopedDmSecurityPolicy,
|
|
createScopedAccountConfigAccessors,
|
|
collectAllowlistProviderRestrictSendersWarnings,
|
|
} from "openclaw/plugin-sdk/compat";
|
|
import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk/core";
|
|
import {
|
|
buildBaseAccountStatusSnapshot,
|
|
buildBaseChannelStatusSummary,
|
|
buildChannelConfigSchema,
|
|
collectStatusIssuesFromLastError,
|
|
createDefaultChannelRuntimeState,
|
|
DEFAULT_ACCOUNT_ID,
|
|
deleteAccountFromConfigSection,
|
|
getChatChannelMeta,
|
|
looksLikeSignalTargetId,
|
|
normalizeE164,
|
|
normalizeSignalMessagingTarget,
|
|
PAIRING_APPROVED_MESSAGE,
|
|
resolveChannelMediaMaxBytes,
|
|
setAccountEnabledInConfigSection,
|
|
SignalConfigSchema,
|
|
type ChannelMessageActionAdapter,
|
|
type ChannelPlugin,
|
|
} from "openclaw/plugin-sdk/signal";
|
|
import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js";
|
|
import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js";
|
|
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
|
|
import {
|
|
listSignalAccountIds,
|
|
resolveDefaultSignalAccountId,
|
|
resolveSignalAccount,
|
|
type ResolvedSignalAccount,
|
|
} from "./accounts.js";
|
|
import { markdownToSignalTextChunks } from "./format.js";
|
|
import {
|
|
looksLikeUuid,
|
|
resolveSignalPeerId,
|
|
resolveSignalRecipient,
|
|
resolveSignalSender,
|
|
} from "./identity.js";
|
|
import type { SignalProbe } from "./probe.js";
|
|
import { getSignalRuntime } from "./runtime.js";
|
|
import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js";
|
|
|
|
async function loadSignalChannelRuntime() {
|
|
return await import("./channel.runtime.js");
|
|
}
|
|
|
|
const signalSetupWizard = createSignalSetupWizardProxy(async () => ({
|
|
signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard,
|
|
}));
|
|
|
|
const signalMessageActions: ChannelMessageActionAdapter = {
|
|
listActions: (ctx) => getSignalRuntime().channel.signal.messageActions?.listActions?.(ctx) ?? [],
|
|
supportsAction: (ctx) =>
|
|
getSignalRuntime().channel.signal.messageActions?.supportsAction?.(ctx) ?? false,
|
|
handleAction: async (ctx) => {
|
|
const ma = getSignalRuntime().channel.signal.messageActions;
|
|
if (!ma?.handleAction) {
|
|
throw new Error("Signal message actions not available");
|
|
}
|
|
return ma.handleAction(ctx);
|
|
},
|
|
};
|
|
|
|
const signalConfigAccessors = createScopedAccountConfigAccessors({
|
|
resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }),
|
|
resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom,
|
|
formatAllowFrom: (allowFrom) =>
|
|
allowFrom
|
|
.map((entry) => String(entry).trim())
|
|
.filter(Boolean)
|
|
.map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, ""))))
|
|
.filter(Boolean),
|
|
resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo,
|
|
});
|
|
|
|
type SignalSendFn = ReturnType<typeof getSignalRuntime>["channel"]["signal"]["sendMessageSignal"];
|
|
|
|
function resolveSignalSendContext(params: {
|
|
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
|
|
accountId?: string;
|
|
deps?: { [channelId: string]: unknown };
|
|
}) {
|
|
const send =
|
|
resolveOutboundSendDep<SignalSendFn>(params.deps, "signal") ??
|
|
getSignalRuntime().channel.signal.sendMessageSignal;
|
|
const maxBytes = resolveChannelMediaMaxBytes({
|
|
cfg: params.cfg,
|
|
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
|
cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.channels?.signal?.mediaMaxMb,
|
|
accountId: params.accountId,
|
|
});
|
|
return { send, maxBytes };
|
|
}
|
|
|
|
async function sendSignalOutbound(params: {
|
|
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
|
|
to: string;
|
|
text: string;
|
|
mediaUrl?: string;
|
|
mediaLocalRoots?: readonly string[];
|
|
accountId?: string;
|
|
deps?: { [channelId: string]: unknown };
|
|
}) {
|
|
const { send, maxBytes } = resolveSignalSendContext(params);
|
|
return await send(params.to, params.text, {
|
|
cfg: params.cfg,
|
|
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
|
|
...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}),
|
|
maxBytes,
|
|
accountId: params.accountId ?? undefined,
|
|
});
|
|
}
|
|
|
|
function inferSignalTargetChatType(rawTo: string) {
|
|
let to = rawTo.trim();
|
|
if (!to) {
|
|
return undefined;
|
|
}
|
|
if (/^signal:/i.test(to)) {
|
|
to = to.replace(/^signal:/i, "").trim();
|
|
}
|
|
if (!to) {
|
|
return undefined;
|
|
}
|
|
const lower = to.toLowerCase();
|
|
if (lower.startsWith("group:")) {
|
|
return "group" as const;
|
|
}
|
|
if (lower.startsWith("username:") || lower.startsWith("u:")) {
|
|
return "direct" as const;
|
|
}
|
|
return "direct" as const;
|
|
}
|
|
|
|
function parseSignalExplicitTarget(raw: string) {
|
|
const normalized = normalizeSignalMessagingTarget(raw);
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
return {
|
|
to: normalized,
|
|
chatType: inferSignalTargetChatType(normalized),
|
|
};
|
|
}
|
|
|
|
function buildSignalBaseSessionKey(params: {
|
|
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
|
|
agentId: string;
|
|
accountId?: string | null;
|
|
peer: RoutePeer;
|
|
}) {
|
|
return buildAgentSessionKey({
|
|
agentId: params.agentId,
|
|
channel: "signal",
|
|
accountId: params.accountId,
|
|
peer: params.peer,
|
|
dmScope: params.cfg.session?.dmScope ?? "main",
|
|
identityLinks: params.cfg.session?.identityLinks,
|
|
});
|
|
}
|
|
|
|
function resolveSignalOutboundSessionRoute(params: {
|
|
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
|
|
agentId: string;
|
|
accountId?: string | null;
|
|
target: string;
|
|
}) {
|
|
const stripped = params.target.replace(/^signal:/i, "").trim();
|
|
const lowered = stripped.toLowerCase();
|
|
if (lowered.startsWith("group:")) {
|
|
const groupId = stripped.slice("group:".length).trim();
|
|
if (!groupId) {
|
|
return null;
|
|
}
|
|
const peer: RoutePeer = { kind: "group", id: groupId };
|
|
const baseSessionKey = buildSignalBaseSessionKey({
|
|
cfg: params.cfg,
|
|
agentId: params.agentId,
|
|
accountId: params.accountId,
|
|
peer,
|
|
});
|
|
return {
|
|
sessionKey: baseSessionKey,
|
|
baseSessionKey,
|
|
peer,
|
|
chatType: "group" as const,
|
|
from: `group:${groupId}`,
|
|
to: `group:${groupId}`,
|
|
};
|
|
}
|
|
|
|
let recipient = stripped.trim();
|
|
if (lowered.startsWith("username:")) {
|
|
recipient = stripped.slice("username:".length).trim();
|
|
} else if (lowered.startsWith("u:")) {
|
|
recipient = stripped.slice("u:".length).trim();
|
|
}
|
|
if (!recipient) {
|
|
return null;
|
|
}
|
|
|
|
const uuidCandidate = recipient.toLowerCase().startsWith("uuid:")
|
|
? recipient.slice("uuid:".length)
|
|
: recipient;
|
|
const sender = resolveSignalSender({
|
|
sourceUuid: looksLikeUuid(uuidCandidate) ? uuidCandidate : null,
|
|
sourceNumber: looksLikeUuid(uuidCandidate) ? null : recipient,
|
|
});
|
|
const peerId = sender ? resolveSignalPeerId(sender) : recipient;
|
|
const displayRecipient = sender ? resolveSignalRecipient(sender) : recipient;
|
|
const peer: RoutePeer = { kind: "direct", id: peerId };
|
|
const baseSessionKey = buildSignalBaseSessionKey({
|
|
cfg: params.cfg,
|
|
agentId: params.agentId,
|
|
accountId: params.accountId,
|
|
peer,
|
|
});
|
|
return {
|
|
sessionKey: baseSessionKey,
|
|
baseSessionKey,
|
|
peer,
|
|
chatType: "direct" as const,
|
|
from: `signal:${displayRecipient}`,
|
|
to: `signal:${displayRecipient}`,
|
|
};
|
|
}
|
|
|
|
async function sendFormattedSignalText(ctx: {
|
|
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
|
|
to: string;
|
|
text: string;
|
|
accountId?: string | null;
|
|
deps?: { [channelId: string]: unknown };
|
|
abortSignal?: AbortSignal;
|
|
}) {
|
|
const { send, maxBytes } = resolveSignalSendContext({
|
|
cfg: ctx.cfg,
|
|
accountId: ctx.accountId ?? undefined,
|
|
deps: ctx.deps,
|
|
});
|
|
const limit = resolveTextChunkLimit(ctx.cfg, "signal", ctx.accountId ?? undefined, {
|
|
fallbackLimit: 4000,
|
|
});
|
|
const tableMode = resolveMarkdownTableMode({
|
|
cfg: ctx.cfg,
|
|
channel: "signal",
|
|
accountId: ctx.accountId ?? undefined,
|
|
});
|
|
let chunks =
|
|
limit === undefined
|
|
? markdownToSignalTextChunks(ctx.text, Number.POSITIVE_INFINITY, { tableMode })
|
|
: markdownToSignalTextChunks(ctx.text, limit, { tableMode });
|
|
if (chunks.length === 0 && ctx.text) {
|
|
chunks = [{ text: ctx.text, styles: [] }];
|
|
}
|
|
const results = [];
|
|
for (const chunk of chunks) {
|
|
ctx.abortSignal?.throwIfAborted();
|
|
const result = await send(ctx.to, chunk.text, {
|
|
cfg: ctx.cfg,
|
|
maxBytes,
|
|
accountId: ctx.accountId ?? undefined,
|
|
textMode: "plain",
|
|
textStyles: chunk.styles,
|
|
});
|
|
results.push({ channel: "signal" as const, ...result });
|
|
}
|
|
return results;
|
|
}
|
|
|
|
async function sendFormattedSignalMedia(ctx: {
|
|
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
|
|
to: string;
|
|
text: string;
|
|
mediaUrl: string;
|
|
mediaLocalRoots?: readonly string[];
|
|
accountId?: string | null;
|
|
deps?: { [channelId: string]: unknown };
|
|
abortSignal?: AbortSignal;
|
|
}) {
|
|
ctx.abortSignal?.throwIfAborted();
|
|
const { send, maxBytes } = resolveSignalSendContext({
|
|
cfg: ctx.cfg,
|
|
accountId: ctx.accountId ?? undefined,
|
|
deps: ctx.deps,
|
|
});
|
|
const tableMode = resolveMarkdownTableMode({
|
|
cfg: ctx.cfg,
|
|
channel: "signal",
|
|
accountId: ctx.accountId ?? undefined,
|
|
});
|
|
const formatted = markdownToSignalTextChunks(ctx.text, Number.POSITIVE_INFINITY, {
|
|
tableMode,
|
|
})[0] ?? {
|
|
text: ctx.text,
|
|
styles: [],
|
|
};
|
|
const result = await send(ctx.to, formatted.text, {
|
|
cfg: ctx.cfg,
|
|
mediaUrl: ctx.mediaUrl,
|
|
mediaLocalRoots: ctx.mediaLocalRoots,
|
|
maxBytes,
|
|
accountId: ctx.accountId ?? undefined,
|
|
textMode: "plain",
|
|
textStyles: formatted.styles,
|
|
});
|
|
return { channel: "signal" as const, ...result };
|
|
}
|
|
|
|
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
|
id: "signal",
|
|
meta: {
|
|
...getChatChannelMeta("signal"),
|
|
},
|
|
setupWizard: signalSetupWizard,
|
|
pairing: {
|
|
idLabel: "signalNumber",
|
|
normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""),
|
|
notifyApproval: async ({ id }) => {
|
|
await getSignalRuntime().channel.signal.sendMessageSignal(id, PAIRING_APPROVED_MESSAGE);
|
|
},
|
|
},
|
|
capabilities: {
|
|
chatTypes: ["direct", "group"],
|
|
media: true,
|
|
reactions: true,
|
|
},
|
|
actions: signalMessageActions,
|
|
streaming: {
|
|
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
|
},
|
|
reload: { configPrefixes: ["channels.signal"] },
|
|
configSchema: buildChannelConfigSchema(SignalConfigSchema),
|
|
config: {
|
|
listAccountIds: (cfg) => listSignalAccountIds(cfg),
|
|
resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }),
|
|
defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg),
|
|
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
setAccountEnabledInConfigSection({
|
|
cfg,
|
|
sectionKey: "signal",
|
|
accountId,
|
|
enabled,
|
|
allowTopLevel: true,
|
|
}),
|
|
deleteAccount: ({ cfg, accountId }) =>
|
|
deleteAccountFromConfigSection({
|
|
cfg,
|
|
sectionKey: "signal",
|
|
accountId,
|
|
clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"],
|
|
}),
|
|
isConfigured: (account) => account.configured,
|
|
describeAccount: (account) => ({
|
|
accountId: account.accountId,
|
|
name: account.name,
|
|
enabled: account.enabled,
|
|
configured: account.configured,
|
|
baseUrl: account.baseUrl,
|
|
}),
|
|
...signalConfigAccessors,
|
|
},
|
|
allowlist: {
|
|
supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all",
|
|
readConfig: ({ cfg, accountId }) => {
|
|
const account = resolveSignalAccount({ cfg, accountId });
|
|
return {
|
|
dmAllowFrom: (account.config.allowFrom ?? []).map(String),
|
|
groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String),
|
|
dmPolicy: account.config.dmPolicy,
|
|
groupPolicy: account.config.groupPolicy,
|
|
};
|
|
},
|
|
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
|
|
channelId: "signal",
|
|
normalize: ({ cfg, accountId, values }) =>
|
|
signalConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
|
|
resolvePaths: (scope) => ({
|
|
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
|
|
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
|
|
}),
|
|
}),
|
|
},
|
|
security: {
|
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
return buildAccountScopedDmSecurityPolicy({
|
|
cfg,
|
|
channelKey: "signal",
|
|
accountId,
|
|
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
policy: account.config.dmPolicy,
|
|
allowFrom: account.config.allowFrom ?? [],
|
|
policyPathSuffix: "dmPolicy",
|
|
normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()),
|
|
});
|
|
},
|
|
collectWarnings: ({ account, cfg }) => {
|
|
return collectAllowlistProviderRestrictSendersWarnings({
|
|
cfg,
|
|
providerConfigPresent: cfg.channels?.signal !== undefined,
|
|
configuredGroupPolicy: account.config.groupPolicy,
|
|
surface: "Signal groups",
|
|
openScope: "any member",
|
|
groupPolicyPath: "channels.signal.groupPolicy",
|
|
groupAllowFromPath: "channels.signal.groupAllowFrom",
|
|
mentionGated: false,
|
|
});
|
|
},
|
|
},
|
|
messaging: {
|
|
normalizeTarget: normalizeSignalMessagingTarget,
|
|
parseExplicitTarget: ({ raw }) => parseSignalExplicitTarget(raw),
|
|
inferTargetChatType: ({ to }) => inferSignalTargetChatType(to),
|
|
resolveOutboundSessionRoute: (params) => resolveSignalOutboundSessionRoute(params),
|
|
targetResolver: {
|
|
looksLikeId: looksLikeSignalTargetId,
|
|
hint: "<E.164|uuid:ID|group:ID|signal:group:ID|signal:+E.164>",
|
|
},
|
|
},
|
|
setup: signalSetupAdapter,
|
|
outbound: {
|
|
deliveryMode: "direct",
|
|
chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit),
|
|
chunkerMode: "text",
|
|
textChunkLimit: 4000,
|
|
sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) =>
|
|
await sendFormattedSignalText({
|
|
cfg,
|
|
to,
|
|
text,
|
|
accountId,
|
|
deps,
|
|
abortSignal,
|
|
}),
|
|
sendFormattedMedia: async ({
|
|
cfg,
|
|
to,
|
|
text,
|
|
mediaUrl,
|
|
mediaLocalRoots,
|
|
accountId,
|
|
deps,
|
|
abortSignal,
|
|
}) =>
|
|
await sendFormattedSignalMedia({
|
|
cfg,
|
|
to,
|
|
text,
|
|
mediaUrl,
|
|
mediaLocalRoots,
|
|
accountId,
|
|
deps,
|
|
abortSignal,
|
|
}),
|
|
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
|
const result = await sendSignalOutbound({
|
|
cfg,
|
|
to,
|
|
text,
|
|
accountId: accountId ?? undefined,
|
|
deps,
|
|
});
|
|
return { channel: "signal", ...result };
|
|
},
|
|
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => {
|
|
const result = await sendSignalOutbound({
|
|
cfg,
|
|
to,
|
|
text,
|
|
mediaUrl,
|
|
mediaLocalRoots,
|
|
accountId: accountId ?? undefined,
|
|
deps,
|
|
});
|
|
return { channel: "signal", ...result };
|
|
},
|
|
},
|
|
status: {
|
|
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
|
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("signal", accounts),
|
|
buildChannelSummary: ({ snapshot }) => ({
|
|
...buildBaseChannelStatusSummary(snapshot),
|
|
baseUrl: snapshot.baseUrl ?? null,
|
|
probe: snapshot.probe,
|
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
}),
|
|
probeAccount: async ({ account, timeoutMs }) => {
|
|
const baseUrl = account.baseUrl;
|
|
return await getSignalRuntime().channel.signal.probeSignal(baseUrl, timeoutMs);
|
|
},
|
|
formatCapabilitiesProbe: ({ probe }) =>
|
|
(probe as SignalProbe | undefined)?.version
|
|
? [{ text: `Signal daemon: ${(probe as SignalProbe).version}` }]
|
|
: [],
|
|
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
|
...buildBaseAccountStatusSnapshot({ account, runtime, probe }),
|
|
baseUrl: account.baseUrl,
|
|
}),
|
|
},
|
|
gateway: {
|
|
startAccount: async (ctx) => {
|
|
const account = ctx.account;
|
|
ctx.setStatus({
|
|
accountId: account.accountId,
|
|
baseUrl: account.baseUrl,
|
|
});
|
|
ctx.log?.info(`[${account.accountId}] starting provider (${account.baseUrl})`);
|
|
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
|
return getSignalRuntime().channel.signal.monitorSignalProvider({
|
|
accountId: account.accountId,
|
|
config: ctx.cfg,
|
|
runtime: ctx.runtime,
|
|
abortSignal: ctx.abortSignal,
|
|
mediaMaxMb: account.config.mediaMaxMb,
|
|
});
|
|
},
|
|
},
|
|
};
|