mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
refactor: de-duplicate channel runtime and payload helpers
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)}`);
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)}`);
|
||||
},
|
||||
|
||||
@@ -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}"`);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
33
src/channels/plugins/media-payload.ts
Normal file
33
src/channels/plugins/media-payload.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
17
src/channels/plugins/whatsapp-shared.ts
Normal file
17
src/channels/plugins/whatsapp-shared.ts
Normal 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}`];
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
97
src/plugin-sdk/reply-payload.ts
Normal file
97
src/plugin-sdk/reply-payload.ts
Normal 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;
|
||||
}
|
||||
45
src/plugin-sdk/run-command.ts
Normal file
45
src/plugin-sdk/run-command.ts
Normal 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
24
src/plugin-sdk/runtime.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }>,
|
||||
|
||||
@@ -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}`
|
||||
|
||||
45
src/shared/gateway-bind-url.ts
Normal file
45
src/shared/gateway-bind-url.ts
Normal 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;
|
||||
}
|
||||
70
src/shared/tailscale-status.ts
Normal file
70
src/shared/tailscale-status.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user