feat: add providers CLI and multi-account onboarding

This commit is contained in:
Peter Steinberger
2026-01-08 01:18:37 +01:00
parent 6b3ed40d0f
commit 05b8679c8b
54 changed files with 4399 additions and 1448 deletions

74
src/imessage/accounts.ts Normal file
View File

@@ -0,0 +1,74 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { IMessageAccountConfig } from "../config/types.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../routing/session-key.js";
export type ResolvedIMessageAccount = {
accountId: string;
enabled: boolean;
name?: string;
config: IMessageAccountConfig;
};
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = cfg.imessage?.accounts;
if (!accounts || typeof accounts !== "object") return [];
return Object.keys(accounts).filter(Boolean);
}
export function listIMessageAccountIds(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 resolveDefaultIMessageAccountId(cfg: ClawdbotConfig): string {
const ids = listIMessageAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): IMessageAccountConfig | undefined {
const accounts = cfg.imessage?.accounts;
if (!accounts || typeof accounts !== "object") return undefined;
return accounts[accountId] as IMessageAccountConfig | undefined;
}
function mergeIMessageAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): IMessageAccountConfig {
const { accounts: _ignored, ...base } = (cfg.imessage ??
{}) as IMessageAccountConfig & { accounts?: unknown };
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account };
}
export function resolveIMessageAccount(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}): ResolvedIMessageAccount {
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = params.cfg.imessage?.enabled !== false;
const merged = mergeIMessageAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
return {
accountId,
enabled: baseEnabled && accountEnabled,
name: merged.name?.trim() || undefined,
config: merged,
};
}
export function listEnabledIMessageAccounts(
cfg: ClawdbotConfig,
): ResolvedIMessageAccount[] {
return listIMessageAccountIds(cfg)
.map((accountId) => resolveIMessageAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View File

@@ -8,6 +8,7 @@ import {
} from "../auto-reply/reply/mentions.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 {
resolveProviderGroupPolicy,
@@ -22,6 +23,7 @@ import {
} from "../pairing/pairing-store.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveIMessageAccount } from "./accounts.js";
import { createIMessageRpcClient } from "./client.js";
import { sendMessageIMessage } from "./send.js";
import {
@@ -56,6 +58,8 @@ export type MonitorIMessageOpts = {
abortSignal?: AbortSignal;
cliPath?: string;
dbPath?: string;
accountId?: string;
config?: ClawdbotConfig;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
includeAttachments?: boolean;
@@ -75,32 +79,21 @@ function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv {
);
}
function resolveAllowFrom(opts: MonitorIMessageOpts): string[] {
const cfg = loadConfig();
const raw = opts.allowFrom ?? cfg.imessage?.allowFrom ?? [];
return raw.map((entry) => String(entry).trim()).filter(Boolean);
}
function resolveGroupAllowFrom(opts: MonitorIMessageOpts): string[] {
const cfg = loadConfig();
const raw =
opts.groupAllowFrom ??
cfg.imessage?.groupAllowFrom ??
(cfg.imessage?.allowFrom && cfg.imessage.allowFrom.length > 0
? cfg.imessage.allowFrom
: []);
return raw.map((entry) => String(entry).trim()).filter(Boolean);
function normalizeAllowList(list?: Array<string | number>) {
return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean);
}
async function deliverReplies(params: {
replies: ReplyPayload[];
target: string;
client: Awaited<ReturnType<typeof createIMessageRpcClient>>;
accountId?: string;
runtime: RuntimeEnv;
maxBytes: number;
textLimit: number;
}) {
const { replies, target, client, runtime, maxBytes, textLimit } = params;
const { replies, target, client, runtime, maxBytes, textLimit, accountId } =
params;
for (const payload of replies) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
@@ -108,7 +101,11 @@ async function deliverReplies(params: {
if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) {
for (const chunk of chunkText(text, textLimit)) {
await sendMessageIMessage(target, chunk, { maxBytes, client });
await sendMessageIMessage(target, chunk, {
maxBytes,
client,
accountId,
});
}
} else {
let first = true;
@@ -119,6 +116,7 @@ async function deliverReplies(params: {
mediaUrl: url,
maxBytes,
client,
accountId,
});
}
}
@@ -130,17 +128,32 @@ export async function monitorIMessageProvider(
opts: MonitorIMessageOpts = {},
): Promise<void> {
const runtime = resolveRuntime(opts);
const cfg = loadConfig();
const textLimit = resolveTextChunkLimit(cfg, "imessage");
const allowFrom = resolveAllowFrom(opts);
const groupAllowFrom = resolveGroupAllowFrom(opts);
const groupPolicy = cfg.imessage?.groupPolicy ?? "open";
const dmPolicy = cfg.imessage?.dmPolicy ?? "pairing";
const cfg = opts.config ?? loadConfig();
const accountInfo = resolveIMessageAccount({
cfg,
accountId: opts.accountId,
});
const imessageCfg = accountInfo.config;
const textLimit = resolveTextChunkLimit(
cfg,
"imessage",
accountInfo.accountId,
);
const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom);
const groupAllowFrom = normalizeAllowList(
opts.groupAllowFrom ??
imessageCfg.groupAllowFrom ??
(imessageCfg.allowFrom && imessageCfg.allowFrom.length > 0
? imessageCfg.allowFrom
: []),
);
const groupPolicy = imessageCfg.groupPolicy ?? "open";
const dmPolicy = imessageCfg.dmPolicy ?? "pairing";
const mentionRegexes = buildMentionRegexes(cfg);
const includeAttachments =
opts.includeAttachments ?? cfg.imessage?.includeAttachments ?? false;
opts.includeAttachments ?? imessageCfg.includeAttachments ?? false;
const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.imessage?.mediaMaxMb ?? 16) * 1024 * 1024;
(opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024;
const handleMessage = async (raw: unknown) => {
const params = raw as { message?: IMessagePayload | null };
@@ -202,6 +215,7 @@ export async function monitorIMessageProvider(
const groupListPolicy = resolveProviderGroupPolicy({
cfg,
provider: "imessage",
accountId: accountInfo.accountId,
groupId,
});
if (groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) {
@@ -254,6 +268,7 @@ export async function monitorIMessageProvider(
{
client,
maxBytes: mediaMaxBytes,
accountId: accountInfo.accountId,
...(chatId ? { chatId } : {}),
},
);
@@ -279,6 +294,7 @@ export async function monitorIMessageProvider(
const requireMention = resolveProviderGroupRequireMention({
cfg,
provider: "imessage",
accountId: accountInfo.accountId,
groupId,
requireMentionOverride: opts.requireMention,
overrideOrder: "before-config",
@@ -344,6 +360,7 @@ export async function monitorIMessageProvider(
const route = resolveAgentRoute({
cfg,
provider: "imessage",
accountId: accountInfo.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: isGroup
@@ -410,6 +427,7 @@ export async function monitorIMessageProvider(
replies: [payload],
target: ctxPayload.To,
client,
accountId: accountInfo.accountId,
runtime,
maxBytes: mediaMaxBytes,
textLimit,
@@ -431,8 +449,8 @@ export async function monitorIMessageProvider(
};
const client = await createIMessageRpcClient({
cliPath: opts.cliPath ?? cfg.imessage?.cliPath,
dbPath: opts.dbPath ?? cfg.imessage?.dbPath,
cliPath: opts.cliPath ?? imessageCfg.cliPath,
dbPath: opts.dbPath ?? imessageCfg.dbPath,
runtime,
onNotification: (msg) => {
if (msg.method === "message") {

View File

@@ -2,6 +2,7 @@ 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 { resolveIMessageAccount } from "./accounts.js";
import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
import {
formatIMessageChatTarget,
@@ -14,6 +15,7 @@ export type IMessageSendOpts = {
dbPath?: string;
service?: IMessageService;
region?: string;
accountId?: string;
mediaUrl?: string;
maxBytes?: number;
timeoutMs?: number;
@@ -25,28 +27,6 @@ export type IMessageSendResult = {
messageId: string;
};
function resolveCliPath(explicit?: string): string {
const cfg = loadConfig();
return explicit?.trim() || cfg.imessage?.cliPath?.trim() || "imsg";
}
function resolveDbPath(explicit?: string): string | undefined {
const cfg = loadConfig();
return explicit?.trim() || cfg.imessage?.dbPath?.trim() || undefined;
}
function resolveService(explicit?: IMessageService): IMessageService {
const cfg = loadConfig();
return (
explicit || (cfg.imessage?.service as IMessageService | undefined) || "auto"
);
}
function resolveRegion(explicit?: string): string {
const cfg = loadConfig();
return explicit?.trim() || cfg.imessage?.region?.trim() || "US";
}
async function resolveAttachment(
mediaUrl: string,
maxBytes: number,
@@ -66,15 +46,28 @@ export async function sendMessageIMessage(
text: string,
opts: IMessageSendOpts = {},
): Promise<IMessageSendResult> {
const cliPath = resolveCliPath(opts.cliPath);
const dbPath = resolveDbPath(opts.dbPath);
const cfg = loadConfig();
const account = resolveIMessageAccount({
cfg,
accountId: opts.accountId,
});
const cliPath =
opts.cliPath?.trim() || account.config.cliPath?.trim() || "imsg";
const dbPath = opts.dbPath?.trim() || account.config.dbPath?.trim();
const target = parseIMessageTarget(
opts.chatId ? formatIMessageChatTarget(opts.chatId) : to,
);
const service =
opts.service ?? (target.kind === "handle" ? target.service : undefined);
const region = resolveRegion(opts.region);
const maxBytes = opts.maxBytes ?? 16 * 1024 * 1024;
opts.service ??
(target.kind === "handle" ? target.service : undefined) ??
(account.config.service as IMessageService | undefined);
const region = opts.region?.trim() || account.config.region?.trim() || "US";
const maxBytes =
typeof opts.maxBytes === "number"
? opts.maxBytes
: typeof account.config.mediaMaxMb === "number"
? account.config.mediaMaxMb * 1024 * 1024
: 16 * 1024 * 1024;
let message = text ?? "";
let filePath: string | undefined;
@@ -94,7 +87,7 @@ export async function sendMessageIMessage(
const params: Record<string, unknown> = {
text: message,
service: resolveService(service),
service: (service || "auto") as IMessageService,
region,
};
if (filePath) params.file = filePath;