feat: add providers CLI and multi-account onboarding
This commit is contained in:
90
src/signal/accounts.ts
Normal file
90
src/signal/accounts.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { SignalAccountConfig } from "../config/types.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "../routing/session-key.js";
|
||||
|
||||
export type ResolvedSignalAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
baseUrl: string;
|
||||
configured: boolean;
|
||||
config: SignalAccountConfig;
|
||||
};
|
||||
|
||||
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const accounts = cfg.signal?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return [];
|
||||
return Object.keys(accounts).filter(Boolean);
|
||||
}
|
||||
|
||||
export function listSignalAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const ids = listConfiguredAccountIds(cfg);
|
||||
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
||||
return ids.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function resolveDefaultSignalAccountId(cfg: ClawdbotConfig): string {
|
||||
const ids = listSignalAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): SignalAccountConfig | undefined {
|
||||
const accounts = cfg.signal?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return undefined;
|
||||
return accounts[accountId] as SignalAccountConfig | undefined;
|
||||
}
|
||||
|
||||
function mergeSignalAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): SignalAccountConfig {
|
||||
const { accounts: _ignored, ...base } = (cfg.signal ??
|
||||
{}) as SignalAccountConfig & { accounts?: unknown };
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
export function resolveSignalAccount(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedSignalAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = params.cfg.signal?.enabled !== false;
|
||||
const merged = mergeSignalAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const host = merged.httpHost?.trim() || "127.0.0.1";
|
||||
const port = merged.httpPort ?? 8080;
|
||||
const baseUrl = merged.httpUrl?.trim() || `http://${host}:${port}`;
|
||||
const configured = Boolean(
|
||||
merged.account?.trim() ||
|
||||
merged.httpUrl?.trim() ||
|
||||
merged.cliPath?.trim() ||
|
||||
merged.httpHost?.trim() ||
|
||||
typeof merged.httpPort === "number" ||
|
||||
typeof merged.autoStart === "boolean",
|
||||
);
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
baseUrl,
|
||||
configured,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledSignalAccounts(
|
||||
cfg: ClawdbotConfig,
|
||||
): ResolvedSignalAccount[] {
|
||||
return listSignalAccountIds(cfg)
|
||||
.map((accountId) => resolveSignalAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
|
||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { signalCheck, signalRpcRequest } from "./client.js";
|
||||
import { spawnSignalDaemon } from "./daemon.js";
|
||||
import { sendMessageSignal } from "./send.js";
|
||||
@@ -51,6 +53,8 @@ export type MonitorSignalOpts = {
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
config?: ClawdbotConfig;
|
||||
baseUrl?: string;
|
||||
autoStart?: boolean;
|
||||
cliPath?: string;
|
||||
@@ -83,36 +87,8 @@ function resolveRuntime(opts: MonitorSignalOpts): RuntimeEnv {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBaseUrl(opts: MonitorSignalOpts): string {
|
||||
const cfg = loadConfig();
|
||||
const signalCfg = cfg.signal;
|
||||
if (opts.baseUrl?.trim()) return opts.baseUrl.trim();
|
||||
if (signalCfg?.httpUrl?.trim()) return signalCfg.httpUrl.trim();
|
||||
const host = opts.httpHost ?? signalCfg?.httpHost ?? "127.0.0.1";
|
||||
const port = opts.httpPort ?? signalCfg?.httpPort ?? 8080;
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
function resolveAccount(opts: MonitorSignalOpts): string | undefined {
|
||||
const cfg = loadConfig();
|
||||
return opts.account?.trim() || cfg.signal?.account?.trim() || undefined;
|
||||
}
|
||||
|
||||
function resolveAllowFrom(opts: MonitorSignalOpts): string[] {
|
||||
const cfg = loadConfig();
|
||||
const raw = opts.allowFrom ?? cfg.signal?.allowFrom ?? [];
|
||||
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveGroupAllowFrom(opts: MonitorSignalOpts): string[] {
|
||||
const cfg = loadConfig();
|
||||
const raw =
|
||||
opts.groupAllowFrom ??
|
||||
cfg.signal?.groupAllowFrom ??
|
||||
(cfg.signal?.allowFrom && cfg.signal.allowFrom.length > 0
|
||||
? cfg.signal.allowFrom
|
||||
: []);
|
||||
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
function normalizeAllowList(raw?: Array<string | number>): string[] {
|
||||
return (raw ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function isAllowedSender(sender: string, allowFrom: string[]): boolean {
|
||||
@@ -207,12 +183,21 @@ async function deliverReplies(params: {
|
||||
target: string;
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
runtime: RuntimeEnv;
|
||||
maxBytes: number;
|
||||
textLimit: number;
|
||||
}) {
|
||||
const { replies, target, baseUrl, account, runtime, maxBytes, textLimit } =
|
||||
params;
|
||||
const {
|
||||
replies,
|
||||
target,
|
||||
baseUrl,
|
||||
account,
|
||||
accountId,
|
||||
runtime,
|
||||
maxBytes,
|
||||
textLimit,
|
||||
} = params;
|
||||
for (const payload of replies) {
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
@@ -224,6 +209,7 @@ async function deliverReplies(params: {
|
||||
baseUrl,
|
||||
account,
|
||||
maxBytes,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -236,6 +222,7 @@ async function deliverReplies(params: {
|
||||
account,
|
||||
mediaUrl: url,
|
||||
maxBytes,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -247,37 +234,53 @@ export async function monitorSignalProvider(
|
||||
opts: MonitorSignalOpts = {},
|
||||
): Promise<void> {
|
||||
const runtime = resolveRuntime(opts);
|
||||
const cfg = loadConfig();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "signal");
|
||||
const baseUrl = resolveBaseUrl(opts);
|
||||
const account = resolveAccount(opts);
|
||||
const dmPolicy = cfg.signal?.dmPolicy ?? "pairing";
|
||||
const allowFrom = resolveAllowFrom(opts);
|
||||
const groupAllowFrom = resolveGroupAllowFrom(opts);
|
||||
const groupPolicy = cfg.signal?.groupPolicy ?? "open";
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const accountInfo = resolveSignalAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const textLimit = resolveTextChunkLimit(cfg, "signal", accountInfo.accountId);
|
||||
const baseUrl = opts.baseUrl?.trim() || accountInfo.baseUrl;
|
||||
const account = opts.account?.trim() || accountInfo.config.account?.trim();
|
||||
const dmPolicy = accountInfo.config.dmPolicy ?? "pairing";
|
||||
const allowFrom = normalizeAllowList(
|
||||
opts.allowFrom ?? accountInfo.config.allowFrom,
|
||||
);
|
||||
const groupAllowFrom = normalizeAllowList(
|
||||
opts.groupAllowFrom ??
|
||||
accountInfo.config.groupAllowFrom ??
|
||||
(accountInfo.config.allowFrom && accountInfo.config.allowFrom.length > 0
|
||||
? accountInfo.config.allowFrom
|
||||
: []),
|
||||
);
|
||||
const groupPolicy = accountInfo.config.groupPolicy ?? "open";
|
||||
const mediaMaxBytes =
|
||||
(opts.mediaMaxMb ?? cfg.signal?.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||
(opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||
const ignoreAttachments =
|
||||
opts.ignoreAttachments ?? cfg.signal?.ignoreAttachments ?? false;
|
||||
opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false;
|
||||
|
||||
const autoStart =
|
||||
opts.autoStart ?? cfg.signal?.autoStart ?? !cfg.signal?.httpUrl;
|
||||
opts.autoStart ??
|
||||
accountInfo.config.autoStart ??
|
||||
!accountInfo.config.httpUrl;
|
||||
let daemonHandle: ReturnType<typeof spawnSignalDaemon> | null = null;
|
||||
|
||||
if (autoStart) {
|
||||
const cliPath = opts.cliPath ?? cfg.signal?.cliPath ?? "signal-cli";
|
||||
const httpHost = opts.httpHost ?? cfg.signal?.httpHost ?? "127.0.0.1";
|
||||
const httpPort = opts.httpPort ?? cfg.signal?.httpPort ?? 8080;
|
||||
const cliPath = opts.cliPath ?? accountInfo.config.cliPath ?? "signal-cli";
|
||||
const httpHost =
|
||||
opts.httpHost ?? accountInfo.config.httpHost ?? "127.0.0.1";
|
||||
const httpPort = opts.httpPort ?? accountInfo.config.httpPort ?? 8080;
|
||||
daemonHandle = spawnSignalDaemon({
|
||||
cliPath,
|
||||
account,
|
||||
httpHost,
|
||||
httpPort,
|
||||
receiveMode: opts.receiveMode ?? cfg.signal?.receiveMode,
|
||||
receiveMode: opts.receiveMode ?? accountInfo.config.receiveMode,
|
||||
ignoreAttachments:
|
||||
opts.ignoreAttachments ?? cfg.signal?.ignoreAttachments,
|
||||
ignoreStories: opts.ignoreStories ?? cfg.signal?.ignoreStories,
|
||||
sendReadReceipts: opts.sendReadReceipts ?? cfg.signal?.sendReadReceipts,
|
||||
opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments,
|
||||
ignoreStories: opts.ignoreStories ?? accountInfo.config.ignoreStories,
|
||||
sendReadReceipts:
|
||||
opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts,
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
@@ -357,7 +360,12 @@ export async function monitorSignalProvider(
|
||||
"Ask the bot owner to approve with:",
|
||||
"clawdbot pairing approve --provider signal <code>",
|
||||
].join("\n"),
|
||||
{ baseUrl, account, maxBytes: mediaMaxBytes },
|
||||
{
|
||||
baseUrl,
|
||||
account,
|
||||
maxBytes: mediaMaxBytes,
|
||||
accountId: accountInfo.accountId,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
@@ -447,6 +455,7 @@ export async function monitorSignalProvider(
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "signal",
|
||||
accountId: accountInfo.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: isGroup ? (groupId ?? "unknown") : normalizeE164(sender),
|
||||
@@ -505,6 +514,7 @@ export async function monitorSignalProvider(
|
||||
target: ctxPayload.To,
|
||||
baseUrl,
|
||||
account,
|
||||
accountId: accountInfo.accountId,
|
||||
runtime,
|
||||
maxBytes: mediaMaxBytes,
|
||||
textLimit,
|
||||
|
||||
@@ -2,11 +2,13 @@ import { loadConfig } from "../config/config.js";
|
||||
import { mediaKindFromMime } from "../media/constants.js";
|
||||
import { saveMediaBuffer } from "../media/store.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { signalRpcRequest } from "./client.js";
|
||||
|
||||
export type SignalSendOpts = {
|
||||
baseUrl?: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
maxBytes?: number;
|
||||
timeoutMs?: number;
|
||||
@@ -22,23 +24,6 @@ type SignalTarget =
|
||||
| { type: "group"; groupId: string }
|
||||
| { type: "username"; username: string };
|
||||
|
||||
function resolveBaseUrl(explicit?: string): string {
|
||||
const cfg = loadConfig();
|
||||
const signalCfg = cfg.signal;
|
||||
if (explicit?.trim()) return explicit.trim();
|
||||
if (signalCfg?.httpUrl?.trim()) return signalCfg.httpUrl.trim();
|
||||
const host = signalCfg?.httpHost?.trim() || "127.0.0.1";
|
||||
const port = signalCfg?.httpPort ?? 8080;
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
function resolveAccount(explicit?: string): string | undefined {
|
||||
const cfg = loadConfig();
|
||||
const signalCfg = cfg.signal;
|
||||
const account = explicit?.trim() || signalCfg?.account?.trim();
|
||||
return account || undefined;
|
||||
}
|
||||
|
||||
function parseTarget(raw: string): SignalTarget {
|
||||
let value = raw.trim();
|
||||
if (!value) throw new Error("Signal recipient is required");
|
||||
@@ -81,11 +66,25 @@ export async function sendMessageSignal(
|
||||
text: string,
|
||||
opts: SignalSendOpts = {},
|
||||
): Promise<SignalSendResult> {
|
||||
const baseUrl = resolveBaseUrl(opts.baseUrl);
|
||||
const account = resolveAccount(opts.account);
|
||||
const cfg = loadConfig();
|
||||
const accountInfo = resolveSignalAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const baseUrl = opts.baseUrl?.trim() || accountInfo.baseUrl;
|
||||
const account = opts.account?.trim() || accountInfo.config.account?.trim();
|
||||
const target = parseTarget(to);
|
||||
let message = text ?? "";
|
||||
const maxBytes = opts.maxBytes ?? 8 * 1024 * 1024;
|
||||
const maxBytes = (() => {
|
||||
if (typeof opts.maxBytes === "number") return opts.maxBytes;
|
||||
if (typeof accountInfo.config.mediaMaxMb === "number") {
|
||||
return accountInfo.config.mediaMaxMb * 1024 * 1024;
|
||||
}
|
||||
if (typeof cfg.agent?.mediaMaxMb === "number") {
|
||||
return cfg.agent.mediaMaxMb * 1024 * 1024;
|
||||
}
|
||||
return 8 * 1024 * 1024;
|
||||
})();
|
||||
|
||||
let attachments: string[] | undefined;
|
||||
if (opts.mediaUrl?.trim()) {
|
||||
|
||||
Reference in New Issue
Block a user