mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 19:37:28 +00:00
refactor: de-duplicate channel runtime and payload helpers
This commit is contained in:
@@ -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