refactor: de-duplicate channel runtime and payload helpers

This commit is contained in:
Peter Steinberger
2026-02-23 21:25:20 +00:00
parent 0ae7f470a2
commit 0183610db3
44 changed files with 775 additions and 698 deletions

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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<string> {
@@ -37,77 +42,6 @@ type ResolveAuthResult = {
error?: string;
};
type CommandResult = {
code: number;
stdout: string;
stderr: string;
};
async function runFixedCommandWithTimeout(
argv: string[],
timeoutMs: number,
): Promise<CommandResult> {
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<string | null> {
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<string, unknown>)
: 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<string, unknown> {
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<string, unknown>;
} 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<ResolveUrlResu
}
}
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();
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();
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,
pickLanHost: pickLanIPv4,
});
if (bindResult) {
return bindResult;
}
return {

View File

@@ -1,6 +1,7 @@
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
buildTokenChannelStatusSummary,
collectDiscordAuditChannelIds,
collectDiscordStatusIssues,
DEFAULT_ACCOUNT_ID,
@@ -347,16 +348,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
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,

View File

@@ -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<ResolvedIrcAccount, IrcProbe> = {
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: {

View File

@@ -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();

View File

@@ -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<void>;
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)}`);
},

View File

@@ -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(

View File

@@ -1,5 +1,6 @@
import {
buildChannelConfigSchema,
buildTokenChannelStatusSummary,
DEFAULT_ACCOUNT_ID,
LineConfigSchema,
processLineMessage,
@@ -595,17 +596,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
}
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 }) => {

View File

@@ -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<CommandResult> {
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<boolean>;
@@ -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,

View File

@@ -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<RuntimeEnv["log"]>) => 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;

View File

@@ -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,
});
}

View File

@@ -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<typeof import("openclaw/plugin-sdk")>();
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" });

View File

@@ -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 });
}

View File

@@ -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();

View File

@@ -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<void> {
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)}`);
},

View File

@@ -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}"`);

View File

@@ -1,5 +1,6 @@
import {
applyAccountNameToChannelSection,
buildBaseAccountStatusSnapshot,
buildBaseChannelStatusSummary,
buildChannelConfigSchema,
collectStatusIssuesFromLastError,
@@ -273,18 +274,8 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
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: {

View File

@@ -1,6 +1,7 @@
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
buildTokenChannelStatusSummary,
collectTelegramStatusIssues,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
@@ -374,17 +375,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
lastError: null,
},
collectStatusIssues: collectTelegramStatusIssues,
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 }) =>
getTelegramRuntime().channel.telegram.probeTelegram(
account.token,

View File

@@ -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<v
}
const logger = core.logging.getChildLogger({ module: "tlon-auto-reply" });
const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => 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) {

View File

@@ -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<ResolvedWhatsAppAccount> = {
}),
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<ResolvedWhatsAppAccount> = {
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,

View File

@@ -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(),
}));

View File

@@ -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 });

View File

@@ -7,6 +7,7 @@ import type {
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
buildTokenChannelStatusSummary,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
chunkTextForOutbound,
@@ -309,17 +310,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
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 }) => {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<ChatChannelId, ChannelDock> = {
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<ChatChannelId, ChannelDock> = {
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 }) => {

View File

@@ -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,
};
}

View File

@@ -9,6 +9,14 @@ export function normalizeWhatsAppMessagingTarget(raw: string): string | undefine
return normalizeWhatsAppTarget(trimmed) ?? undefined;
}
export function normalizeWhatsAppAllowFromEntries(allowFrom: Array<string | number>): 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,

View File

@@ -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}`];
}

View File

@@ -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(),

View File

@@ -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);
}

View File

@@ -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<typeof normalizeMediaAttachments>;
cache: ReturnType<typeof createMediaAttachmentCache>;
};
export async function withAudioFixture(
filePrefix: string,
run: (params: AudioFixtureParams) => Promise<void>,
export async function withMediaFixture(
params: {
filePrefix: string;
extension: string;
mediaType: string;
fileContents: Buffer;
},
run: (params: MediaFixtureParams) => Promise<void>,
) {
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<void>,
) {
await withMediaFixture(
{
filePrefix,
extension: "wav",
mediaType: "audio/wav",
fileContents: Buffer.from("RIFF"),
},
run,
);
}

View File

@@ -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<typeof normalizeMediaAttachments>;
cache: ReturnType<typeof createMediaAttachmentCache>;
media: ReturnType<typeof import("./runner.js").normalizeMediaAttachments>;
cache: ReturnType<typeof import("./runner.js").createMediaAttachmentCache>;
}) => Promise<void>,
) {
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", () => {

View File

@@ -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<string, unknown> {
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<string, unknown>;
} catch {
return {};
}
}
async function resolveTailnetHost(
runCommandWithTimeout?: PairingSetupCommandRunner,
): Promise<string | null> {
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<string, unknown>)
: 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 {

View File

@@ -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

View File

@@ -0,0 +1,97 @@
export type OutboundReplyPayload = {
text?: string;
mediaUrls?: string[];
mediaUrl?: string;
replyToId?: string;
};
export function normalizeOutboundReplyPayload(
payload: Record<string, unknown>,
): 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<void>,
): (payload: unknown) => Promise<void> {
return async (payload: unknown) => {
const normalized =
payload && typeof payload === "object"
? normalizeOutboundReplyPayload(payload as Record<string, unknown>)
: {};
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<void>;
onError?: (error: unknown, mediaUrl: string) => void;
}): Promise<boolean> {
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;
}

View File

@@ -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<PluginCommandRunResult> {
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),
};
}
}

24
src/plugin-sdk/runtime.ts Normal file
View File

@@ -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}`);
},
};
}

View File

@@ -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(

View File

@@ -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<T extends Record<string, unknown>>(
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 }>,

View File

@@ -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}`

View File

@@ -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;
}

View File

@@ -0,0 +1,70 @@
export type TailscaleStatusCommandResult = {
code: number | null;
stdout: string;
};
export type TailscaleStatusCommandRunner = (
argv: string[],
opts: { timeoutMs: number },
) => Promise<TailscaleStatusCommandResult>;
const TAILSCALE_STATUS_COMMAND_CANDIDATES = [
"tailscale",
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
];
function parsePossiblyNoisyJsonObject(raw: string): Record<string, unknown> {
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<string, unknown>;
} 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<string, unknown>)
: 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<string | null> {
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;
}