diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index 22c5d3e42e8..e774ef6c85e 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -2,13 +2,13 @@ import { BLUEBUBBLES_ACTION_NAMES, BLUEBUBBLES_ACTIONS, createActionGate, + extractToolSend, jsonResult, readNumberParam, readReactionParams, readStringParam, type ChannelMessageActionAdapter, type ChannelMessageActionName, - type ChannelToolSend, } from "openclaw/plugin-sdk"; import { resolveBlueBubblesAccount } from "./accounts.js"; import { sendBlueBubblesAttachment } from "./attachments.js"; @@ -112,18 +112,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { return Array.from(actions); }, supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), - extractToolSend: ({ args }): ChannelToolSend | null => { - const action = typeof args.action === "string" ? args.action.trim() : ""; - if (action !== "sendMessage") { - return null; - } - const to = typeof args.to === "string" ? args.to : undefined; - if (!to) { - return null; - } - const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; - return { to, accountId }; - }, + extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"), handleAction: async ({ action, params, cfg, accountId, toolContext }) => { const account = resolveBlueBubblesAccount({ cfg: cfg, diff --git a/extensions/bluebubbles/src/monitor-shared.ts b/extensions/bluebubbles/src/monitor-shared.ts index 88e84039417..c768385e03a 100644 --- a/extensions/bluebubbles/src/monitor-shared.ts +++ b/extensions/bluebubbles/src/monitor-shared.ts @@ -1,8 +1,10 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { normalizeWebhookPath, type OpenClawConfig } from "openclaw/plugin-sdk"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; import { getBlueBubblesRuntime } from "./runtime.js"; import type { BlueBubblesAccountConfig } from "./types.js"; +export { normalizeWebhookPath }; + export type BlueBubblesRuntimeEnv = { log?: (message: string) => void; error?: (message: string) => void; @@ -30,18 +32,6 @@ export type WebhookTarget = { export const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"; -export function normalizeWebhookPath(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - return "/"; - } - const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; - if (withSlash.length > 1 && withSlash.endsWith("/")) { - return withSlash.slice(0, -1); - } - return withSlash; -} - export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string { const raw = config?.webhookPath?.trim(); if (raw) { diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 7890659fef1..f3a32e4542f 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -1,7 +1,12 @@ -import { spawn } from "node:child_process"; import os from "node:os"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { approveDevicePairing, listDevicePairing } from "openclaw/plugin-sdk"; +import { + approveDevicePairing, + listDevicePairing, + resolveGatewayBindUrl, + runPluginCommandWithTimeout, + resolveTailnetHostWithRunner, +} from "openclaw/plugin-sdk"; import qrcode from "qrcode-terminal"; function renderQrAscii(data: string): Promise { @@ -37,77 +42,6 @@ type ResolveAuthResult = { error?: string; }; -type CommandResult = { - code: number; - stdout: string; - stderr: string; -}; - -async function runFixedCommandWithTimeout( - argv: string[], - timeoutMs: number, -): Promise { - return await new Promise((resolve) => { - const [command, ...args] = argv; - if (!command) { - resolve({ code: 1, stdout: "", stderr: "command is required" }); - return; - } - const proc = spawn(command, args, { - stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env }, - }); - - let stdout = ""; - let stderr = ""; - let settled = false; - let timer: NodeJS.Timeout | null = null; - - const finalize = (result: CommandResult) => { - if (settled) { - return; - } - settled = true; - if (timer) { - clearTimeout(timer); - } - resolve(result); - }; - - proc.stdout?.on("data", (chunk: Buffer | string) => { - stdout += chunk.toString(); - }); - proc.stderr?.on("data", (chunk: Buffer | string) => { - stderr += chunk.toString(); - }); - - timer = setTimeout(() => { - proc.kill("SIGKILL"); - finalize({ - code: 124, - stdout, - stderr: stderr || `command timed out after ${timeoutMs}ms`, - }); - }, timeoutMs); - - proc.on("error", (err) => { - finalize({ - code: 1, - stdout, - stderr: err.message, - }); - }); - - proc.on("close", (code) => { - finalize({ - code: code ?? 1, - stdout, - stderr, - }); - }); - }); -} - function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null { const candidate = raw.trim(); if (!candidate) { @@ -239,48 +173,12 @@ function pickTailnetIPv4(): string | null { } async function resolveTailnetHost(): Promise { - const candidates = ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]; - for (const candidate of candidates) { - try { - const result = await runFixedCommandWithTimeout([candidate, "status", "--json"], 5000); - if (result.code !== 0) { - continue; - } - const raw = result.stdout.trim(); - if (!raw) { - continue; - } - const parsed = parsePossiblyNoisyJsonObject(raw); - const self = - typeof parsed.Self === "object" && parsed.Self !== null - ? (parsed.Self as Record) - : undefined; - const dns = typeof self?.DNSName === "string" ? self.DNSName : undefined; - if (dns && dns.length > 0) { - return dns.replace(/\.$/, ""); - } - const ips = Array.isArray(self?.TailscaleIPs) ? (self?.TailscaleIPs as string[]) : []; - if (ips.length > 0) { - return ips[0] ?? null; - } - } catch { - continue; - } - } - return null; -} - -function parsePossiblyNoisyJsonObject(raw: string): Record { - const start = raw.indexOf("{"); - const end = raw.lastIndexOf("}"); - if (start === -1 || end <= start) { - return {}; - } - try { - return JSON.parse(raw.slice(start, end + 1)) as Record; - } catch { - return {}; - } + return await resolveTailnetHostWithRunner((argv, opts) => + runPluginCommandWithTimeout({ + argv, + timeoutMs: opts.timeoutMs, + }), + ); } function resolveAuth(cfg: OpenClawPluginApi["config"]): ResolveAuthResult { @@ -365,29 +263,16 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise = { lastError: null, }, collectStatusIssues: collectDiscordStatusIssues, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => + buildTokenChannelStatusSummary(snapshot, { includeMode: false }), probeAccount: async ({ account, timeoutMs }) => getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, { includeApplication: true, diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 59121e7ff58..6993baa0ba7 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -1,13 +1,15 @@ import { + buildBaseAccountStatusSnapshot, + buildBaseChannelStatusSummary, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, formatPairingApproveHint, getChatChannelMeta, PAIRING_APPROVED_MESSAGE, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, setAccountEnabledInConfigSection, - deleteAccountFromConfigSection, type ChannelPlugin, } from "openclaw/plugin-sdk"; import { @@ -319,37 +321,23 @@ export const ircPlugin: ChannelPlugin = { lastError: null, }, buildChannelSummary: ({ account, snapshot }) => ({ - configured: snapshot.configured ?? false, + ...buildBaseChannelStatusSummary(snapshot), host: account.host, port: snapshot.port, tls: account.tls, nick: account.nick, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, probe: snapshot.probe, lastProbeAt: snapshot.lastProbeAt ?? null, }), probeAccount: async ({ cfg, account, timeoutMs }) => probeIrc(cfg as CoreConfig, { accountId: account.accountId, timeoutMs }), buildAccountSnapshot: ({ account, runtime, probe }) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, + ...buildBaseAccountStatusSnapshot({ account, runtime, probe }), host: account.host, port: account.port, tls: account.tls, nick: account.nick, passwordSource: account.passwordSource, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, - probe, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, }), }, gateway: { diff --git a/extensions/irc/src/config-schema.ts b/extensions/irc/src/config-schema.ts index 14ce51b39a4..34eb85b021d 100644 --- a/extensions/irc/src/config-schema.ts +++ b/extensions/irc/src/config-schema.ts @@ -4,6 +4,7 @@ import { DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema, + ReplyRuntimeConfigSchemaShape, ToolPolicySchema, requireOpenAllowFrom, } from "openclaw/plugin-sdk"; @@ -62,15 +63,7 @@ export const IrcAccountSchemaBase = z channels: z.array(z.string()).optional(), mentionPatterns: z.array(z.string()).optional(), markdown: MarkdownConfigSchema, - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - dms: z.record(z.string(), DmConfigSchema.optional()).optional(), - textChunkLimit: z.number().int().positive().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - blockStreaming: z.boolean().optional(), - blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), - responsePrefix: z.string().optional(), - mediaMaxMb: z.number().positive().optional(), + ...ReplyRuntimeConfigSchemaShape, }) .strict(); diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index dd466f09507..7c82573b3bd 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -1,11 +1,15 @@ import { GROUP_POLICY_BLOCKED_LABEL, + createNormalizedOutboundDeliverer, createReplyPrefixOptions, + formatTextWithAttachmentLinks, logInboundDrop, resolveControlCommandGate, + resolveOutboundMediaUrls, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, + type OutboundReplyPayload, type OpenClawConfig, type RuntimeEnv, } from "openclaw/plugin-sdk"; @@ -27,32 +31,20 @@ const CHANNEL_ID = "irc" as const; const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); async function deliverIrcReply(params: { - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; + payload: OutboundReplyPayload; target: string; accountId: string; sendReply?: (target: string, text: string, replyToId?: string) => Promise; statusSink?: (patch: { lastOutboundAt?: number }) => void; }) { - const text = params.payload.text ?? ""; - const mediaList = params.payload.mediaUrls?.length - ? params.payload.mediaUrls - : params.payload.mediaUrl - ? [params.payload.mediaUrl] - : []; - - if (!text.trim() && mediaList.length === 0) { + const combined = formatTextWithAttachmentLinks( + params.payload.text, + resolveOutboundMediaUrls(params.payload), + ); + if (!combined) { return; } - const mediaBlock = mediaList.length - ? mediaList.map((url) => `Attachment: ${url}`).join("\n") - : ""; - const combined = text.trim() - ? mediaBlock - ? `${text.trim()}\n\n${mediaBlock}` - : text.trim() - : mediaBlock; - if (params.sendReply) { await params.sendReply(params.target, combined, params.payload.replyToId); } else { @@ -317,26 +309,22 @@ export async function handleIrcInbound(params: { channel: CHANNEL_ID, accountId: account.accountId, }); + const deliverReply = createNormalizedOutboundDeliverer(async (payload) => { + await deliverIrcReply({ + payload, + target: peerId, + accountId: account.accountId, + sendReply: params.sendReply, + statusSink, + }); + }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config as OpenClawConfig, dispatcherOptions: { ...prefixOptions, - deliver: async (payload) => { - await deliverIrcReply({ - payload: payload as { - text?: string; - mediaUrls?: string[]; - mediaUrl?: string; - replyToId?: string; - }, - target: peerId, - accountId: account.accountId, - sendReply: params.sendReply, - statusSink, - }); - }, + deliver: deliverReply, onError: (err, info) => { runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`); }, diff --git a/extensions/irc/src/monitor.ts b/extensions/irc/src/monitor.ts index d4dbec89db8..4e07fa28abd 100644 --- a/extensions/irc/src/monitor.ts +++ b/extensions/irc/src/monitor.ts @@ -1,4 +1,4 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import { createLoggerBackedRuntime, type RuntimeEnv } from "openclaw/plugin-sdk"; import { resolveIrcAccount } from "./accounts.js"; import { connectIrcClient, type IrcClient } from "./client.js"; import { buildIrcConnectOptions } from "./connect-options.js"; @@ -39,13 +39,12 @@ export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ sto accountId: opts.accountId, }); - const runtime: RuntimeEnv = opts.runtime ?? { - log: (...args: unknown[]) => core.logging.getChildLogger().info(args.map(String).join(" ")), - error: (...args: unknown[]) => core.logging.getChildLogger().error(args.map(String).join(" ")), - exit: () => { - throw new Error("Runtime exit not available"); - }, - }; + const runtime: RuntimeEnv = + opts.runtime ?? + createLoggerBackedRuntime({ + logger: core.logging.getChildLogger(), + exitError: () => new Error("Runtime exit not available"), + }); if (!account.configured) { throw new Error( diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index ac49940d256..a260d96c961 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -1,5 +1,6 @@ import { buildChannelConfigSchema, + buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, LineConfigSchema, processLineMessage, @@ -595,17 +596,7 @@ export const linePlugin: ChannelPlugin = { } return issues; }, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - mode: snapshot.mode ?? null, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => getLineRuntime().channel.line.probeLineBot(account.channelAccessToken, timeoutMs), buildAccountSnapshot: ({ account, runtime, probe }) => { diff --git a/extensions/matrix/src/matrix/deps.ts b/extensions/matrix/src/matrix/deps.ts index 16de3bfd3e3..6941af8af68 100644 --- a/extensions/matrix/src/matrix/deps.ts +++ b/extensions/matrix/src/matrix/deps.ts @@ -1,9 +1,8 @@ -import { spawn } from "node:child_process"; import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import { runPluginCommandWithTimeout, type RuntimeEnv } from "openclaw/plugin-sdk"; const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk"; @@ -22,85 +21,6 @@ function resolvePluginRoot(): string { return path.resolve(currentDir, "..", ".."); } -type CommandResult = { - code: number; - stdout: string; - stderr: string; -}; - -async function runFixedCommandWithTimeout(params: { - argv: string[]; - cwd: string; - timeoutMs: number; - env?: NodeJS.ProcessEnv; -}): Promise { - return await new Promise((resolve) => { - const [command, ...args] = params.argv; - if (!command) { - resolve({ - code: 1, - stdout: "", - stderr: "command is required", - }); - return; - } - - const proc = spawn(command, args, { - cwd: params.cwd, - env: { ...process.env, ...params.env }, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - let settled = false; - let timer: NodeJS.Timeout | null = null; - - const finalize = (result: CommandResult) => { - if (settled) { - return; - } - settled = true; - if (timer) { - clearTimeout(timer); - } - resolve(result); - }; - - proc.stdout?.on("data", (chunk: Buffer | string) => { - stdout += chunk.toString(); - }); - proc.stderr?.on("data", (chunk: Buffer | string) => { - stderr += chunk.toString(); - }); - - timer = setTimeout(() => { - proc.kill("SIGKILL"); - finalize({ - code: 124, - stdout, - stderr: stderr || `command timed out after ${params.timeoutMs}ms`, - }); - }, params.timeoutMs); - - proc.on("error", (err) => { - finalize({ - code: 1, - stdout, - stderr: err.message, - }); - }); - - proc.on("close", (code) => { - finalize({ - code: code ?? 1, - stdout, - stderr, - }); - }); - }); -} - export async function ensureMatrixSdkInstalled(params: { runtime: RuntimeEnv; confirm?: (message: string) => Promise; @@ -121,7 +41,7 @@ export async function ensureMatrixSdkInstalled(params: { ? ["pnpm", "install"] : ["npm", "install", "--omit=dev", "--silent"]; params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`); - const result = await runFixedCommandWithTimeout({ + const result = await runPluginCommandWithTimeout({ argv: command, cwd: root, timeoutMs: 300_000, diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 0544dba9ab2..936eabdd346 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -1,5 +1,5 @@ -import { format } from "node:util"; import { + createLoggerBackedRuntime, GROUP_POLICY_BLOCKED_LABEL, mergeAllowlist, resolveAllowlistProviderRuntimeGroupPolicy, @@ -48,18 +48,11 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi } const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" }); - const formatRuntimeMessage = (...args: Parameters) => format(...args); - const runtime: RuntimeEnv = opts.runtime ?? { - log: (...args) => { - logger.info(formatRuntimeMessage(...args)); - }, - error: (...args) => { - logger.error(formatRuntimeMessage(...args)); - }, - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }; + const runtime: RuntimeEnv = + opts.runtime ?? + createLoggerBackedRuntime({ + logger, + }); const logVerboseMessage = (message: string) => { if (!core.logging.shouldLogVerbose()) { return; diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts index c423513a6a2..d645d563d38 100644 --- a/extensions/mattermost/src/mattermost/monitor-helpers.ts +++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts @@ -1,4 +1,8 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { + formatInboundFromLabel as formatInboundFromLabelShared, + resolveThreadSessionKeys as resolveThreadSessionKeysShared, + type OpenClawConfig, +} from "openclaw/plugin-sdk"; export { createDedupeCache, rawDataToString } from "openclaw/plugin-sdk"; export type ResponsePrefixContext = { @@ -15,27 +19,7 @@ export function extractShortModelName(fullModel: string): string { return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, ""); } -export function formatInboundFromLabel(params: { - isGroup: boolean; - groupLabel?: string; - groupId?: string; - directLabel: string; - directId?: string; - groupFallback?: string; -}): string { - if (params.isGroup) { - const label = params.groupLabel?.trim() || params.groupFallback || "Group"; - const id = params.groupId?.trim(); - return id ? `${label} id:${id}` : label; - } - - const directLabel = params.directLabel.trim(); - const directId = params.directId?.trim(); - if (!directId || directId === directLabel) { - return directLabel; - } - return `${directLabel} id:${directId}`; -} +export const formatInboundFromLabel = formatInboundFromLabelShared; function normalizeAgentId(value: string | undefined | null): string { const trimmed = (value ?? "").trim(); @@ -81,13 +65,8 @@ export function resolveThreadSessionKeys(params: { parentSessionKey?: string; useSuffix?: boolean; }): { sessionKey: string; parentSessionKey?: string } { - const threadId = (params.threadId ?? "").trim(); - if (!threadId) { - return { sessionKey: params.baseSessionKey, parentSessionKey: undefined }; - } - const useSuffix = params.useSuffix ?? true; - const sessionKey = useSuffix - ? `${params.baseSessionKey}:thread:${threadId}` - : params.baseSessionKey; - return { sessionKey, parentSessionKey: params.parentSessionKey }; + return resolveThreadSessionKeysShared({ + ...params, + normalizeThreadId: (threadId) => threadId, + }); } diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index caa50557f51..b67289aea9d 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -9,9 +9,13 @@ import { } from "./attachments.js"; import { setMSTeamsRuntime } from "./runtime.js"; -vi.mock("openclaw/plugin-sdk", () => ({ - isPrivateIpAddress: () => false, -})); +vi.mock("openclaw/plugin-sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isPrivateIpAddress: () => false, + }; +}); /** Mock DNS resolver that always returns a public IP (for anti-SSRF validation in tests). */ const publicResolveFn = async () => ({ address: "13.107.136.10" }); diff --git a/extensions/msteams/src/attachments/payload.ts b/extensions/msteams/src/attachments/payload.ts index 3887f9ee927..2049609d894 100644 --- a/extensions/msteams/src/attachments/payload.ts +++ b/extensions/msteams/src/attachments/payload.ts @@ -1,3 +1,5 @@ +import { buildMediaPayload } from "openclaw/plugin-sdk"; + export function buildMSTeamsMediaPayload( mediaList: Array<{ path: string; contentType?: string }>, ): { @@ -8,15 +10,5 @@ export function buildMSTeamsMediaPayload( MediaUrls?: string[]; MediaTypes?: string[]; } { - const first = mediaList[0]; - const mediaPaths = mediaList.map((media) => media.path); - const mediaTypes = mediaList.map((media) => media.contentType ?? ""); - return { - MediaPath: first?.path, - MediaType: first?.contentType, - MediaUrl: first?.path, - MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined, - MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined, - MediaTypes: mediaPaths.length > 0 ? mediaTypes : undefined, - }; + return buildMediaPayload(mediaList, { preserveMediaTypeCardinality: true }); } diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts index 73369b1eb2e..b52522983c2 100644 --- a/extensions/nextcloud-talk/src/config-schema.ts +++ b/extensions/nextcloud-talk/src/config-schema.ts @@ -4,6 +4,7 @@ import { DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema, + ReplyRuntimeConfigSchemaShape, ToolPolicySchema, requireOpenAllowFrom, } from "openclaw/plugin-sdk"; @@ -40,15 +41,7 @@ export const NextcloudTalkAccountSchemaBase = z groupAllowFrom: z.array(z.string()).optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), rooms: z.record(z.string(), NextcloudTalkRoomSchema.optional()).optional(), - historyLimit: z.number().int().min(0).optional(), - dmHistoryLimit: z.number().int().min(0).optional(), - dms: z.record(z.string(), DmConfigSchema.optional()).optional(), - textChunkLimit: z.number().int().positive().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - blockStreaming: z.boolean().optional(), - blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), - responsePrefix: z.string().optional(), - mediaMaxMb: z.number().positive().optional(), + ...ReplyRuntimeConfigSchemaShape, }) .strict(); diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 5ad02979b60..dcef6aa9382 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -1,11 +1,15 @@ import { GROUP_POLICY_BLOCKED_LABEL, + createNormalizedOutboundDeliverer, createReplyPrefixOptions, + formatTextWithAttachmentLinks, logInboundDrop, resolveControlCommandGate, + resolveOutboundMediaUrls, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, + type OutboundReplyPayload, type OpenClawConfig, type RuntimeEnv, } from "openclaw/plugin-sdk"; @@ -26,32 +30,17 @@ import type { CoreConfig, GroupPolicy, NextcloudTalkInboundMessage } from "./typ const CHANNEL_ID = "nextcloud-talk" as const; async function deliverNextcloudTalkReply(params: { - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; + payload: OutboundReplyPayload; roomToken: string; accountId: string; statusSink?: (patch: { lastOutboundAt?: number }) => void; }): Promise { const { payload, roomToken, accountId, statusSink } = params; - const text = payload.text ?? ""; - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; - - if (!text.trim() && mediaList.length === 0) { + const combined = formatTextWithAttachmentLinks(payload.text, resolveOutboundMediaUrls(payload)); + if (!combined) { return; } - const mediaBlock = mediaList.length - ? mediaList.map((url) => `Attachment: ${url}`).join("\n") - : ""; - const combined = text.trim() - ? mediaBlock - ? `${text.trim()}\n\n${mediaBlock}` - : text.trim() - : mediaBlock; - await sendMessageNextcloudTalk(roomToken, combined, { accountId, replyTo: payload.replyToId, @@ -318,25 +307,21 @@ export async function handleNextcloudTalkInbound(params: { channel: CHANNEL_ID, accountId: account.accountId, }); + const deliverReply = createNormalizedOutboundDeliverer(async (payload) => { + await deliverNextcloudTalkReply({ + payload, + roomToken, + accountId: account.accountId, + statusSink, + }); + }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config as OpenClawConfig, dispatcherOptions: { ...prefixOptions, - deliver: async (payload) => { - await deliverNextcloudTalkReply({ - payload: payload as { - text?: string; - mediaUrls?: string[]; - mediaUrl?: string; - replyToId?: string; - }, - roomToken, - accountId: account.accountId, - statusSink, - }); - }, + deliver: deliverReply, onError: (err, info) => { runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`); }, diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index ca9214fa600..b7daac4d07c 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -1,5 +1,6 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { + createLoggerBackedRuntime, type RuntimeEnv, isRequestBodyLimitError, readRequestBodyWithLimit, @@ -212,13 +213,12 @@ export async function monitorNextcloudTalkProvider( cfg, accountId: opts.accountId, }); - const runtime: RuntimeEnv = opts.runtime ?? { - log: (...args: unknown[]) => core.logging.getChildLogger().info(args.map(String).join(" ")), - error: (...args: unknown[]) => core.logging.getChildLogger().error(args.map(String).join(" ")), - exit: () => { - throw new Error("Runtime exit not available"); - }, - }; + const runtime: RuntimeEnv = + opts.runtime ?? + createLoggerBackedRuntime({ + logger: core.logging.getChildLogger(), + exitError: () => new Error("Runtime exit not available"), + }); if (!account.secret) { throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`); diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 2feb30dfe95..9f3a96b6c41 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,5 +1,6 @@ import { applyAccountNameToChannelSection, + buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, buildChannelConfigSchema, collectStatusIssuesFromLastError, @@ -273,18 +274,8 @@ export const signalPlugin: ChannelPlugin = { return await getSignalRuntime().channel.signal.probeSignal(baseUrl, timeoutMs); }, buildAccountSnapshot: ({ account, runtime, probe }) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, + ...buildBaseAccountStatusSnapshot({ account, runtime, probe }), baseUrl: account.baseUrl, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, - probe, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, }), }, gateway: { diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index c562d12470d..0028e993fc0 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,6 +1,7 @@ import { applyAccountNameToChannelSection, buildChannelConfigSchema, + buildTokenChannelStatusSummary, collectTelegramStatusIssues, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, @@ -374,17 +375,7 @@ export const telegramPlugin: ChannelPlugin ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - mode: snapshot.mode ?? null, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => getTelegramRuntime().channel.telegram.probeTelegram( account.token, diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index bbcfa3fedc7..7d2e8dbd31f 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -1,6 +1,5 @@ -import { format } from "node:util"; import type { RuntimeEnv, ReplyPayload, OpenClawConfig } from "openclaw/plugin-sdk"; -import { createReplyPrefixOptions } from "openclaw/plugin-sdk"; +import { createLoggerBackedRuntime, createReplyPrefixOptions } from "openclaw/plugin-sdk"; import { getTlonRuntime } from "../runtime.js"; import { normalizeShip, parseChannelNest } from "../targets.js"; import { resolveTlonAccount } from "../types.js"; @@ -88,18 +87,11 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise) => format(...args); - const runtime: RuntimeEnv = opts.runtime ?? { - log: (...args) => { - logger.info(formatRuntimeMessage(...args)); - }, - error: (...args) => { - logger.error(formatRuntimeMessage(...args)); - }, - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }; + const runtime: RuntimeEnv = + opts.runtime ?? + createLoggerBackedRuntime({ + logger, + }); const account = resolveTlonAccount(cfg, opts.accountId ?? undefined); if (!account.enabled) { diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index b122577e2e8..a5554cd4c5e 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -4,7 +4,6 @@ import { collectWhatsAppStatusIssues, createActionGate, DEFAULT_ACCOUNT_ID, - escapeRegExp, formatPairingApproveHint, getChatChannelMeta, listWhatsAppAccountIds, @@ -14,8 +13,8 @@ import { migrateBaseNameToDefaultAccount, normalizeAccountId, normalizeE164, + normalizeWhatsAppAllowFromEntries, normalizeWhatsAppMessagingTarget, - normalizeWhatsAppTarget, readStringParam, resolveDefaultWhatsAppAccountId, resolveWhatsAppOutboundTarget, @@ -23,8 +22,10 @@ import { resolveDefaultGroupPolicy, resolveWhatsAppAccount, resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupIntroHint, resolveWhatsAppGroupToolPolicy, resolveWhatsAppHeartbeatRecipients, + resolveWhatsAppMentionStripPatterns, whatsappOnboardingAdapter, WhatsAppConfigSchema, type ChannelMessageActionName, @@ -114,12 +115,7 @@ export const whatsappPlugin: ChannelPlugin = { }), resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [], - formatAllowFrom: ({ allowFrom }) => - allowFrom - .map((entry) => String(entry).trim()) - .filter((entry): entry is string => Boolean(entry)) - .map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry))) - .filter((entry): entry is string => Boolean(entry)), + formatAllowFrom: ({ allowFrom }) => normalizeWhatsAppAllowFromEntries(allowFrom), resolveDefaultTo: ({ cfg, accountId }) => { const root = cfg.channels?.whatsapp; const normalized = normalizeAccountId(accountId); @@ -211,18 +207,10 @@ export const whatsappPlugin: ChannelPlugin = { groups: { resolveRequireMention: resolveWhatsAppGroupRequireMention, resolveToolPolicy: resolveWhatsAppGroupToolPolicy, - resolveGroupIntroHint: () => - "WhatsApp IDs: SenderId is the participant JID (group participant id).", + resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, }, mentions: { - stripPatterns: ({ ctx }) => { - const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, ""); - if (!selfE164) { - return []; - } - const escaped = escapeRegExp(selfE164); - return [escaped, `@${escaped}`]; - }, + stripPatterns: ({ ctx }) => resolveWhatsAppMentionStripPatterns(ctx), }, commands: { enforceOwnerForCommands: true, diff --git a/extensions/whatsapp/src/resolve-target.test.ts b/extensions/whatsapp/src/resolve-target.test.ts index 86295a310ef..51bcd15bad3 100644 --- a/extensions/whatsapp/src/resolve-target.test.ts +++ b/extensions/whatsapp/src/resolve-target.test.ts @@ -71,8 +71,10 @@ vi.mock("openclaw/plugin-sdk", () => ({ readStringParam: vi.fn(), resolveDefaultWhatsAppAccountId: vi.fn(), resolveWhatsAppAccount: vi.fn(), + resolveWhatsAppGroupIntroHint: vi.fn(), resolveWhatsAppGroupRequireMention: vi.fn(), resolveWhatsAppGroupToolPolicy: vi.fn(), + resolveWhatsAppMentionStripPatterns: vi.fn(() => []), applyAccountNameToChannelSection: vi.fn(), })); diff --git a/extensions/zalo/src/actions.ts b/extensions/zalo/src/actions.ts index 318220f8c16..a5fca946ca7 100644 --- a/extensions/zalo/src/actions.ts +++ b/extensions/zalo/src/actions.ts @@ -3,7 +3,7 @@ import type { ChannelMessageActionName, OpenClawConfig, } from "openclaw/plugin-sdk"; -import { jsonResult, readStringParam } from "openclaw/plugin-sdk"; +import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk"; import { listEnabledZaloAccounts } from "./accounts.js"; import { sendMessageZalo } from "./send.js"; @@ -25,18 +25,7 @@ export const zaloMessageActions: ChannelMessageActionAdapter = { return Array.from(actions); }, supportsButtons: () => false, - extractToolSend: ({ args }) => { - const action = typeof args.action === "string" ? args.action.trim() : ""; - if (action !== "sendMessage") { - return null; - } - const to = typeof args.to === "string" ? args.to : undefined; - if (!to) { - return null; - } - const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; - return { to, accountId }; - }, + extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"), handleAction: async ({ action, params, cfg, accountId }) => { if (action === "send") { const to = readStringParam(params, "to", { required: true }); diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index b7f9fce996d..9e263f0bff8 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -7,6 +7,7 @@ import type { import { applyAccountNameToChannelSection, buildChannelConfigSchema, + buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, chunkTextForOutbound, @@ -309,17 +310,7 @@ export const zaloPlugin: ChannelPlugin = { lastError: null, }, collectStatusIssues: collectZaloStatusIssues, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - mode: snapshot.mode ?? null, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), + buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)), buildAccountSnapshot: ({ account, runtime }) => { diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 6b253d3cd7b..47269635a44 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,6 +1,6 @@ import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; -import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk"; +import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk"; import { createDedupeCache, createReplyPrefixOptions, @@ -9,6 +9,8 @@ import { rejectNonPostWebhookRequest, resolveSingleWebhookTarget, resolveSenderCommandAuthorization, + resolveOutboundMediaUrls, + sendMediaWithLeadingCaption, resolveWebhookPath, resolveWebhookTargets, requestBodyErrorToText, @@ -681,7 +683,7 @@ async function processMessageWithPipeline(params: { } async function deliverZaloReply(params: { - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }; + payload: OutboundReplyPayload; token: string; chatId: string; runtime: ZaloRuntimeEnv; @@ -696,24 +698,18 @@ async function deliverZaloReply(params: { const tableMode = params.tableMode ?? "code"; const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; - - if (mediaList.length > 0) { - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : undefined; - first = false; - try { - await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error?.(`Zalo photo send failed: ${String(err)}`); - } - } + const sentMedia = await sendMediaWithLeadingCaption({ + mediaUrls: resolveOutboundMediaUrls(payload), + caption: text, + send: async ({ mediaUrl, caption }) => { + await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); + statusSink?.({ lastOutboundAt: Date.now() }); + }, + onError: (error) => { + runtime.error?.(`Zalo photo send failed: ${String(error)}`); + }, + }); + if (sentMedia) { return; } diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 17575c40128..7e2ff850d40 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -1,11 +1,18 @@ import type { ChildProcess } from "node:child_process"; -import type { OpenClawConfig, MarkdownTableMode, RuntimeEnv } from "openclaw/plugin-sdk"; +import type { + MarkdownTableMode, + OpenClawConfig, + OutboundReplyPayload, + RuntimeEnv, +} from "openclaw/plugin-sdk"; import { createReplyPrefixOptions, + resolveOutboundMediaUrls, mergeAllowlist, resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, resolveSenderCommandAuthorization, + sendMediaWithLeadingCaption, summarizeMapping, warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk"; @@ -392,7 +399,7 @@ async function processMessage( } async function deliverZalouserReply(params: { - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }; + payload: OutboundReplyPayload; profile: string; chatId: string; isGroup: boolean; @@ -408,29 +415,23 @@ async function deliverZalouserReply(params: { const tableMode = params.tableMode ?? "code"; const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; - - if (mediaList.length > 0) { - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : undefined; - first = false; - try { - logVerbose(core, runtime, `Sending media to ${chatId}`); - await sendMessageZalouser(chatId, caption ?? "", { - profile, - mediaUrl, - isGroup, - }); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error(`Zalouser media send failed: ${String(err)}`); - } - } + const sentMedia = await sendMediaWithLeadingCaption({ + mediaUrls: resolveOutboundMediaUrls(payload), + caption: text, + send: async ({ mediaUrl, caption }) => { + logVerbose(core, runtime, `Sending media to ${chatId}`); + await sendMessageZalouser(chatId, caption ?? "", { + profile, + mediaUrl, + isGroup, + }); + statusSink?.({ lastOutboundAt: Date.now() }); + }, + onError: (error) => { + runtime.error(`Zalouser media send failed: ${String(error)}`); + }, + }); + if (sentMedia) { return; } diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 3cec944b800..2556ba5996c 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -10,9 +10,8 @@ import { resolveSignalAccount } from "../signal/accounts.js"; import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.js"; import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js"; import { resolveTelegramAccount } from "../telegram/accounts.js"; -import { escapeRegExp, normalizeE164 } from "../utils.js"; +import { normalizeE164 } from "../utils.js"; import { resolveWhatsAppAccount } from "../web/accounts.js"; -import { normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; import { resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, @@ -28,6 +27,7 @@ import { resolveWhatsAppGroupToolPolicy, } from "./plugins/group-mentions.js"; import { normalizeSignalMessagingTarget } from "./plugins/normalize/signal.js"; +import { normalizeWhatsAppAllowFromEntries } from "./plugins/normalize/whatsapp.js"; import type { ChannelCapabilities, ChannelCommandAdapter, @@ -42,6 +42,10 @@ import type { ChannelThreadingAdapter, ChannelThreadingToolContext, } from "./plugins/types.js"; +import { + resolveWhatsAppGroupIntroHint, + resolveWhatsAppMentionStripPatterns, +} from "./plugins/whatsapp-shared.js"; import { CHAT_CHANNEL_ORDER, type ChatChannelId, getChatChannelMeta } from "./registry.js"; export type ChannelDock = { @@ -287,12 +291,7 @@ const DOCKS: Record = { config: { resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [], - formatAllowFrom: ({ allowFrom }) => - allowFrom - .map((entry) => String(entry).trim()) - .filter((entry): entry is string => Boolean(entry)) - .map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry))) - .filter((entry): entry is string => Boolean(entry)), + formatAllowFrom: ({ allowFrom }) => normalizeWhatsAppAllowFromEntries(allowFrom), resolveDefaultTo: ({ cfg, accountId }) => { const root = cfg.channels?.whatsapp; const normalized = normalizeAccountId(accountId); @@ -303,18 +302,10 @@ const DOCKS: Record = { groups: { resolveRequireMention: resolveWhatsAppGroupRequireMention, resolveToolPolicy: resolveWhatsAppGroupToolPolicy, - resolveGroupIntroHint: () => - "WhatsApp IDs: SenderId is the participant JID (group participant id).", + resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, }, mentions: { - stripPatterns: ({ ctx }) => { - const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, ""); - if (!selfE164) { - return []; - } - const escaped = escapeRegExp(selfE164); - return [escaped, `@${escaped}`]; - }, + stripPatterns: ({ ctx }) => resolveWhatsAppMentionStripPatterns(ctx), }, threading: { buildToolContext: ({ context, hasRepliedRef }) => { diff --git a/src/channels/plugins/media-payload.ts b/src/channels/plugins/media-payload.ts new file mode 100644 index 00000000000..035e0082143 --- /dev/null +++ b/src/channels/plugins/media-payload.ts @@ -0,0 +1,33 @@ +export type MediaPayloadInput = { + path: string; + contentType?: string; +}; + +export type MediaPayload = { + MediaPath?: string; + MediaType?: string; + MediaUrl?: string; + MediaPaths?: string[]; + MediaUrls?: string[]; + MediaTypes?: string[]; +}; + +export function buildMediaPayload( + mediaList: MediaPayloadInput[], + opts?: { preserveMediaTypeCardinality?: boolean }, +): MediaPayload { + const first = mediaList[0]; + const mediaPaths = mediaList.map((media) => media.path); + const rawMediaTypes = mediaList.map((media) => media.contentType ?? ""); + const mediaTypes = opts?.preserveMediaTypeCardinality + ? rawMediaTypes + : rawMediaTypes.filter((value): value is string => Boolean(value)); + return { + MediaPath: first?.path, + MediaType: first?.contentType, + MediaUrl: first?.path, + MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined, + MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined, + MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined, + }; +} diff --git a/src/channels/plugins/normalize/whatsapp.ts b/src/channels/plugins/normalize/whatsapp.ts index 3504766cc3a..edff8bfe5e1 100644 --- a/src/channels/plugins/normalize/whatsapp.ts +++ b/src/channels/plugins/normalize/whatsapp.ts @@ -9,6 +9,14 @@ export function normalizeWhatsAppMessagingTarget(raw: string): string | undefine return normalizeWhatsAppTarget(trimmed) ?? undefined; } +export function normalizeWhatsAppAllowFromEntries(allowFrom: Array): string[] { + return allowFrom + .map((entry) => String(entry).trim()) + .filter((entry): entry is string => Boolean(entry)) + .map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry))) + .filter((entry): entry is string => Boolean(entry)); +} + export function looksLikeWhatsAppTargetId(raw: string): boolean { return looksLikeHandleOrPhoneTarget({ raw, diff --git a/src/channels/plugins/whatsapp-shared.ts b/src/channels/plugins/whatsapp-shared.ts new file mode 100644 index 00000000000..368b58454fb --- /dev/null +++ b/src/channels/plugins/whatsapp-shared.ts @@ -0,0 +1,17 @@ +import { escapeRegExp } from "../../utils.js"; + +export const WHATSAPP_GROUP_INTRO_HINT = + "WhatsApp IDs: SenderId is the participant JID (group participant id)."; + +export function resolveWhatsAppGroupIntroHint(): string { + return WHATSAPP_GROUP_INTRO_HINT; +} + +export function resolveWhatsAppMentionStripPatterns(ctx: { To?: string | null }): string[] { + const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, ""); + if (!selfE164) { + return []; + } + const escaped = escapeRegExp(selfE164); + return [escaped, `@${escaped}`]; +} diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 9018eb1e2f1..d99ebe3b907 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -152,6 +152,18 @@ export const BlockStreamingCoalesceSchema = z }) .strict(); +export const ReplyRuntimeConfigSchemaShape = { + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), + textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + responsePrefix: z.string().optional(), + mediaMaxMb: z.number().positive().optional(), +}; + export const BlockStreamingChunkSchema = z .object({ minChars: z.number().int().positive().optional(), diff --git a/src/discord/monitor/message-utils.ts b/src/discord/monitor/message-utils.ts index 4276fa37418..05aeab5dc76 100644 --- a/src/discord/monitor/message-utils.ts +++ b/src/discord/monitor/message-utils.ts @@ -1,5 +1,6 @@ import type { ChannelType, Client, Message } from "@buape/carbon"; import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10"; +import { buildMediaPayload } from "../../channels/plugins/media-payload.js"; import { logVerbose } from "../../globals.js"; import { fetchRemoteMedia } from "../../media/fetch.js"; import { saveMediaBuffer } from "../../media/store.js"; @@ -504,15 +505,5 @@ export function buildDiscordMediaPayload( MediaUrls?: string[]; MediaTypes?: string[]; } { - const first = mediaList[0]; - const mediaPaths = mediaList.map((media) => media.path); - const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[]; - return { - MediaPath: first?.path, - MediaType: first?.contentType, - MediaUrl: first?.path, - MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined, - MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined, - MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined, - }; + return buildMediaPayload(mediaList); } diff --git a/src/media-understanding/runner.test-utils.ts b/src/media-understanding/runner.test-utils.ts index 98c8e1cc8c2..9938202657f 100644 --- a/src/media-understanding/runner.test-utils.ts +++ b/src/media-understanding/runner.test-utils.ts @@ -1,23 +1,30 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { MsgContext } from "../auto-reply/templating.js"; import { withEnvAsync } from "../test-utils/env.js"; import { createMediaAttachmentCache, normalizeMediaAttachments } from "./runner.js"; -type AudioFixtureParams = { - ctx: MsgContext; +type MediaFixtureParams = { + ctx: { MediaPath: string; MediaType: string }; media: ReturnType; cache: ReturnType; }; -export async function withAudioFixture( - filePrefix: string, - run: (params: AudioFixtureParams) => Promise, +export async function withMediaFixture( + params: { + filePrefix: string; + extension: string; + mediaType: string; + fileContents: Buffer; + }, + run: (params: MediaFixtureParams) => Promise, ) { - const tmpPath = path.join(os.tmpdir(), filePrefix + "-" + Date.now().toString() + ".wav"); - await fs.writeFile(tmpPath, Buffer.from("RIFF")); - const ctx: MsgContext = { MediaPath: tmpPath, MediaType: "audio/wav" }; + const tmpPath = path.join( + os.tmpdir(), + `${params.filePrefix}-${Date.now().toString()}.${params.extension}`, + ); + await fs.writeFile(tmpPath, params.fileContents); + const ctx = { MediaPath: tmpPath, MediaType: params.mediaType }; const media = normalizeMediaAttachments(ctx); const cache = createMediaAttachmentCache(media, { localPathRoots: [path.dirname(tmpPath)], @@ -32,3 +39,18 @@ export async function withAudioFixture( await fs.unlink(tmpPath).catch(() => {}); } } + +export async function withAudioFixture( + filePrefix: string, + run: (params: MediaFixtureParams) => Promise, +) { + await withMediaFixture( + { + filePrefix, + extension: "wav", + mediaType: "audio/wav", + fileContents: Buffer.from("RIFF"), + }, + run, + ); +} diff --git a/src/media-understanding/runner.video.test.ts b/src/media-understanding/runner.video.test.ts index 6eba2ad15d4..3e9f3266db8 100644 --- a/src/media-understanding/runner.video.test.ts +++ b/src/media-understanding/runner.video.test.ts @@ -1,34 +1,26 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { withEnvAsync } from "../test-utils/env.js"; -import { createMediaAttachmentCache, normalizeMediaAttachments, runCapability } from "./runner.js"; +import { runCapability } from "./runner.js"; +import { withMediaFixture } from "./runner.test-utils.js"; async function withVideoFixture( filePrefix: string, run: (params: { ctx: { MediaPath: string; MediaType: string }; - media: ReturnType; - cache: ReturnType; + media: ReturnType; + cache: ReturnType; }) => Promise, ) { - const tmpPath = path.join(os.tmpdir(), `${filePrefix}-${Date.now().toString()}.mp4`); - await fs.writeFile(tmpPath, Buffer.from("video")); - const ctx = { MediaPath: tmpPath, MediaType: "video/mp4" }; - const media = normalizeMediaAttachments(ctx); - const cache = createMediaAttachmentCache(media, { - localPathRoots: [path.dirname(tmpPath)], - }); - try { - await withEnvAsync({ PATH: "" }, async () => { - await run({ ctx, media, cache }); - }); - } finally { - await cache.cleanup(); - await fs.unlink(tmpPath).catch(() => {}); - } + await withMediaFixture( + { + filePrefix, + extension: "mp4", + mediaType: "video/mp4", + fileContents: Buffer.from("video"), + }, + run, + ); } describe("runCapability video provider wiring", () => { diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index 5eed17a8981..d6b0ca2de42 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -1,6 +1,8 @@ import os from "node:os"; import type { OpenClawConfig } from "../config/types.js"; +import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; import { isCarrierGradeNatIpv4Address, isRfc1918Ipv4Address } from "../shared/net/ip.js"; +import { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; const DEFAULT_GATEWAY_PORT = 18789; @@ -161,58 +163,6 @@ function pickTailnetIPv4( return pickIPv4Matching(networkInterfaces, isTailnetIPv4); } -function parsePossiblyNoisyJsonObject(raw: string): Record { - const start = raw.indexOf("{"); - const end = raw.lastIndexOf("}"); - if (start === -1 || end <= start) { - return {}; - } - try { - return JSON.parse(raw.slice(start, end + 1)) as Record; - } catch { - return {}; - } -} - -async function resolveTailnetHost( - runCommandWithTimeout?: PairingSetupCommandRunner, -): Promise { - if (!runCommandWithTimeout) { - return null; - } - const candidates = ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]; - for (const candidate of candidates) { - try { - const result = await runCommandWithTimeout([candidate, "status", "--json"], { - timeoutMs: 5000, - }); - if (result.code !== 0) { - continue; - } - const raw = result.stdout.trim(); - if (!raw) { - continue; - } - const parsed = parsePossiblyNoisyJsonObject(raw); - const self = - typeof parsed.Self === "object" && parsed.Self !== null - ? (parsed.Self as Record) - : undefined; - const dns = typeof self?.DNSName === "string" ? self.DNSName : undefined; - if (dns && dns.length > 0) { - return dns.replace(/\.$/, ""); - } - const ips = Array.isArray(self?.TailscaleIPs) ? (self.TailscaleIPs as string[]) : []; - if (ips.length > 0) { - return ips[0] ?? null; - } - } catch { - continue; - } - } - return null; -} - function resolveAuth(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): ResolveAuthResult { const mode = cfg.gateway?.auth?.mode; const token = @@ -278,7 +228,7 @@ async function resolveGatewayUrl( const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; if (tailscaleMode === "serve" || tailscaleMode === "funnel") { - const host = await resolveTailnetHost(opts.runCommandWithTimeout); + const host = await resolveTailnetHostWithRunner(opts.runCommandWithTimeout); if (!host) { return { error: "Tailscale Serve is enabled, but MagicDNS could not be resolved." }; } @@ -289,29 +239,16 @@ async function resolveGatewayUrl( return { url: remoteUrl, source: "gateway.remote.url" }; } - const bind = cfg.gateway?.bind ?? "loopback"; - if (bind === "custom") { - const host = cfg.gateway?.customBindHost?.trim(); - if (host) { - return { url: `${scheme}://${host}:${port}`, source: "gateway.bind=custom" }; - } - return { error: "gateway.bind=custom requires gateway.customBindHost." }; - } - - if (bind === "tailnet") { - const host = pickTailnetIPv4(opts.networkInterfaces); - if (host) { - return { url: `${scheme}://${host}:${port}`, source: "gateway.bind=tailnet" }; - } - return { error: "gateway.bind=tailnet set, but no tailnet IP was found." }; - } - - if (bind === "lan") { - const host = pickLanIPv4(opts.networkInterfaces); - if (host) { - return { url: `${scheme}://${host}:${port}`, source: "gateway.bind=lan" }; - } - return { error: "gateway.bind=lan set, but no private LAN IP was found." }; + const bindResult = resolveGatewayBindUrl({ + bind: cfg.gateway?.bind, + customBindHost: cfg.gateway?.customBindHost, + scheme, + port, + pickTailnetHost: () => pickTailnetIPv4(opts.networkInterfaces), + pickLanHost: () => pickLanIPv4(opts.networkInterfaces), + }); + if (bindResult) { + return bindResult; } return { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 17958205d04..bc675fb36b6 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -106,7 +106,9 @@ export type { WebhookTargetMatchResult } from "./webhook-targets.js"; export type { AgentMediaPayload } from "./agent-media-payload.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { + buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, + buildTokenChannelStatusSummary, collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, } from "./status-helpers.js"; @@ -163,6 +165,7 @@ export { MarkdownConfigSchema, MarkdownTableModeSchema, normalizeAllowFrom, + ReplyRuntimeConfigSchemaShape, requireOpenAllowFrom, TtsAutoSchema, TtsConfigSchema, @@ -172,15 +175,42 @@ export { export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export type { RuntimeEnv } from "../runtime.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +export { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + resolveThreadSessionKeys, +} from "../routing/session-key.js"; export { formatAllowFromLowercase, isAllowedParsedChatSender } from "./allow-from.js"; export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { handleSlackMessageAction } from "./slack-message-actions.js"; export { extractToolSend } from "./tool-send.js"; +export { + createNormalizedOutboundDeliverer, + formatTextWithAttachmentLinks, + normalizeOutboundReplyPayload, + resolveOutboundMediaUrls, + sendMediaWithLeadingCaption, +} from "./reply-payload.js"; +export type { OutboundReplyPayload } from "./reply-payload.js"; export { resolveChannelAccountConfigBasePath } from "./config-paths.js"; +export { buildMediaPayload } from "../channels/plugins/media-payload.js"; +export type { MediaPayload, MediaPayloadInput } from "../channels/plugins/media-payload.js"; +export { createLoggerBackedRuntime } from "./runtime.js"; export { chunkTextForOutbound } from "./text-chunking.js"; export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js"; export { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js"; +export { + runPluginCommandWithTimeout, + type PluginCommandRunOptions, + type PluginCommandRunResult, +} from "./run-command.js"; +export { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; +export type { GatewayBindUrlResult } from "../shared/gateway-bind-url.js"; +export { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; +export type { + TailscaleStatusCommandResult, + TailscaleStatusCommandRunner, +} from "../shared/tailscale-status.js"; export type { ChatType } from "../channels/chat-type.js"; /** @deprecated Use ChatType instead */ export type { RoutePeerKind } from "../routing/resolve-route.js"; @@ -188,6 +218,7 @@ export { resolveAckReaction } from "../agents/identity.js"; export type { ReplyPayload } from "../auto-reply/types.js"; export type { ChunkMode } from "../auto-reply/chunk.js"; export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js"; +export { formatInboundFromLabel } from "../auto-reply/envelope.js"; export { approveDevicePairing, listDevicePairing, @@ -462,8 +493,13 @@ export { whatsappOnboardingAdapter } from "../channels/plugins/onboarding/whatsa export { resolveWhatsAppHeartbeatRecipients } from "../channels/plugins/whatsapp-heartbeat.js"; export { looksLikeWhatsAppTargetId, + normalizeWhatsAppAllowFromEntries, normalizeWhatsAppMessagingTarget, } from "../channels/plugins/normalize/whatsapp.js"; +export { + resolveWhatsAppGroupIntroHint, + resolveWhatsAppMentionStripPatterns, +} from "../channels/plugins/whatsapp-shared.js"; export { collectWhatsAppStatusIssues } from "../channels/plugins/status-issues/whatsapp.js"; // Channel: BlueBubbles diff --git a/src/plugin-sdk/reply-payload.ts b/src/plugin-sdk/reply-payload.ts new file mode 100644 index 00000000000..b2534cd629c --- /dev/null +++ b/src/plugin-sdk/reply-payload.ts @@ -0,0 +1,97 @@ +export type OutboundReplyPayload = { + text?: string; + mediaUrls?: string[]; + mediaUrl?: string; + replyToId?: string; +}; + +export function normalizeOutboundReplyPayload( + payload: Record, +): OutboundReplyPayload { + const text = typeof payload.text === "string" ? payload.text : undefined; + const mediaUrls = Array.isArray(payload.mediaUrls) + ? payload.mediaUrls.filter( + (entry): entry is string => typeof entry === "string" && entry.length > 0, + ) + : undefined; + const mediaUrl = typeof payload.mediaUrl === "string" ? payload.mediaUrl : undefined; + const replyToId = typeof payload.replyToId === "string" ? payload.replyToId : undefined; + return { + text, + mediaUrls, + mediaUrl, + replyToId, + }; +} + +export function createNormalizedOutboundDeliverer( + handler: (payload: OutboundReplyPayload) => Promise, +): (payload: unknown) => Promise { + return async (payload: unknown) => { + const normalized = + payload && typeof payload === "object" + ? normalizeOutboundReplyPayload(payload as Record) + : {}; + await handler(normalized); + }; +} + +export function resolveOutboundMediaUrls(payload: { + mediaUrls?: string[]; + mediaUrl?: string; +}): string[] { + if (payload.mediaUrls?.length) { + return payload.mediaUrls; + } + if (payload.mediaUrl) { + return [payload.mediaUrl]; + } + return []; +} + +export function formatTextWithAttachmentLinks( + text: string | undefined, + mediaUrls: string[], +): string { + const trimmedText = text?.trim() ?? ""; + if (!trimmedText && mediaUrls.length === 0) { + return ""; + } + const mediaBlock = mediaUrls.length + ? mediaUrls.map((url) => `Attachment: ${url}`).join("\n") + : ""; + if (!trimmedText) { + return mediaBlock; + } + if (!mediaBlock) { + return trimmedText; + } + return `${trimmedText}\n\n${mediaBlock}`; +} + +export async function sendMediaWithLeadingCaption(params: { + mediaUrls: string[]; + caption: string; + send: (payload: { mediaUrl: string; caption?: string }) => Promise; + onError?: (error: unknown, mediaUrl: string) => void; +}): Promise { + if (params.mediaUrls.length === 0) { + return false; + } + + let first = true; + for (const mediaUrl of params.mediaUrls) { + const caption = first ? params.caption : undefined; + first = false; + try { + await params.send({ mediaUrl, caption }); + } catch (error) { + if (params.onError) { + params.onError(error, mediaUrl); + continue; + } + throw error; + } + } + return true; +} diff --git a/src/plugin-sdk/run-command.ts b/src/plugin-sdk/run-command.ts new file mode 100644 index 00000000000..03f0846a57e --- /dev/null +++ b/src/plugin-sdk/run-command.ts @@ -0,0 +1,45 @@ +import { runCommandWithTimeout } from "../process/exec.js"; + +export type PluginCommandRunResult = { + code: number; + stdout: string; + stderr: string; +}; + +export type PluginCommandRunOptions = { + argv: string[]; + timeoutMs: number; + cwd?: string; + env?: NodeJS.ProcessEnv; +}; + +export async function runPluginCommandWithTimeout( + options: PluginCommandRunOptions, +): Promise { + const [command] = options.argv; + if (!command) { + return { code: 1, stdout: "", stderr: "command is required" }; + } + + try { + const result = await runCommandWithTimeout(options.argv, { + timeoutMs: options.timeoutMs, + cwd: options.cwd, + env: options.env, + }); + const timedOut = result.termination === "timeout" || result.termination === "no-output-timeout"; + return { + code: result.code ?? 1, + stdout: result.stdout, + stderr: timedOut + ? result.stderr || `command timed out after ${options.timeoutMs}ms` + : result.stderr, + }; + } catch (error) { + return { + code: 1, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/src/plugin-sdk/runtime.ts b/src/plugin-sdk/runtime.ts new file mode 100644 index 00000000000..dac01e9b5dc --- /dev/null +++ b/src/plugin-sdk/runtime.ts @@ -0,0 +1,24 @@ +import { format } from "node:util"; +import type { RuntimeEnv } from "../runtime.js"; + +type LoggerLike = { + info: (message: string) => void; + error: (message: string) => void; +}; + +export function createLoggerBackedRuntime(params: { + logger: LoggerLike; + exitError?: (code: number) => Error; +}): RuntimeEnv { + return { + log: (...args) => { + params.logger.info(format(...args)); + }, + error: (...args) => { + params.logger.error(format(...args)); + }, + exit: (code: number): never => { + throw params.exitError?.(code) ?? new Error(`exit ${code}`); + }, + }; +} diff --git a/src/plugin-sdk/status-helpers.test.ts b/src/plugin-sdk/status-helpers.test.ts index d63f1ee0ddf..b2e10cc4ae8 100644 --- a/src/plugin-sdk/status-helpers.test.ts +++ b/src/plugin-sdk/status-helpers.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, + buildTokenChannelStatusSummary, collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, } from "./status-helpers.js"; @@ -64,6 +66,71 @@ describe("buildBaseChannelStatusSummary", () => { }); }); +describe("buildBaseAccountStatusSnapshot", () => { + it("builds account status with runtime defaults", () => { + expect( + buildBaseAccountStatusSnapshot({ + account: { accountId: "default", enabled: true, configured: true }, + }), + ).toEqual({ + accountId: "default", + name: undefined, + enabled: true, + configured: true, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + probe: undefined, + lastInboundAt: null, + lastOutboundAt: null, + }); + }); +}); + +describe("buildTokenChannelStatusSummary", () => { + it("includes token/probe fields with mode by default", () => { + expect(buildTokenChannelStatusSummary({})).toEqual({ + configured: false, + tokenSource: "none", + running: false, + mode: null, + lastStartAt: null, + lastStopAt: null, + lastError: null, + probe: undefined, + lastProbeAt: null, + }); + }); + + it("can omit mode for channels without a mode state", () => { + expect( + buildTokenChannelStatusSummary( + { + configured: true, + tokenSource: "env", + running: true, + lastStartAt: 1, + lastStopAt: 2, + lastError: "boom", + probe: { ok: true }, + lastProbeAt: 3, + }, + { includeMode: false }, + ), + ).toEqual({ + configured: true, + tokenSource: "env", + running: true, + lastStartAt: 1, + lastStopAt: 2, + lastError: "boom", + probe: { ok: true }, + lastProbeAt: 3, + }); + }); +}); + describe("collectStatusIssuesFromLastError", () => { it("returns runtime issues only for non-empty string lastError values", () => { expect( diff --git a/src/plugin-sdk/status-helpers.ts b/src/plugin-sdk/status-helpers.ts index 945dca1bcbf..cbcc8ca57d4 100644 --- a/src/plugin-sdk/status-helpers.ts +++ b/src/plugin-sdk/status-helpers.ts @@ -1,5 +1,14 @@ import type { ChannelStatusIssue } from "../channels/plugins/types.js"; +type RuntimeLifecycleSnapshot = { + running?: boolean | null; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + lastInboundAt?: number | null; + lastOutboundAt?: number | null; +}; + export function createDefaultChannelRuntimeState>( accountId: string, extra?: T, @@ -36,6 +45,61 @@ export function buildBaseChannelStatusSummary(snapshot: { }; } +export function buildBaseAccountStatusSnapshot(params: { + account: { + accountId: string; + name?: string; + enabled?: boolean; + configured?: boolean; + }; + runtime?: RuntimeLifecycleSnapshot | null; + probe?: unknown; +}) { + const { account, runtime, probe } = params; + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + probe, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }; +} + +export function buildTokenChannelStatusSummary( + snapshot: { + configured?: boolean | null; + tokenSource?: string | null; + running?: boolean | null; + mode?: string | null; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + probe?: unknown; + lastProbeAt?: number | null; + }, + opts?: { includeMode?: boolean }, +) { + const base = { + ...buildBaseChannelStatusSummary(snapshot), + tokenSource: snapshot.tokenSource ?? "none", + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }; + if (opts?.includeMode === false) { + return base; + } + return { + ...base, + mode: snapshot.mode ?? null, + }; +} + export function collectStatusIssuesFromLastError( channel: string, accounts: Array<{ accountId: string; lastError?: unknown }>, diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index 77429870797..73b10dfeb7c 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -223,12 +223,15 @@ export function resolveThreadSessionKeys(params: { threadId?: string | null; parentSessionKey?: string; useSuffix?: boolean; + normalizeThreadId?: (threadId: string) => string; }): { sessionKey: string; parentSessionKey?: string } { const threadId = (params.threadId ?? "").trim(); if (!threadId) { return { sessionKey: params.baseSessionKey, parentSessionKey: undefined }; } - const normalizedThreadId = threadId.toLowerCase(); + const normalizedThreadId = (params.normalizeThreadId ?? ((value: string) => value.toLowerCase()))( + threadId, + ); const useSuffix = params.useSuffix ?? true; const sessionKey = useSuffix ? `${params.baseSessionKey}:thread:${normalizedThreadId}` diff --git a/src/shared/gateway-bind-url.ts b/src/shared/gateway-bind-url.ts new file mode 100644 index 00000000000..9412fd8a1e1 --- /dev/null +++ b/src/shared/gateway-bind-url.ts @@ -0,0 +1,45 @@ +export type GatewayBindUrlResult = + | { + url: string; + source: "gateway.bind=custom" | "gateway.bind=tailnet" | "gateway.bind=lan"; + } + | { + error: string; + } + | null; + +export function resolveGatewayBindUrl(params: { + bind?: string; + customBindHost?: string; + scheme: "ws" | "wss"; + port: number; + pickTailnetHost: () => string | null; + pickLanHost: () => string | null; +}): GatewayBindUrlResult { + const bind = params.bind ?? "loopback"; + if (bind === "custom") { + const host = params.customBindHost?.trim(); + if (host) { + return { url: `${params.scheme}://${host}:${params.port}`, source: "gateway.bind=custom" }; + } + return { error: "gateway.bind=custom requires gateway.customBindHost." }; + } + + if (bind === "tailnet") { + const host = params.pickTailnetHost(); + if (host) { + return { url: `${params.scheme}://${host}:${params.port}`, source: "gateway.bind=tailnet" }; + } + return { error: "gateway.bind=tailnet set, but no tailnet IP was found." }; + } + + if (bind === "lan") { + const host = params.pickLanHost(); + if (host) { + return { url: `${params.scheme}://${host}:${params.port}`, source: "gateway.bind=lan" }; + } + return { error: "gateway.bind=lan set, but no private LAN IP was found." }; + } + + return null; +} diff --git a/src/shared/tailscale-status.ts b/src/shared/tailscale-status.ts new file mode 100644 index 00000000000..2756e6efdf1 --- /dev/null +++ b/src/shared/tailscale-status.ts @@ -0,0 +1,70 @@ +export type TailscaleStatusCommandResult = { + code: number | null; + stdout: string; +}; + +export type TailscaleStatusCommandRunner = ( + argv: string[], + opts: { timeoutMs: number }, +) => Promise; + +const TAILSCALE_STATUS_COMMAND_CANDIDATES = [ + "tailscale", + "/Applications/Tailscale.app/Contents/MacOS/Tailscale", +]; + +function parsePossiblyNoisyJsonObject(raw: string): Record { + const start = raw.indexOf("{"); + const end = raw.lastIndexOf("}"); + if (start === -1 || end <= start) { + return {}; + } + try { + return JSON.parse(raw.slice(start, end + 1)) as Record; + } catch { + return {}; + } +} + +function extractTailnetHostFromStatusJson(raw: string): string | null { + const parsed = parsePossiblyNoisyJsonObject(raw); + const self = + typeof parsed.Self === "object" && parsed.Self !== null + ? (parsed.Self as Record) + : undefined; + const dns = typeof self?.DNSName === "string" ? self.DNSName : undefined; + if (dns && dns.length > 0) { + return dns.replace(/\.$/, ""); + } + const ips = Array.isArray(self?.TailscaleIPs) ? (self.TailscaleIPs as string[]) : []; + return ips.length > 0 ? (ips[0] ?? null) : null; +} + +export async function resolveTailnetHostWithRunner( + runCommandWithTimeout?: TailscaleStatusCommandRunner, +): Promise { + if (!runCommandWithTimeout) { + return null; + } + for (const candidate of TAILSCALE_STATUS_COMMAND_CANDIDATES) { + try { + const result = await runCommandWithTimeout([candidate, "status", "--json"], { + timeoutMs: 5000, + }); + if (result.code !== 0) { + continue; + } + const raw = result.stdout.trim(); + if (!raw) { + continue; + } + const host = extractTailnetHostFromStatusJson(raw); + if (host) { + return host; + } + } catch { + continue; + } + } + return null; +}