diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index f61e06004..700ea90fd 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -260,6 +260,33 @@ Notes: - Outbound commands default to account `default` if present; otherwise the first configured account id (sorted). - The legacy single-account Baileys auth dir is migrated by `clawdbot doctor` into `whatsapp/default`. +### `telegram.accounts` / `discord.accounts` / `slack.accounts` / `signal.accounts` / `imessage.accounts` + +Run multiple accounts per provider (each account has its own `accountId` and optional `name`): + +```json5 +{ + telegram: { + accounts: { + default: { + name: "Primary bot", + botToken: "123456:ABC..." + }, + alerts: { + name: "Alerts bot", + botToken: "987654:XYZ..." + } + } + } +} +``` + +Notes: +- `default` is used when `accountId` is omitted (CLI + routing). +- Env tokens only apply to the **default** account. +- Base provider settings (group policy, mention gating, etc.) apply to all accounts unless overridden per account. +- Use `routing.bindings[].match.accountId` to route each account to a different agent. + ### `routing.groupChat` Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats. @@ -560,6 +587,7 @@ Set `web.enabled: false` to keep it off by default. Clawdbot starts Telegram only when a `telegram` config section exists. The bot token is resolved from `TELEGRAM_BOT_TOKEN` or `telegram.botToken`. Set `telegram.enabled: false` to disable automatic startup. +Multi-account support lives under `telegram.accounts` (see the multi-account section above). Env tokens only apply to the default account. ```json5 { @@ -609,6 +637,7 @@ Retry policy defaults and behavior are documented in [Retry policy](/concepts/re ### `discord` (bot transport) Configure the Discord bot by setting the bot token and optional gating: +Multi-account support lives under `discord.accounts` (see the multi-account section above). Env tokens only apply to the default account. ```json5 { @@ -728,6 +757,8 @@ Slack runs in Socket Mode and requires both a bot token and app token: } ``` +Multi-account support lives under `slack.accounts` (see the multi-account section above). Env tokens only apply to the default account. + Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:` (DM) or `channel:` when specifying delivery targets for cron/CLI commands. Reaction notification modes: @@ -764,10 +795,19 @@ Clawdbot spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required. } ``` +Multi-account support lives under `imessage.accounts` (see the multi-account section above). + Notes: - Requires Full Disk Access to the Messages DB. - The first send will prompt for Messages automation permission. - Prefer `chat_id:` targets. Use `imsg chats --limit 20` to list chats. +- `imessage.cliPath` can point to a wrapper script (e.g. `ssh` to another Mac that runs `imsg rpc`); use SSH keys to avoid password prompts. + +Example wrapper: +```bash +#!/usr/bin/env bash +exec ssh -T mac-mini "imsg rpc" +``` ### `agent.workspace` diff --git a/docs/providers/discord.md b/docs/providers/discord.md index 4d5d652c4..383916f63 100644 --- a/docs/providers/discord.md +++ b/docs/providers/discord.md @@ -106,6 +106,8 @@ Or via config: } ``` +Multi-account support: use `discord.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. + #### Allowlist + channel routing Example “single server, only allow me, only allow #help”: diff --git a/docs/providers/imessage.md b/docs/providers/imessage.md index 3f031d5f1..c045d7458 100644 --- a/docs/providers/imessage.md +++ b/docs/providers/imessage.md @@ -19,11 +19,28 @@ Status: external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio - macOS with Messages signed in. - Full Disk Access for Clawdbot + `imsg` (Messages DB access). - Automation permission when sending. + - `imessage.cliPath` can point to a wrapper script (for example, an SSH hop to another Mac that runs `imsg rpc`). ## Setup (fast path) 1) Ensure Messages is signed in on this Mac. 2) Configure iMessage and start the gateway. +### Remote/SSH variant (optional) +If you want iMessage on another Mac, set `imessage.cliPath` to a wrapper that +execs `ssh` and runs `imsg rpc` on the remote host. Clawdbot only needs a +stdio stream; `imsg` still runs on the remote macOS host. + +Example wrapper (save somewhere in your PATH and `chmod +x`): +```bash +#!/usr/bin/env bash +exec ssh -T mac-mini "imsg rpc" +``` + +Notes: +- Remote Mac must have Messages signed in and `imsg` installed. +- Full Disk Access + Automation prompts happen on the remote Mac. +- Use SSH keys (no password prompt) so the gateway can launch `imsg rpc` unattended. + Example: ```json5 { @@ -36,6 +53,8 @@ Example: } ``` +Multi-account support: use `imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. + ## Access control (DMs + groups) DMs: - Default: `imessage.dmPolicy = "pairing"`. diff --git a/docs/providers/signal.md b/docs/providers/signal.md index e83994e61..e41660a59 100644 --- a/docs/providers/signal.md +++ b/docs/providers/signal.md @@ -39,6 +39,8 @@ Example: } ``` +Multi-account support: use `signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. + ## Access control (DMs + groups) DMs: - Default: `signal.dmPolicy = "pairing"`. diff --git a/docs/providers/slack.md b/docs/providers/slack.md index 3b8e1ca5b..23c203a9b 100644 --- a/docs/providers/slack.md +++ b/docs/providers/slack.md @@ -22,6 +22,8 @@ read_when: "Setting up Slack or debugging Slack socket mode" Use the manifest below so scopes and events stay in sync. +Multi-account support: use `slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. + ## Manifest (optional) Use this Slack app manifest to create the app quickly (adjust the name/command if you want). diff --git a/docs/providers/telegram.md b/docs/providers/telegram.md index a5a7aca3e..a3cc5837f 100644 --- a/docs/providers/telegram.md +++ b/docs/providers/telegram.md @@ -29,6 +29,8 @@ Status: production-ready for bot DMs + groups via grammY. Long-polling by defaul } ``` +Multi-account support: use `telegram.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. + 3) Start the gateway. Telegram starts when a `telegram` config section exists and a token is resolved. 4) DM access defaults to pairing. Approve the code when the bot is first contacted. 5) For groups: add the bot, disable privacy mode (or make it admin), then set `telegram.groups` to control mention gating + allowlists. diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index a6218fbfa..f4462cfc9 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -85,6 +85,20 @@ describe("resolveTextChunkLimit", () => { expect(resolveTextChunkLimit(cfg, "telegram")).toBe(1234); }); + it("prefers account overrides when provided", () => { + const cfg = { + telegram: { + textChunkLimit: 2000, + accounts: { + default: { textChunkLimit: 1234 }, + primary: { textChunkLimit: 777 }, + }, + }, + }; + expect(resolveTextChunkLimit(cfg, "telegram", "primary")).toBe(777); + expect(resolveTextChunkLimit(cfg, "telegram", "default")).toBe(1234); + }); + it("uses the matching provider override", () => { const cfg = { discord: { textChunkLimit: 111 }, diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index fb2174d1d..b362eab2f 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -8,6 +8,7 @@ import { isSafeFenceBreak, parseFenceSpans, } from "../markdown/fences.js"; +import { normalizeAccountId } from "../routing/session-key.js"; export type TextChunkProvider = | "whatsapp" @@ -31,15 +32,44 @@ const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record = { export function resolveTextChunkLimit( cfg: ClawdbotConfig | undefined, provider?: TextChunkProvider, + accountId?: string | null, ): number { const providerOverride = (() => { if (!provider) return undefined; - if (provider === "whatsapp") return cfg?.whatsapp?.textChunkLimit; - if (provider === "telegram") return cfg?.telegram?.textChunkLimit; - if (provider === "discord") return cfg?.discord?.textChunkLimit; - if (provider === "slack") return cfg?.slack?.textChunkLimit; - if (provider === "signal") return cfg?.signal?.textChunkLimit; - if (provider === "imessage") return cfg?.imessage?.textChunkLimit; + const normalizedAccountId = normalizeAccountId(accountId); + if (provider === "whatsapp") { + return cfg?.whatsapp?.textChunkLimit; + } + if (provider === "telegram") { + return ( + cfg?.telegram?.accounts?.[normalizedAccountId]?.textChunkLimit ?? + cfg?.telegram?.textChunkLimit + ); + } + if (provider === "discord") { + return ( + cfg?.discord?.accounts?.[normalizedAccountId]?.textChunkLimit ?? + cfg?.discord?.textChunkLimit + ); + } + if (provider === "slack") { + return ( + cfg?.slack?.accounts?.[normalizedAccountId]?.textChunkLimit ?? + cfg?.slack?.textChunkLimit + ); + } + if (provider === "signal") { + return ( + cfg?.signal?.accounts?.[normalizedAccountId]?.textChunkLimit ?? + cfg?.signal?.textChunkLimit + ); + } + if (provider === "imessage") { + return ( + cfg?.imessage?.accounts?.[normalizedAccountId]?.textChunkLimit ?? + cfg?.imessage?.textChunkLimit + ); + } return undefined; })(); if (typeof providerOverride === "number" && providerOverride > 0) { diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 980b2bc0c..c02ce1c77 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -85,6 +85,7 @@ export async function routeReply( mediaUrl, messageThreadId: threadId, replyToMessageId: resolvedReplyToMessageId, + accountId, }); return { ok: true, messageId: result.messageId }; } @@ -93,6 +94,7 @@ export async function routeReply( const result = await sendMessageSlack(to, text, { mediaUrl, threadTs: replyToId, + accountId, }); return { ok: true, messageId: result.messageId }; } @@ -101,17 +103,24 @@ export async function routeReply( const result = await sendMessageDiscord(to, text, { mediaUrl, replyTo: replyToId, + accountId, }); return { ok: true, messageId: result.messageId }; } case "signal": { - const result = await sendMessageSignal(to, text, { mediaUrl }); + const result = await sendMessageSignal(to, text, { + mediaUrl, + accountId, + }); return { ok: true, messageId: result.messageId }; } case "imessage": { - const result = await sendMessageIMessage(to, text, { mediaUrl }); + const result = await sendMessageIMessage(to, text, { + mediaUrl, + accountId, + }); return { ok: true, messageId: result.messageId }; } diff --git a/src/cli/program.ts b/src/cli/program.ts index bf315f06f..d952b907e 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -42,6 +42,7 @@ import { registerModelsCli } from "./models-cli.js"; import { registerNodesCli } from "./nodes-cli.js"; import { registerPairingCli } from "./pairing-cli.js"; import { forceFreePort } from "./ports.js"; +import { registerProvidersCli } from "./providers-cli.js"; import { registerTelegramCli } from "./telegram-cli.js"; import { registerTuiCli } from "./tui-cli.js"; @@ -637,6 +638,7 @@ Examples: registerDocsCli(program); registerHooksCli(program); registerPairingCli(program); + registerProvidersCli(program); registerTelegramCli(program); program diff --git a/src/cli/providers-cli.ts b/src/cli/providers-cli.ts new file mode 100644 index 000000000..5e2db2e5b --- /dev/null +++ b/src/cli/providers-cli.ts @@ -0,0 +1,130 @@ +import type { Command } from "commander"; + +import { + providersAddCommand, + providersListCommand, + providersRemoveCommand, + providersStatusCommand, +} from "../commands/providers.js"; +import { defaultRuntime } from "../runtime.js"; + +const optionNamesAdd = [ + "provider", + "account", + "name", + "token", + "tokenFile", + "botToken", + "appToken", + "signalNumber", + "cliPath", + "dbPath", + "service", + "region", + "authDir", + "httpUrl", + "httpHost", + "httpPort", + "useEnv", +] as const; + +const optionNamesRemove = ["provider", "account", "delete"] as const; + +function hasExplicitOptions( + command: Command, + names: readonly string[], +): boolean { + return names.some((name) => { + if (typeof command.getOptionValueSource !== "function") { + return false; + } + return command.getOptionValueSource(name) === "cli"; + }); +} + +export function registerProvidersCli(program: Command) { + const providers = program + .command("providers") + .alias("provider") + .description("Manage chat provider accounts"); + + providers + .command("list") + .description("List configured providers + auth profiles") + .option("--json", "Output JSON", false) + .action(async (opts) => { + try { + await providersListCommand(opts, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + providers + .command("status") + .description("Show gateway provider status") + .option("--probe", "Probe provider credentials", false) + .option("--timeout ", "Timeout in ms", "10000") + .option("--json", "Output JSON", false) + .action(async (opts) => { + try { + await providersStatusCommand(opts, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + providers + .command("add") + .description("Add or update a provider account") + .option( + "--provider ", + "Provider (whatsapp|telegram|discord|slack|signal|imessage)", + ) + .option("--account ", "Account id (default when omitted)") + .option("--name ", "Display name for this account") + .option("--token ", "Bot token (Telegram/Discord)") + .option("--token-file ", "Bot token file (Telegram)") + .option("--bot-token ", "Slack bot token (xoxb-...)") + .option("--app-token ", "Slack app token (xapp-...)") + .option("--signal-number ", "Signal account number (E.164)") + .option("--cli-path ", "CLI path (signal-cli or imsg)") + .option("--db-path ", "iMessage database path") + .option("--service ", "iMessage service (imessage|sms|auto)") + .option("--region ", "iMessage region (for SMS)") + .option("--auth-dir ", "WhatsApp auth directory override") + .option("--http-url ", "Signal HTTP daemon base URL") + .option("--http-host ", "Signal HTTP host") + .option("--http-port ", "Signal HTTP port") + .option("--use-env", "Use env token (default account only)", false) + .action(async (opts, command) => { + try { + const hasFlags = hasExplicitOptions(command, optionNamesAdd); + await providersAddCommand(opts, defaultRuntime, { hasFlags }); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + providers + .command("remove") + .description("Disable or delete a provider account") + .option( + "--provider ", + "Provider (whatsapp|telegram|discord|slack|signal|imessage)", + ) + .option("--account ", "Account id (default when omitted)") + .option("--delete", "Delete config entries (no prompt)", false) + .action(async (opts, command) => { + try { + const hasFlags = hasExplicitOptions(command, optionNamesRemove); + await providersRemoveCommand(opts, defaultRuntime, { hasFlags }); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); +} diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 8383190f5..e7d647c16 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -266,7 +266,7 @@ describe("agentCommand", () => { }); }); - it("passes telegram token when delivering", async () => { + it("passes telegram account id when delivering", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); mockConfig(home, store, undefined, undefined, { botToken: "t-1" }); @@ -297,7 +297,7 @@ describe("agentCommand", () => { expect(deps.sendMessageTelegram).toHaveBeenCalledWith( "123", "ok", - expect.objectContaining({ token: "t-1" }), + expect.objectContaining({ accountId: "default", verbose: false }), ); } finally { if (prevTelegramToken === undefined) { diff --git a/src/commands/agents.ts b/src/commands/agents.ts index 76cbc0139..9a8aefac2 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -322,17 +322,18 @@ function buildProviderBindings(params: { agentId: string; selection: ProviderChoice[]; config: ClawdbotConfig; - whatsappAccountId?: string; + accountIds?: Partial>; }): AgentBinding[] { const bindings: AgentBinding[] = []; const agentId = normalizeAgentId(params.agentId); for (const provider of params.selection) { const match: AgentBinding["match"] = { provider }; - if (provider === "whatsapp") { - const accountId = - params.whatsappAccountId?.trim() || - resolveDefaultWhatsAppAccountId(params.config); - match.accountId = accountId || DEFAULT_ACCOUNT_ID; + const accountId = params.accountIds?.[provider]?.trim(); + if (accountId) { + match.accountId = accountId; + } else if (provider === "whatsapp") { + const defaultId = resolveDefaultWhatsAppAccountId(params.config); + match.accountId = defaultId || DEFAULT_ACCOUNT_ID; } bindings.push({ agentId, match }); } @@ -493,15 +494,15 @@ export async function agentsAddCommand( }); let selection: ProviderChoice[] = []; - let whatsappAccountId: string | undefined; + const providerAccountIds: Partial> = {}; nextConfig = await setupProviders(nextConfig, runtime, prompter, { allowSignalInstall: true, onSelection: (value) => { selection = value; }, - promptWhatsAppAccountId: true, - onWhatsAppAccountId: (value) => { - whatsappAccountId = value; + promptAccountIds: true, + onAccountId: (provider, accountId) => { + providerAccountIds[provider] = accountId; }, }); @@ -516,7 +517,7 @@ export async function agentsAddCommand( agentId, selection, config: nextConfig, - whatsappAccountId, + accountIds: providerAccountIds, }); const result = applyAgentBindings(nextConfig, desiredBindings); nextConfig = result.config; diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index db4636487..8bbe596d9 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -44,13 +44,17 @@ function normalizeDefaultWorkspacePath( return next === resolved ? value : next; } -export function replaceLegacyName(value: string | undefined): string | undefined { +export function replaceLegacyName( + value: string | undefined, +): string | undefined { if (!value) return value; const replacedClawdis = value.replace(/clawdis/g, "clawdbot"); return replacedClawdis.replace(/clawd(?!bot)/g, "clawdbot"); } -export function replaceModernName(value: string | undefined): string | undefined { +export function replaceModernName( + value: string | undefined, +): string | undefined { if (!value) return value; if (!value.includes("clawdbot")) return value; return value.replace(/clawdbot/g, "clawdis"); diff --git a/src/commands/doctor-sandbox.ts b/src/commands/doctor-sandbox.ts index 38a44b47e..23289591a 100644 --- a/src/commands/doctor-sandbox.ts +++ b/src/commands/doctor-sandbox.ts @@ -11,8 +11,8 @@ import { import type { ClawdbotConfig } from "../config/config.js"; import { runCommandWithTimeout, runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; -import type { DoctorPrompter } from "./doctor-prompter.js"; import { replaceModernName } from "./doctor-legacy-config.js"; +import type { DoctorPrompter } from "./doctor-prompter.js"; type SandboxScriptInfo = { scriptPath: string; diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index 4b3a491ce..49ad12520 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -257,8 +257,10 @@ export async function noteStateIntegrity( const recent = entries .slice() .sort((a, b) => { - const aUpdated = typeof a[1].updatedAt === "number" ? a[1].updatedAt : 0; - const bUpdated = typeof b[1].updatedAt === "number" ? b[1].updatedAt : 0; + const aUpdated = + typeof a[1].updatedAt === "number" ? a[1].updatedAt : 0; + const bUpdated = + typeof b[1].updatedAt === "number" ? b[1].updatedAt : 0; return bUpdated - aUpdated; }) .slice(0, 5); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 40b824d16..93115377a 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -13,28 +13,25 @@ import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js"; -import { - maybeMigrateLegacyConfigFile, - normalizeLegacyConfigValues, -} from "./doctor-legacy-config.js"; import { maybeMigrateLegacyGatewayService, maybeScanExtraGatewayServices, } from "./doctor-gateway-services.js"; import { - createDoctorPrompter, - type DoctorOptions, -} from "./doctor-prompter.js"; + maybeMigrateLegacyConfigFile, + normalizeLegacyConfigValues, +} from "./doctor-legacy-config.js"; +import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js"; import { maybeRepairSandboxImages } from "./doctor-sandbox.js"; import { noteSecurityWarnings } from "./doctor-security.js"; -import { - detectLegacyStateMigrations, - runLegacyStateMigrations, -} from "./doctor-state-migrations.js"; import { noteStateIntegrity, noteWorkspaceBackupTip, } from "./doctor-state-integrity.js"; +import { + detectLegacyStateMigrations, + runLegacyStateMigrations, +} from "./doctor-state-migrations.js"; import { MEMORY_SYSTEM_PROMPT, shouldSuggestMemorySystem, diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index 65679b18d..cd3f6d89d 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -2,13 +2,38 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { ClawdbotConfig } from "../config/config.js"; import type { DmPolicy } from "../config/types.js"; +import { + listDiscordAccountIds, + resolveDefaultDiscordAccountId, + resolveDiscordAccount, +} from "../discord/accounts.js"; +import { + listIMessageAccountIds, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, +} from "../imessage/accounts.js"; import { loginWeb } from "../provider-web.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId, } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; -import { normalizeE164 } from "../utils.js"; +import { + listSignalAccountIds, + resolveDefaultSignalAccountId, + resolveSignalAccount, +} from "../signal/accounts.js"; +import { + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, +} from "../slack/accounts.js"; +import { + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, +} from "../telegram/accounts.js"; +import { formatTerminalLink, normalizeE164 } from "../utils.js"; import { listWhatsAppAccountIds, resolveDefaultWhatsAppAccountId, @@ -19,6 +44,53 @@ import { detectBinary } from "./onboard-helpers.js"; import type { ProviderChoice } from "./onboard-types.js"; import { installSignalCli } from "./signal-install.js"; +const DOCS_BASE = "https://docs.clawd.bot"; + +function docsLink(path: string, label?: string): string { + const cleanPath = path.startsWith("/") ? path : `/${path}`; + const url = `${DOCS_BASE}${cleanPath}`; + return formatTerminalLink(label ?? url, url, { fallback: url }); +} + +async function promptAccountId(params: { + cfg: ClawdbotConfig; + prompter: WizardPrompter; + label: string; + currentId?: string; + listAccountIds: (cfg: ClawdbotConfig) => string[]; + defaultAccountId: string; +}): Promise { + const existingIds = params.listAccountIds(params.cfg); + const initial = + params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID; + const choice = (await params.prompter.select({ + message: `${params.label} account`, + options: [ + ...existingIds.map((id) => ({ + value: id, + label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id, + })), + { value: "__new__", label: "Add a new account" }, + ], + initialValue: initial, + })) as string; + + if (choice !== "__new__") return normalizeAccountId(choice); + + const entered = await params.prompter.text({ + message: `New ${params.label} account id`, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + const normalized = normalizeAccountId(String(entered)); + if (String(entered).trim() !== normalized) { + await params.prompter.note( + `Normalized account id to "${normalized}".`, + `${params.label} account`, + ); + } + return normalized; +} + function addWildcardAllowFrom( allowFrom?: Array | null, ): Array { @@ -51,13 +123,13 @@ async function noteProviderPrimer(prompter: WizardPrompter): Promise { "DM security: default is pairing; unknown DMs get a pairing code.", "Approve with: clawdbot pairing approve --provider ", 'Public DMs require dmPolicy="open" + allowFrom=["*"].', - "Docs: https://docs.clawd.bot/start/pairing", + `Docs: ${docsLink("/start/pairing", "start/pairing")}`, "", - "Telegram: easiest start — register a bot with @BotFather, paste token, go.", + "Telegram: simplest way to get started — register a bot with @BotFather and get going.", "WhatsApp: works with your own number; recommend a separate phone + eSIM.", "Discord: very well supported right now.", "Slack: supported (Socket Mode).", - "Signal: signal-cli linked device; more setup (if you want easy, hop on Discord).", + 'Signal: signal-cli linked device; more setup (David Reagans: "Hop on Discord.").', "iMessage: this is still a work in progress.", ].join("\n"), "How providers work", @@ -71,7 +143,7 @@ async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise { "2) Run /newbot (or /mybots)", "3) Copy the token (looks like 123456:ABC...)", "Tip: you can also set TELEGRAM_BOT_TOKEN in your env.", - "Docs: https://docs.clawd.bot/telegram", + `Docs: ${docsLink("/telegram", "telegram")}`, ].join("\n"), "Telegram bot token", ); @@ -84,7 +156,7 @@ async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise { "2) Bot → Add Bot → Reset Token → copy token", "3) OAuth2 → URL Generator → scope 'bot' → invite to your server", "Tip: enable Message Content Intent if you need message text.", - "Docs: https://docs.clawd.bot/discord", + `Docs: ${docsLink("/discord", "discord")}`, ].join("\n"), "Discord bot token", ); @@ -172,7 +244,7 @@ async function noteSlackTokenHelp( "4) Enable Event Subscriptions (socket) for message events", "5) App Home → enable the Messages tab for DMs", "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", - "Docs: https://docs.clawd.bot/slack", + `Docs: ${docsLink("/slack", "slack")}`, "", "Manifest (JSON):", manifest, @@ -345,7 +417,7 @@ async function maybeConfigureDmPolicies(params: { "Default: pairing (unknown DMs get a pairing code).", `Approve: clawdbot pairing approve --provider ${params.provider} `, `Public DMs: ${params.policyKey}="open" + ${params.allowFromKey} includes "*".`, - "Docs: https://docs.clawd.bot/start/pairing", + `Docs: ${docsLink("/start/pairing", "start/pairing")}`, ].join("\n"), `${params.label} DM access`, ); @@ -432,7 +504,7 @@ async function promptWhatsAppAllowFrom( "- disabled: ignore WhatsApp DMs", "", `Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`, - "Docs: https://docs.clawd.bot/whatsapp", + `Docs: ${docsLink("/whatsapp", "whatsapp")}`, ].join("\n"), "WhatsApp DM access", ); @@ -567,6 +639,9 @@ type SetupProvidersOptions = { allowDisable?: boolean; allowSignalInstall?: boolean; onSelection?: (selection: ProviderChoice[]) => void; + accountIds?: Partial>; + onAccountId?: (provider: ProviderChoice, accountId: string) => void; + promptAccountIds?: boolean; whatsappAccountId?: string; promptWhatsAppAccountId?: boolean; onWhatsAppAccountId?: (accountId: string) => void; @@ -585,22 +660,31 @@ export async function setupProviders( const discordEnv = Boolean(process.env.DISCORD_BOT_TOKEN?.trim()); const slackBotEnv = Boolean(process.env.SLACK_BOT_TOKEN?.trim()); const slackAppEnv = Boolean(process.env.SLACK_APP_TOKEN?.trim()); - const telegramConfigured = Boolean( - telegramEnv || cfg.telegram?.botToken || cfg.telegram?.tokenFile, + const telegramConfigured = listTelegramAccountIds(cfg).some((accountId) => + Boolean(resolveTelegramAccount({ cfg, accountId }).token), ); - const discordConfigured = Boolean(discordEnv || cfg.discord?.token); - const slackConfigured = Boolean( - (slackBotEnv && slackAppEnv) || - (cfg.slack?.botToken && cfg.slack?.appToken), + const discordConfigured = listDiscordAccountIds(cfg).some((accountId) => + Boolean(resolveDiscordAccount({ cfg, accountId }).token), ); - const signalConfigured = Boolean( - cfg.signal?.account || cfg.signal?.httpUrl || cfg.signal?.httpPort, + const slackConfigured = listSlackAccountIds(cfg).some((accountId) => { + const account = resolveSlackAccount({ cfg, accountId }); + return Boolean(account.botToken && account.appToken); + }); + const signalConfigured = listSignalAccountIds(cfg).some( + (accountId) => resolveSignalAccount({ cfg, accountId }).configured, ); const signalCliPath = cfg.signal?.cliPath ?? "signal-cli"; const signalCliDetected = await detectBinary(signalCliPath); - const imessageConfigured = Boolean( - cfg.imessage?.cliPath || cfg.imessage?.dbPath || cfg.imessage?.allowFrom, - ); + const imessageConfigured = listIMessageAccountIds(cfg).some((accountId) => { + const account = resolveIMessageAccount({ cfg, accountId }); + return Boolean( + account.config.cliPath || + account.config.dbPath || + account.config.allowFrom || + account.config.service || + account.config.region, + ); + }); const imessageCliPath = cfg.imessage?.cliPath ?? "imsg"; const imessageCliDetected = await detectBinary(imessageCliPath); @@ -635,8 +719,8 @@ export async function setupProviders( value: "telegram", label: "Telegram (Bot API)", hint: telegramConfigured - ? "easy start · configured" - : "easy start · needs token", + ? "recommended · configured" + : "recommended · newcomer-friendly", }, { value: "whatsapp", @@ -667,20 +751,26 @@ export async function setupProviders( })) as ProviderChoice[]; options?.onSelection?.(selection); + const accountOverrides: Partial> = { + ...options?.accountIds, + }; + if (options?.whatsappAccountId?.trim()) { + accountOverrides.whatsapp = options.whatsappAccountId.trim(); + } + const recordAccount = (provider: ProviderChoice, accountId: string) => { + options?.onAccountId?.(provider, accountId); + if (provider === "whatsapp") { + options?.onWhatsAppAccountId?.(accountId); + } + }; const selectionNotes: Record = { - telegram: - "Telegram — easiest start: register a bot with @BotFather and paste the token. Docs: https://docs.clawd.bot/telegram", - whatsapp: - "WhatsApp — works with your own number; recommend a separate phone + eSIM. Docs: https://docs.clawd.bot/whatsapp", - discord: - "Discord — very well supported right now. Docs: https://docs.clawd.bot/discord", - slack: - "Slack — supported (Socket Mode). Docs: https://docs.clawd.bot/slack", - signal: - "Signal — signal-cli linked device; more setup (if you want easy, hop on Discord). Docs: https://docs.clawd.bot/signal", - imessage: - "iMessage — this is still a work in progress. Docs: https://docs.clawd.bot/imessage", + telegram: `Telegram — simplest way to get started: register a bot with @BotFather and get going. Docs: ${docsLink("/telegram", "telegram")}`, + whatsapp: `WhatsApp — works with your own number; recommend a separate phone + eSIM. Docs: ${docsLink("/whatsapp", "whatsapp")}`, + discord: `Discord — very well supported right now. Docs: ${docsLink("/discord", "discord")}`, + slack: `Slack — supported (Socket Mode). Docs: ${docsLink("/slack", "slack")}`, + signal: `Signal — signal-cli linked device; more setup (David Reagans: "Hop on Discord."). Docs: ${docsLink("/signal", "signal")}`, + imessage: `iMessage — this is still a work in progress. Docs: ${docsLink("/imessage", "imessage")}`, }; const selectedLines = selection .map((provider) => selectionNotes[provider]) @@ -689,38 +779,23 @@ export async function setupProviders( await prompter.note(selectedLines.join("\n"), "Selected providers"); } + const shouldPromptAccountIds = options?.promptAccountIds === true; + let next = cfg; if (selection.includes("whatsapp")) { - if (options?.promptWhatsAppAccountId && !options.whatsappAccountId) { - const existingIds = listWhatsAppAccountIds(next); - const choice = (await prompter.select({ - message: "WhatsApp account", - options: [ - ...existingIds.map((id) => ({ - value: id, - label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id, - })), - { value: "__new__", label: "Add a new account" }, - ], - })) as string; - - if (choice === "__new__") { - const entered = await prompter.text({ - message: "New WhatsApp account id", - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - const normalized = normalizeAccountId(String(entered)); - if (String(entered).trim() !== normalized) { - await prompter.note( - `Normalized account id to "${normalized}".`, - "WhatsApp account", - ); - } - whatsappAccountId = normalized; - } else { - whatsappAccountId = choice; - } + const overrideId = accountOverrides.whatsapp?.trim(); + if (overrideId) { + whatsappAccountId = normalizeAccountId(overrideId); + } else if (shouldPromptAccountIds || options?.promptWhatsAppAccountId) { + whatsappAccountId = await promptAccountId({ + cfg: next, + prompter, + label: "WhatsApp", + currentId: whatsappAccountId, + listAccountIds: listWhatsAppAccountIds, + defaultAccountId: resolveDefaultWhatsAppAccountId(next), + }); } if (whatsappAccountId !== DEFAULT_ACCOUNT_ID) { @@ -740,7 +815,7 @@ export async function setupProviders( }; } - options?.onWhatsAppAccountId?.(whatsappAccountId); + recordAccount("whatsapp", whatsappAccountId); whatsappLinked = await detectWhatsAppLinked(next, whatsappAccountId); const { authDir } = resolveWhatsAppAuthDir({ cfg: next, @@ -752,7 +827,7 @@ export async function setupProviders( [ "Scan the QR with WhatsApp on your phone.", `Credentials are stored under ${authDir}/ for future runs.`, - "Docs: https://docs.clawd.bot/whatsapp", + `Docs: ${docsLink("/whatsapp", "whatsapp")}`, ].join("\n"), "WhatsApp linking", ); @@ -769,7 +844,7 @@ export async function setupProviders( } catch (err) { runtime.error(`WhatsApp login failed: ${String(err)}`); await prompter.note( - "Docs: https://docs.clawd.bot/whatsapp", + `Docs: ${docsLink("/whatsapp", "whatsapp")}`, "WhatsApp help", ); } @@ -784,11 +859,39 @@ export async function setupProviders( } if (selection.includes("telegram")) { + const telegramOverride = accountOverrides.telegram?.trim(); + const defaultTelegramAccountId = resolveDefaultTelegramAccountId(next); + let telegramAccountId = telegramOverride + ? normalizeAccountId(telegramOverride) + : defaultTelegramAccountId; + if (shouldPromptAccountIds && !telegramOverride) { + telegramAccountId = await promptAccountId({ + cfg: next, + prompter, + label: "Telegram", + currentId: telegramAccountId, + listAccountIds: listTelegramAccountIds, + defaultAccountId: defaultTelegramAccountId, + }); + } + recordAccount("telegram", telegramAccountId); + + const resolvedAccount = resolveTelegramAccount({ + cfg: next, + accountId: telegramAccountId, + }); + const accountConfigured = Boolean(resolvedAccount.token); + const allowEnv = telegramAccountId === DEFAULT_ACCOUNT_ID; + const canUseEnv = allowEnv && telegramEnv; + const hasConfigToken = Boolean( + resolvedAccount.config.botToken || resolvedAccount.config.tokenFile, + ); + let token: string | null = null; - if (!telegramConfigured) { + if (!accountConfigured) { await noteTelegramTokenHelp(prompter); } - if (telegramEnv && !cfg.telegram?.botToken) { + if (canUseEnv && !resolvedAccount.config.botToken) { const keepEnv = await prompter.confirm({ message: "TELEGRAM_BOT_TOKEN detected. Use env var?", initialValue: true, @@ -809,7 +912,7 @@ export async function setupProviders( }), ).trim(); } - } else if (cfg.telegram?.botToken) { + } else if (hasConfigToken) { const keep = await prompter.confirm({ message: "Telegram token already configured. Keep it?", initialValue: true, @@ -832,23 +935,68 @@ export async function setupProviders( } if (token) { - next = { - ...next, - telegram: { - ...next.telegram, - enabled: true, - botToken: token, - }, - }; + if (telegramAccountId === DEFAULT_ACCOUNT_ID) { + next = { + ...next, + telegram: { + ...next.telegram, + enabled: true, + botToken: token, + }, + }; + } else { + next = { + ...next, + telegram: { + ...next.telegram, + enabled: true, + accounts: { + ...next.telegram?.accounts, + [telegramAccountId]: { + ...next.telegram?.accounts?.[telegramAccountId], + enabled: + next.telegram?.accounts?.[telegramAccountId]?.enabled ?? true, + botToken: token, + }, + }, + }, + }; + } } } if (selection.includes("discord")) { + const discordOverride = accountOverrides.discord?.trim(); + const defaultDiscordAccountId = resolveDefaultDiscordAccountId(next); + let discordAccountId = discordOverride + ? normalizeAccountId(discordOverride) + : defaultDiscordAccountId; + if (shouldPromptAccountIds && !discordOverride) { + discordAccountId = await promptAccountId({ + cfg: next, + prompter, + label: "Discord", + currentId: discordAccountId, + listAccountIds: listDiscordAccountIds, + defaultAccountId: defaultDiscordAccountId, + }); + } + recordAccount("discord", discordAccountId); + + const resolvedAccount = resolveDiscordAccount({ + cfg: next, + accountId: discordAccountId, + }); + const accountConfigured = Boolean(resolvedAccount.token); + const allowEnv = discordAccountId === DEFAULT_ACCOUNT_ID; + const canUseEnv = allowEnv && discordEnv; + const hasConfigToken = Boolean(resolvedAccount.config.token); + let token: string | null = null; - if (!discordConfigured) { + if (!accountConfigured) { await noteDiscordTokenHelp(prompter); } - if (discordEnv && !cfg.discord?.token) { + if (canUseEnv && !resolvedAccount.config.token) { const keepEnv = await prompter.confirm({ message: "DISCORD_BOT_TOKEN detected. Use env var?", initialValue: true, @@ -869,7 +1017,7 @@ export async function setupProviders( }), ).trim(); } - } else if (cfg.discord?.token) { + } else if (hasConfigToken) { const keep = await prompter.confirm({ message: "Discord token already configured. Keep it?", initialValue: true, @@ -892,18 +1040,67 @@ export async function setupProviders( } if (token) { - next = { - ...next, - discord: { - ...next.discord, - enabled: true, - token, - }, - }; + if (discordAccountId === DEFAULT_ACCOUNT_ID) { + next = { + ...next, + discord: { + ...next.discord, + enabled: true, + token, + }, + }; + } else { + next = { + ...next, + discord: { + ...next.discord, + enabled: true, + accounts: { + ...next.discord?.accounts, + [discordAccountId]: { + ...next.discord?.accounts?.[discordAccountId], + enabled: + next.discord?.accounts?.[discordAccountId]?.enabled ?? true, + token, + }, + }, + }, + }; + } } } if (selection.includes("slack")) { + const slackOverride = accountOverrides.slack?.trim(); + const defaultSlackAccountId = resolveDefaultSlackAccountId(next); + let slackAccountId = slackOverride + ? normalizeAccountId(slackOverride) + : defaultSlackAccountId; + if (shouldPromptAccountIds && !slackOverride) { + slackAccountId = await promptAccountId({ + cfg: next, + prompter, + label: "Slack", + currentId: slackAccountId, + listAccountIds: listSlackAccountIds, + defaultAccountId: defaultSlackAccountId, + }); + } + recordAccount("slack", slackAccountId); + + const resolvedAccount = resolveSlackAccount({ + cfg: next, + accountId: slackAccountId, + }); + const accountConfigured = Boolean( + resolvedAccount.botToken && resolvedAccount.appToken, + ); + const allowEnv = slackAccountId === DEFAULT_ACCOUNT_ID; + const canUseEnv = allowEnv && slackBotEnv && slackAppEnv; + const hasConfigTokens = Boolean( + resolvedAccount.config.botToken && resolvedAccount.config.appToken, + ); + let botToken: string | null = null; let appToken: string | null = null; const slackBotName = String( @@ -912,13 +1109,12 @@ export async function setupProviders( initialValue: "Clawdbot", }), ).trim(); - if (!slackConfigured) { + if (!accountConfigured) { await noteSlackTokenHelp(prompter, slackBotName); } if ( - slackBotEnv && - slackAppEnv && - (!cfg.slack?.botToken || !cfg.slack?.appToken) + canUseEnv && + (!resolvedAccount.config.botToken || !resolvedAccount.config.appToken) ) { const keepEnv = await prompter.confirm({ message: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?", @@ -946,7 +1142,7 @@ export async function setupProviders( }), ).trim(); } - } else if (cfg.slack?.botToken && cfg.slack?.appToken) { + } else if (hasConfigTokens) { const keep = await prompter.confirm({ message: "Slack tokens already configured. Keep them?", initialValue: true, @@ -981,21 +1177,63 @@ export async function setupProviders( } if (botToken && appToken) { - next = { - ...next, - slack: { - ...next.slack, - enabled: true, - botToken, - appToken, - }, - }; + if (slackAccountId === DEFAULT_ACCOUNT_ID) { + next = { + ...next, + slack: { + ...next.slack, + enabled: true, + botToken, + appToken, + }, + }; + } else { + next = { + ...next, + slack: { + ...next.slack, + enabled: true, + accounts: { + ...next.slack?.accounts, + [slackAccountId]: { + ...next.slack?.accounts?.[slackAccountId], + enabled: + next.slack?.accounts?.[slackAccountId]?.enabled ?? true, + botToken, + appToken, + }, + }, + }, + }; + } } } if (selection.includes("signal")) { - let resolvedCliPath = signalCliPath; - let cliDetected = signalCliDetected; + const signalOverride = accountOverrides.signal?.trim(); + const defaultSignalAccountId = resolveDefaultSignalAccountId(next); + let signalAccountId = signalOverride + ? normalizeAccountId(signalOverride) + : defaultSignalAccountId; + if (shouldPromptAccountIds && !signalOverride) { + signalAccountId = await promptAccountId({ + cfg: next, + prompter, + label: "Signal", + currentId: signalAccountId, + listAccountIds: listSignalAccountIds, + defaultAccountId: defaultSignalAccountId, + }); + } + recordAccount("signal", signalAccountId); + + const resolvedAccount = resolveSignalAccount({ + cfg: next, + accountId: signalAccountId, + }); + const accountConfig = resolvedAccount.config; + let resolvedCliPath = accountConfig.cliPath ?? signalCliPath; + let cliDetected = await detectBinary(resolvedCliPath); if (options?.allowSignalInstall) { const wantsInstall = await prompter.confirm({ message: cliDetected @@ -1035,7 +1273,7 @@ export async function setupProviders( ); } - let account = cfg.signal?.account ?? ""; + let account = accountConfig.account ?? ""; if (account) { const keep = await prompter.confirm({ message: `Signal account set (${account}). Keep it?`, @@ -1054,15 +1292,35 @@ export async function setupProviders( } if (account) { - next = { - ...next, - signal: { - ...next.signal, - enabled: true, - account, - cliPath: resolvedCliPath ?? "signal-cli", - }, - }; + if (signalAccountId === DEFAULT_ACCOUNT_ID) { + next = { + ...next, + signal: { + ...next.signal, + enabled: true, + account, + cliPath: resolvedCliPath ?? "signal-cli", + }, + }; + } else { + next = { + ...next, + signal: { + ...next.signal, + enabled: true, + accounts: { + ...next.signal?.accounts, + [signalAccountId]: { + ...next.signal?.accounts?.[signalAccountId], + enabled: + next.signal?.accounts?.[signalAccountId]?.enabled ?? true, + account, + cliPath: resolvedCliPath ?? "signal-cli", + }, + }, + }, + }; + } } await prompter.note( @@ -1070,15 +1328,37 @@ export async function setupProviders( 'Link device with: signal-cli link -n "Clawdbot"', "Scan QR in Signal → Linked Devices", "Then run: clawdbot gateway call providers.status --params '{\"probe\":true}'", - "Docs: https://docs.clawd.bot/signal", + `Docs: ${docsLink("/signal", "signal")}`, ].join("\n"), "Signal next steps", ); } if (selection.includes("imessage")) { - let resolvedCliPath = imessageCliPath; - if (!imessageCliDetected) { + const imessageOverride = accountOverrides.imessage?.trim(); + const defaultIMessageAccountId = resolveDefaultIMessageAccountId(next); + let imessageAccountId = imessageOverride + ? normalizeAccountId(imessageOverride) + : defaultIMessageAccountId; + if (shouldPromptAccountIds && !imessageOverride) { + imessageAccountId = await promptAccountId({ + cfg: next, + prompter, + label: "iMessage", + currentId: imessageAccountId, + listAccountIds: listIMessageAccountIds, + defaultAccountId: defaultIMessageAccountId, + }); + } + recordAccount("imessage", imessageAccountId); + + const resolvedAccount = resolveIMessageAccount({ + cfg: next, + accountId: imessageAccountId, + }); + let resolvedCliPath = resolvedAccount.config.cliPath ?? imessageCliPath; + const cliDetected = await detectBinary(resolvedCliPath); + if (!cliDetected) { const entered = await prompter.text({ message: "imsg CLI path", initialValue: resolvedCliPath, @@ -1094,14 +1374,33 @@ export async function setupProviders( } if (resolvedCliPath) { - next = { - ...next, - imessage: { - ...next.imessage, - enabled: true, - cliPath: resolvedCliPath, - }, - }; + if (imessageAccountId === DEFAULT_ACCOUNT_ID) { + next = { + ...next, + imessage: { + ...next.imessage, + enabled: true, + cliPath: resolvedCliPath, + }, + }; + } else { + next = { + ...next, + imessage: { + ...next.imessage, + enabled: true, + accounts: { + ...next.imessage?.accounts, + [imessageAccountId]: { + ...next.imessage?.accounts?.[imessageAccountId], + enabled: + next.imessage?.accounts?.[imessageAccountId]?.enabled ?? true, + cliPath: resolvedCliPath, + }, + }, + }, + }; + } } await prompter.note( @@ -1110,7 +1409,7 @@ export async function setupProviders( "Ensure Clawdbot has Full Disk Access to Messages DB.", "Grant Automation permission for Messages when prompted.", "List chats with: imsg chats --limit 20", - "Docs: https://docs.clawd.bot/imessage", + `Docs: ${docsLink("/imessage", "imessage")}`, ].join("\n"), "iMessage next steps", ); diff --git a/src/commands/providers.test.ts b/src/commands/providers.test.ts new file mode 100644 index 000000000..382683b17 --- /dev/null +++ b/src/commands/providers.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RuntimeEnv } from "../runtime.js"; + +const configMocks = vi.hoisted(() => ({ + readConfigFileSnapshot: vi.fn(), + writeConfigFile: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readConfigFileSnapshot: configMocks.readConfigFileSnapshot, + writeConfigFile: configMocks.writeConfigFile, + }; +}); + +import { providersAddCommand, providersRemoveCommand } from "./providers.js"; + +const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}; + +const baseSnapshot = { + path: "/tmp/clawdbot.json", + exists: true, + raw: "{}", + parsed: {}, + valid: true, + config: {}, + issues: [], + legacyIssues: [], +}; + +describe("providers command", () => { + beforeEach(() => { + configMocks.readConfigFileSnapshot.mockReset(); + configMocks.writeConfigFile.mockClear(); + runtime.log.mockClear(); + runtime.error.mockClear(); + runtime.exit.mockClear(); + }); + + it("adds a non-default telegram account", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot }); + await providersAddCommand( + { provider: "telegram", account: "alerts", token: "123:abc" }, + runtime, + { hasFlags: true }, + ); + + expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1); + const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + telegram?: { + enabled?: boolean; + accounts?: Record; + }; + }; + expect(next.telegram?.enabled).toBe(true); + expect(next.telegram?.accounts?.alerts?.botToken).toBe("123:abc"); + }); + + it("adds a default slack account with tokens", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot }); + await providersAddCommand( + { + provider: "slack", + account: "default", + botToken: "xoxb-1", + appToken: "xapp-1", + }, + runtime, + { hasFlags: true }, + ); + + expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1); + const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + slack?: { enabled?: boolean; botToken?: string; appToken?: string }; + }; + expect(next.slack?.enabled).toBe(true); + expect(next.slack?.botToken).toBe("xoxb-1"); + expect(next.slack?.appToken).toBe("xapp-1"); + }); + + it("deletes a non-default discord account", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseSnapshot, + config: { + discord: { + accounts: { + default: { token: "d0" }, + work: { token: "d1" }, + }, + }, + }, + }); + + await providersRemoveCommand( + { provider: "discord", account: "work", delete: true }, + runtime, + { hasFlags: true }, + ); + + expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1); + const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + discord?: { accounts?: Record }; + }; + expect(next.discord?.accounts?.work).toBeUndefined(); + expect(next.discord?.accounts?.default?.token).toBe("d0"); + }); +}); diff --git a/src/commands/providers.ts b/src/commands/providers.ts new file mode 100644 index 000000000..3fa3ce716 --- /dev/null +++ b/src/commands/providers.ts @@ -0,0 +1,1077 @@ +import chalk from "chalk"; + +import { + CLAUDE_CLI_PROFILE_ID, + CODEX_CLI_PROFILE_ID, + loadAuthProfileStore, +} from "../agents/auth-profiles.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; +import { + listDiscordAccountIds, + resolveDiscordAccount, +} from "../discord/accounts.js"; +import { callGateway } from "../gateway/call.js"; +import { + listIMessageAccountIds, + resolveIMessageAccount, +} from "../imessage/accounts.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../routing/session-key.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { defaultRuntime } from "../runtime.js"; +import { + listSignalAccountIds, + resolveSignalAccount, +} from "../signal/accounts.js"; +import { listSlackAccountIds, resolveSlackAccount } from "../slack/accounts.js"; +import { + listTelegramAccountIds, + resolveTelegramAccount, +} from "../telegram/accounts.js"; +import { formatTerminalLink } from "../utils.js"; +import { + listWhatsAppAccountIds, + resolveWhatsAppAuthDir, +} from "../web/accounts.js"; +import { webAuthExists } from "../web/session.js"; +import { createClackPrompter } from "../wizard/clack-prompter.js"; +import { setupProviders } from "./onboard-providers.js"; +import type { ProviderChoice } from "./onboard-types.js"; + +const DOCS_BASE = "https://docs.clawd.bot"; + +const CHAT_PROVIDERS = [ + "whatsapp", + "telegram", + "discord", + "slack", + "signal", + "imessage", +] as const; + +type ChatProvider = (typeof CHAT_PROVIDERS)[number]; + +type ProvidersListOptions = { + json?: boolean; +}; + +type ProvidersStatusOptions = { + json?: boolean; + probe?: boolean; + timeout?: string; +}; + +export type ProvidersAddOptions = { + provider?: string; + account?: string; + name?: string; + token?: string; + tokenFile?: string; + botToken?: string; + appToken?: string; + signalNumber?: string; + cliPath?: string; + dbPath?: string; + service?: "imessage" | "sms" | "auto"; + region?: string; + authDir?: string; + httpUrl?: string; + httpHost?: string; + httpPort?: string; + useEnv?: boolean; +}; + +export type ProvidersRemoveOptions = { + provider?: string; + account?: string; + delete?: boolean; +}; + +function docsLink(path: string, label?: string): string { + const cleanPath = path.startsWith("/") ? path : `/${path}`; + const url = `${DOCS_BASE}${cleanPath}`; + return formatTerminalLink(label ?? url, url, { fallback: url }); +} + +function normalizeChatProvider(raw?: string): ChatProvider | null { + const trimmed = (raw ?? "").trim().toLowerCase(); + if (!trimmed) return null; + const normalized = trimmed === "imsg" ? "imessage" : trimmed; + return CHAT_PROVIDERS.includes(normalized as ChatProvider) + ? (normalized as ChatProvider) + : null; +} + +async function requireValidConfig( + runtime: RuntimeEnv, +): Promise { + const snapshot = await readConfigFileSnapshot(); + if (snapshot.exists && !snapshot.valid) { + const issues = + snapshot.issues.length > 0 + ? snapshot.issues + .map((issue) => `- ${issue.path}: ${issue.message}`) + .join("\n") + : "Unknown validation issue."; + runtime.error(`Config invalid:\n${issues}`); + runtime.error("Fix the config or run clawdbot doctor."); + runtime.exit(1); + return null; + } + return snapshot.config; +} + +function formatAccountLabel(params: { accountId: string; name?: string }) { + const base = params.accountId || DEFAULT_ACCOUNT_ID; + if (params.name?.trim()) return `${base} (${params.name.trim()})`; + return base; +} + +function formatEnabled(value: boolean | undefined): string { + return value === false ? "disabled" : "enabled"; +} + +function formatConfigured(value: boolean): string { + return value ? "configured" : "not configured"; +} + +function formatTokenSource(source?: string): string { + if (!source || source === "none") return "token=none"; + return `token=${source}`; +} + +function applyAccountName(params: { + cfg: ClawdbotConfig; + provider: ChatProvider; + accountId: string; + name?: string; +}): ClawdbotConfig { + const trimmed = params.name?.trim(); + if (!trimmed) return params.cfg; + const accountId = normalizeAccountId(params.accountId); + if (params.provider === "whatsapp") { + return { + ...params.cfg, + whatsapp: { + ...params.cfg.whatsapp, + accounts: { + ...params.cfg.whatsapp?.accounts, + [accountId]: { + ...params.cfg.whatsapp?.accounts?.[accountId], + name: trimmed, + }, + }, + }, + }; + } + const key = params.provider; + if (accountId === DEFAULT_ACCOUNT_ID) { + const baseConfig = (params.cfg as Record)[key]; + const safeBase = + typeof baseConfig === "object" && baseConfig + ? (baseConfig as Record) + : {}; + return { + ...params.cfg, + [key]: { + ...safeBase, + name: trimmed, + }, + } as ClawdbotConfig; + } + const base = (params.cfg as Record)[key] as + | { accounts?: Record> } + | undefined; + const baseAccounts: Record< + string, + Record + > = base?.accounts ?? {}; + const existingAccount = baseAccounts[accountId] ?? {}; + return { + ...params.cfg, + [key]: { + ...base, + accounts: { + ...baseAccounts, + [accountId]: { + ...existingAccount, + name: trimmed, + }, + }, + }, + } as ClawdbotConfig; +} + +function applyProviderAccountConfig(params: { + cfg: ClawdbotConfig; + provider: ChatProvider; + accountId: string; + name?: string; + token?: string; + tokenFile?: string; + botToken?: string; + appToken?: string; + signalNumber?: string; + cliPath?: string; + dbPath?: string; + service?: "imessage" | "sms" | "auto"; + region?: string; + authDir?: string; + httpUrl?: string; + httpHost?: string; + httpPort?: string; + useEnv?: boolean; +}): ClawdbotConfig { + const accountId = normalizeAccountId(params.accountId); + const name = params.name?.trim() || undefined; + const next = applyAccountName({ + cfg: params.cfg, + provider: params.provider, + accountId, + name, + }); + + if (params.provider === "whatsapp") { + const entry = { + ...next.whatsapp?.accounts?.[accountId], + ...(params.authDir ? { authDir: params.authDir } : {}), + enabled: true, + ...(name ? { name } : {}), + }; + return { + ...next, + whatsapp: { + ...next.whatsapp, + accounts: { + ...next.whatsapp?.accounts, + [accountId]: entry, + }, + }, + }; + } + + if (params.provider === "telegram") { + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + telegram: { + ...next.telegram, + enabled: true, + ...(params.useEnv + ? {} + : params.tokenFile + ? { tokenFile: params.tokenFile } + : params.token + ? { botToken: params.token } + : {}), + ...(name ? { name } : {}), + }, + }; + } + return { + ...next, + telegram: { + ...next.telegram, + enabled: true, + accounts: { + ...next.telegram?.accounts, + [accountId]: { + ...next.telegram?.accounts?.[accountId], + enabled: true, + ...(params.tokenFile + ? { tokenFile: params.tokenFile } + : params.token + ? { botToken: params.token } + : {}), + ...(name ? { name } : {}), + }, + }, + }, + }; + } + + if (params.provider === "discord") { + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + discord: { + ...next.discord, + enabled: true, + ...(params.useEnv ? {} : params.token ? { token: params.token } : {}), + ...(name ? { name } : {}), + }, + }; + } + return { + ...next, + discord: { + ...next.discord, + enabled: true, + accounts: { + ...next.discord?.accounts, + [accountId]: { + ...next.discord?.accounts?.[accountId], + enabled: true, + ...(params.token ? { token: params.token } : {}), + ...(name ? { name } : {}), + }, + }, + }, + }; + } + + if (params.provider === "slack") { + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + slack: { + ...next.slack, + enabled: true, + ...(params.useEnv + ? {} + : { + ...(params.botToken ? { botToken: params.botToken } : {}), + ...(params.appToken ? { appToken: params.appToken } : {}), + }), + ...(name ? { name } : {}), + }, + }; + } + return { + ...next, + slack: { + ...next.slack, + enabled: true, + accounts: { + ...next.slack?.accounts, + [accountId]: { + ...next.slack?.accounts?.[accountId], + enabled: true, + ...(params.botToken ? { botToken: params.botToken } : {}), + ...(params.appToken ? { appToken: params.appToken } : {}), + ...(name ? { name } : {}), + }, + }, + }, + }; + } + + if (params.provider === "signal") { + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + signal: { + ...next.signal, + enabled: true, + ...(params.signalNumber ? { account: params.signalNumber } : {}), + ...(params.cliPath ? { cliPath: params.cliPath } : {}), + ...(params.httpUrl ? { httpUrl: params.httpUrl } : {}), + ...(params.httpHost ? { httpHost: params.httpHost } : {}), + ...(params.httpPort ? { httpPort: Number(params.httpPort) } : {}), + ...(name ? { name } : {}), + }, + }; + } + return { + ...next, + signal: { + ...next.signal, + enabled: true, + accounts: { + ...next.signal?.accounts, + [accountId]: { + ...next.signal?.accounts?.[accountId], + enabled: true, + ...(params.signalNumber ? { account: params.signalNumber } : {}), + ...(params.cliPath ? { cliPath: params.cliPath } : {}), + ...(params.httpUrl ? { httpUrl: params.httpUrl } : {}), + ...(params.httpHost ? { httpHost: params.httpHost } : {}), + ...(params.httpPort ? { httpPort: Number(params.httpPort) } : {}), + ...(name ? { name } : {}), + }, + }, + }, + }; + } + + if (params.provider === "imessage") { + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + imessage: { + ...next.imessage, + enabled: true, + ...(params.cliPath ? { cliPath: params.cliPath } : {}), + ...(params.dbPath ? { dbPath: params.dbPath } : {}), + ...(params.service ? { service: params.service } : {}), + ...(params.region ? { region: params.region } : {}), + ...(name ? { name } : {}), + }, + }; + } + return { + ...next, + imessage: { + ...next.imessage, + enabled: true, + accounts: { + ...next.imessage?.accounts, + [accountId]: { + ...next.imessage?.accounts?.[accountId], + enabled: true, + ...(params.cliPath ? { cliPath: params.cliPath } : {}), + ...(params.dbPath ? { dbPath: params.dbPath } : {}), + ...(params.service ? { service: params.service } : {}), + ...(params.region ? { region: params.region } : {}), + ...(name ? { name } : {}), + }, + }, + }, + }; + } + + return next; +} + +export async function providersListCommand( + opts: ProvidersListOptions, + runtime: RuntimeEnv = defaultRuntime, +) { + const cfg = await requireValidConfig(runtime); + if (!cfg) return; + + const whatsappAccounts = listWhatsAppAccountIds(cfg); + const telegramAccounts = listTelegramAccountIds(cfg); + const discordAccounts = listDiscordAccountIds(cfg); + const slackAccounts = listSlackAccountIds(cfg); + const signalAccounts = listSignalAccountIds(cfg); + const imessageAccounts = listIMessageAccountIds(cfg); + + const authStore = loadAuthProfileStore(); + const authProfiles = Object.entries(authStore.profiles).map( + ([profileId, profile]) => ({ + id: profileId, + provider: profile.provider, + type: profile.type, + isExternal: + profileId === CLAUDE_CLI_PROFILE_ID || + profileId === CODEX_CLI_PROFILE_ID, + }), + ); + + if (opts.json) { + const payload = { + chat: { + whatsapp: whatsappAccounts, + telegram: telegramAccounts, + discord: discordAccounts, + slack: slackAccounts, + signal: signalAccounts, + imessage: imessageAccounts, + }, + auth: authProfiles, + }; + runtime.log(JSON.stringify(payload, null, 2)); + return; + } + + const lines: string[] = []; + lines.push("Chat providers:"); + + for (const accountId of whatsappAccounts) { + const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId }); + const linked = await webAuthExists(authDir); + const name = cfg.whatsapp?.accounts?.[accountId]?.name; + lines.push( + `- WhatsApp ${formatAccountLabel({ + accountId, + name, + })}: ${linked ? "linked" : "not linked"}, ${formatEnabled( + cfg.whatsapp?.accounts?.[accountId]?.enabled ?? + cfg.web?.enabled ?? + true, + )}`, + ); + } + + for (const accountId of telegramAccounts) { + const account = resolveTelegramAccount({ cfg, accountId }); + lines.push( + `- Telegram ${formatAccountLabel({ + accountId, + name: account.name, + })}: ${formatConfigured(Boolean(account.token))}, ${formatTokenSource( + account.tokenSource, + )}, ${formatEnabled(account.enabled)}`, + ); + } + + for (const accountId of discordAccounts) { + const account = resolveDiscordAccount({ cfg, accountId }); + lines.push( + `- Discord ${formatAccountLabel({ + accountId, + name: account.name, + })}: ${formatConfigured(Boolean(account.token))}, ${formatTokenSource( + account.tokenSource, + )}, ${formatEnabled(account.enabled)}`, + ); + } + + for (const accountId of slackAccounts) { + const account = resolveSlackAccount({ cfg, accountId }); + const configured = Boolean(account.botToken && account.appToken); + lines.push( + `- Slack ${formatAccountLabel({ + accountId, + name: account.name, + })}: ${formatConfigured(configured)}, bot=${account.botTokenSource}, app=${account.appTokenSource}, ${formatEnabled( + account.enabled, + )}`, + ); + } + + for (const accountId of signalAccounts) { + const account = resolveSignalAccount({ cfg, accountId }); + lines.push( + `- Signal ${formatAccountLabel({ + accountId, + name: account.name, + })}: ${formatConfigured(account.configured)}, base=${account.baseUrl}, ${formatEnabled( + account.enabled, + )}`, + ); + } + + for (const accountId of imessageAccounts) { + const account = resolveIMessageAccount({ cfg, accountId }); + lines.push( + `- iMessage ${formatAccountLabel({ + accountId, + name: account.name, + })}: ${formatEnabled(account.enabled)}`, + ); + } + + lines.push(""); + lines.push("Auth providers (OAuth + API keys):"); + if (authProfiles.length === 0) { + lines.push("- none"); + } else { + for (const profile of authProfiles) { + const external = profile.isExternal ? " (synced)" : ""; + lines.push(`- ${profile.id} (${profile.type}${external})`); + } + } + + lines.push(""); + lines.push( + `Docs: ${docsLink("/gateway/configuration", "gateway/configuration")}`, + ); + + runtime.log(lines.join("\n")); +} + +export async function providersStatusCommand( + opts: ProvidersStatusOptions, + runtime: RuntimeEnv = defaultRuntime, +) { + const timeoutMs = Number(opts.timeout ?? 10_000); + try { + const payload = await callGateway({ + method: "providers.status", + params: { probe: Boolean(opts.probe), timeoutMs }, + timeoutMs, + }); + if (opts.json) { + runtime.log(JSON.stringify(payload, null, 2)); + return; + } + const data = payload as Record; + const lines: string[] = []; + lines.push(chalk.green("Gateway reachable.")); + const accountLines = ( + label: string, + accounts: Array>, + ) => + accounts.map((account) => { + const bits: string[] = []; + if (typeof account.enabled === "boolean") { + bits.push(account.enabled ? "enabled" : "disabled"); + } + if (typeof account.configured === "boolean") { + bits.push(account.configured ? "configured" : "not configured"); + } + if (typeof account.linked === "boolean") { + bits.push(account.linked ? "linked" : "not linked"); + } + if (typeof account.running === "boolean") { + bits.push(account.running ? "running" : "stopped"); + } + const probe = account.probe as { ok?: boolean } | undefined; + if (probe && typeof probe.ok === "boolean") { + bits.push(probe.ok ? "works" : "probe failed"); + } + const accountId = + typeof account.accountId === "string" ? account.accountId : "default"; + const labelText = `${label} ${accountId}`; + return `- ${labelText}: ${bits.join(", ")}`; + }); + + if (Array.isArray(data.whatsappAccounts)) { + lines.push( + ...accountLines( + "WhatsApp", + data.whatsappAccounts as Array>, + ), + ); + } + if (Array.isArray(data.telegramAccounts)) { + lines.push( + ...accountLines( + "Telegram", + data.telegramAccounts as Array>, + ), + ); + } + if (Array.isArray(data.discordAccounts)) { + lines.push( + ...accountLines( + "Discord", + data.discordAccounts as Array>, + ), + ); + } + if (Array.isArray(data.slackAccounts)) { + lines.push( + ...accountLines( + "Slack", + data.slackAccounts as Array>, + ), + ); + } + if (Array.isArray(data.signalAccounts)) { + lines.push( + ...accountLines( + "Signal", + data.signalAccounts as Array>, + ), + ); + } + if (Array.isArray(data.imessageAccounts)) { + lines.push( + ...accountLines( + "iMessage", + data.imessageAccounts as Array>, + ), + ); + } + + runtime.log(lines.join("\n")); + } catch (err) { + runtime.error(`Gateway not reachable: ${String(err)}`); + runtime.exit(1); + } +} + +export async function providersAddCommand( + opts: ProvidersAddOptions, + runtime: RuntimeEnv = defaultRuntime, + params?: { hasFlags?: boolean }, +) { + const cfg = await requireValidConfig(runtime); + if (!cfg) return; + + const useWizard = params?.hasFlags === false; + if (useWizard) { + const prompter = createClackPrompter(); + let selection: ProviderChoice[] = []; + const accountIds: Partial> = {}; + await prompter.intro("Provider setup"); + let nextConfig = await setupProviders(cfg, runtime, prompter, { + allowDisable: false, + allowSignalInstall: true, + promptAccountIds: true, + onSelection: (value) => { + selection = value; + }, + onAccountId: (provider, accountId) => { + accountIds[provider] = accountId; + }, + }); + if (selection.length === 0) { + await prompter.outro("No providers selected."); + return; + } + + const wantsNames = await prompter.confirm({ + message: "Add display names for these accounts? (optional)", + initialValue: false, + }); + if (wantsNames) { + for (const provider of selection) { + const accountId = accountIds[provider] ?? DEFAULT_ACCOUNT_ID; + const existingName = + provider === "whatsapp" + ? nextConfig.whatsapp?.accounts?.[accountId]?.name + : provider === "telegram" + ? (nextConfig.telegram?.accounts?.[accountId]?.name ?? + nextConfig.telegram?.name) + : provider === "discord" + ? (nextConfig.discord?.accounts?.[accountId]?.name ?? + nextConfig.discord?.name) + : provider === "slack" + ? (nextConfig.slack?.accounts?.[accountId]?.name ?? + nextConfig.slack?.name) + : provider === "signal" + ? (nextConfig.signal?.accounts?.[accountId]?.name ?? + nextConfig.signal?.name) + : provider === "imessage" + ? (nextConfig.imessage?.accounts?.[accountId]?.name ?? + nextConfig.imessage?.name) + : undefined; + const name = await prompter.text({ + message: `${provider} account name (${accountId})`, + initialValue: existingName, + }); + if (name?.trim()) { + nextConfig = applyAccountName({ + cfg: nextConfig, + provider, + accountId, + name, + }); + } + } + } + + await writeConfigFile(nextConfig); + await prompter.outro("Providers updated."); + return; + } + + const provider = normalizeChatProvider(opts.provider); + if (!provider) { + runtime.error(`Unknown provider: ${String(opts.provider ?? "")}`); + runtime.exit(1); + return; + } + + const accountId = normalizeAccountId(opts.account); + const useEnv = opts.useEnv === true; + + if (provider === "telegram") { + if (useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + runtime.error( + "TELEGRAM_BOT_TOKEN can only be used for the default account.", + ); + runtime.exit(1); + return; + } + if (!useEnv && !opts.token && !opts.tokenFile) { + runtime.error( + "Telegram requires --token or --token-file (or --use-env).", + ); + runtime.exit(1); + return; + } + } + if (provider === "discord") { + if (useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + runtime.error( + "DISCORD_BOT_TOKEN can only be used for the default account.", + ); + runtime.exit(1); + return; + } + if (!useEnv && !opts.token) { + runtime.error("Discord requires --token (or --use-env)."); + runtime.exit(1); + return; + } + } + if (provider === "slack") { + if (useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + runtime.error( + "Slack env tokens can only be used for the default account.", + ); + runtime.exit(1); + return; + } + if (!useEnv && (!opts.botToken || !opts.appToken)) { + runtime.error( + "Slack requires --bot-token and --app-token (or --use-env).", + ); + runtime.exit(1); + return; + } + } + if (provider === "signal") { + if ( + !opts.signalNumber && + !opts.httpUrl && + !opts.httpHost && + !opts.httpPort && + !opts.cliPath + ) { + runtime.error( + "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path.", + ); + runtime.exit(1); + return; + } + } + + const nextConfig = applyProviderAccountConfig({ + cfg, + provider, + accountId, + name: opts.name, + token: opts.token, + tokenFile: opts.tokenFile, + botToken: opts.botToken, + appToken: opts.appToken, + signalNumber: opts.signalNumber, + cliPath: opts.cliPath, + dbPath: opts.dbPath, + service: opts.service, + region: opts.region, + authDir: opts.authDir, + httpUrl: opts.httpUrl, + httpHost: opts.httpHost, + httpPort: opts.httpPort, + useEnv, + }); + + await writeConfigFile(nextConfig); + runtime.log(`Added ${provider} account "${accountId}".`); +} + +export async function providersRemoveCommand( + opts: ProvidersRemoveOptions, + runtime: RuntimeEnv = defaultRuntime, + params?: { hasFlags?: boolean }, +) { + const cfg = await requireValidConfig(runtime); + if (!cfg) return; + + const useWizard = params?.hasFlags === false; + const prompter = useWizard ? createClackPrompter() : null; + let provider = normalizeChatProvider(opts.provider); + let accountId = normalizeAccountId(opts.account); + const deleteConfig = Boolean(opts.delete); + + if (useWizard && prompter) { + await prompter.intro("Remove provider account"); + provider = (await prompter.select({ + message: "Provider", + options: CHAT_PROVIDERS.map((value) => ({ + value, + label: value, + })), + })) as ChatProvider; + + const listAccounts = + provider === "whatsapp" + ? listWhatsAppAccountIds + : provider === "telegram" + ? listTelegramAccountIds + : provider === "discord" + ? listDiscordAccountIds + : provider === "slack" + ? listSlackAccountIds + : provider === "signal" + ? listSignalAccountIds + : listIMessageAccountIds; + accountId = await (async () => { + const ids = listAccounts(cfg); + const choice = (await prompter.select({ + message: "Account", + options: ids.map((id) => ({ + value: id, + label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id, + })), + initialValue: ids[0] ?? DEFAULT_ACCOUNT_ID, + })) as string; + return normalizeAccountId(choice); + })(); + + const wantsDisable = await prompter.confirm({ + message: `Disable ${provider} account "${accountId}"? (keeps config)`, + initialValue: true, + }); + if (!wantsDisable) { + await prompter.outro("Cancelled."); + return; + } + } else { + if (!provider) { + runtime.error("Provider is required. Use --provider ."); + runtime.exit(1); + return; + } + if (!deleteConfig) { + const confirm = createClackPrompter(); + const ok = await confirm.confirm({ + message: `Disable ${provider} account "${accountId}"? (keeps config)`, + initialValue: true, + }); + if (!ok) { + return; + } + } + } + + let next = { ...cfg }; + const accountKey = accountId || DEFAULT_ACCOUNT_ID; + + const setAccountEnabled = (key: ChatProvider, enabled: boolean) => { + if (key === "whatsapp") { + next = { + ...next, + whatsapp: { + ...next.whatsapp, + accounts: { + ...next.whatsapp?.accounts, + [accountKey]: { + ...next.whatsapp?.accounts?.[accountKey], + enabled, + }, + }, + }, + }; + return; + } + const base = (next as Record)[key] as + | { + accounts?: Record>; + enabled?: boolean; + } + | undefined; + const baseAccounts: Record< + string, + Record + > = base?.accounts ?? {}; + const existingAccount = baseAccounts[accountKey] ?? {}; + if (accountKey === DEFAULT_ACCOUNT_ID && !base?.accounts) { + next = { + ...next, + [key]: { + ...base, + enabled, + }, + } as ClawdbotConfig; + return; + } + next = { + ...next, + [key]: { + ...base, + accounts: { + ...baseAccounts, + [accountKey]: { + ...existingAccount, + enabled, + }, + }, + }, + } as ClawdbotConfig; + }; + + const deleteAccount = (key: ChatProvider) => { + if (key === "whatsapp") { + const accounts = { ...next.whatsapp?.accounts }; + delete accounts[accountKey]; + next = { + ...next, + whatsapp: { + ...next.whatsapp, + accounts: Object.keys(accounts).length ? accounts : undefined, + }, + }; + return; + } + const base = (next as Record)[key] as + | { + accounts?: Record>; + enabled?: boolean; + } + | undefined; + if (accountKey !== DEFAULT_ACCOUNT_ID) { + const accounts = { ...base?.accounts }; + delete accounts[accountKey]; + next = { + ...next, + [key]: { + ...base, + accounts: Object.keys(accounts).length ? accounts : undefined, + }, + } as ClawdbotConfig; + return; + } + if (base?.accounts && Object.keys(base.accounts).length > 0) { + const accounts = { ...base.accounts }; + delete accounts[accountKey]; + next = { + ...next, + [key]: { + ...base, + accounts: Object.keys(accounts).length ? accounts : undefined, + ...(key === "telegram" + ? { botToken: undefined, tokenFile: undefined, name: undefined } + : key === "discord" + ? { token: undefined, name: undefined } + : key === "slack" + ? { botToken: undefined, appToken: undefined, name: undefined } + : key === "signal" + ? { + account: undefined, + httpUrl: undefined, + httpHost: undefined, + httpPort: undefined, + cliPath: undefined, + name: undefined, + } + : key === "imessage" + ? { + cliPath: undefined, + dbPath: undefined, + service: undefined, + region: undefined, + name: undefined, + } + : {}), + }, + } as ClawdbotConfig; + return; + } + // No accounts map: remove entire provider section. + const clone = { ...next } as Record; + delete clone[key]; + next = clone as ClawdbotConfig; + }; + + if (deleteConfig) { + deleteAccount(provider); + } else { + setAccountEnabled(provider, false); + } + + await writeConfigFile(next); + if (useWizard && prompter) { + await prompter.outro( + deleteConfig + ? `Deleted ${provider} account "${accountKey}".` + : `Disabled ${provider} account "${accountKey}".`, + ); + } else { + runtime.log( + deleteConfig + ? `Deleted ${provider} account "${accountKey}".` + : `Disabled ${provider} account "${accountKey}".`, + ); + } +} diff --git a/src/commands/send.test.ts b/src/commands/send.test.ts index c741a12e2..51c83d489 100644 --- a/src/commands/send.test.ts +++ b/src/commands/send.test.ts @@ -137,7 +137,7 @@ describe("sendCommand", () => { expect(deps.sendMessageTelegram).toHaveBeenCalledWith( "123", "hi", - expect.objectContaining({ token: "token-abc", verbose: false }), + expect.objectContaining({ accountId: "default", verbose: false }), ); expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); }); @@ -158,7 +158,7 @@ describe("sendCommand", () => { expect(deps.sendMessageTelegram).toHaveBeenCalledWith( "123", "hi", - expect.objectContaining({ token: "cfg-token", verbose: false }), + expect.objectContaining({ accountId: "default", verbose: false }), ); }); @@ -209,7 +209,11 @@ describe("sendCommand", () => { deps, runtime, ); - expect(deps.sendMessageSlack).toHaveBeenCalledWith("channel:C123", "hi"); + expect(deps.sendMessageSlack).toHaveBeenCalledWith( + "channel:C123", + "hi", + expect.objectContaining({ accountId: "default" }), + ); expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); }); diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts index 611d16913..5c9a4f290 100644 --- a/src/config/group-policy.ts +++ b/src/config/group-policy.ts @@ -1,3 +1,4 @@ +import { normalizeAccountId } from "../routing/session-key.js"; import type { ClawdbotConfig } from "./config.js"; export type GroupPolicyProvider = "whatsapp" | "telegram" | "imessage"; @@ -18,10 +19,22 @@ type ProviderGroups = Record; function resolveProviderGroups( cfg: ClawdbotConfig, provider: GroupPolicyProvider, + accountId?: string | null, ): ProviderGroups | undefined { if (provider === "whatsapp") return cfg.whatsapp?.groups; - if (provider === "telegram") return cfg.telegram?.groups; - if (provider === "imessage") return cfg.imessage?.groups; + const normalizedAccountId = normalizeAccountId(accountId); + if (provider === "telegram") { + return ( + cfg.telegram?.accounts?.[normalizedAccountId]?.groups ?? + cfg.telegram?.groups + ); + } + if (provider === "imessage") { + return ( + cfg.imessage?.accounts?.[normalizedAccountId]?.groups ?? + cfg.imessage?.groups + ); + } return undefined; } @@ -29,9 +42,10 @@ export function resolveProviderGroupPolicy(params: { cfg: ClawdbotConfig; provider: GroupPolicyProvider; groupId?: string | null; + accountId?: string | null; }): ProviderGroupPolicy { const { cfg, provider } = params; - const groups = resolveProviderGroups(cfg, provider); + const groups = resolveProviderGroups(cfg, provider, params.accountId); const allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0); const normalizedId = params.groupId?.trim(); const groupConfig = normalizedId && groups ? groups[normalizedId] : undefined; @@ -56,6 +70,7 @@ export function resolveProviderGroupRequireMention(params: { cfg: ClawdbotConfig; provider: GroupPolicyProvider; groupId?: string | null; + accountId?: string | null; requireMentionOverride?: boolean; overrideOrder?: "before-config" | "after-config"; }): boolean { diff --git a/src/config/types.ts b/src/config/types.ts index cff56f3d1..a488282a8 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -129,6 +129,8 @@ export type WhatsAppConfig = { }; export type WhatsAppAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; /** If false, do not start this WhatsApp account provider. Default: true. */ enabled?: boolean; /** Override auth directory (Baileys multi-file auth state). */ @@ -258,33 +260,9 @@ export type TelegramActionConfig = { sendMessage?: boolean; }; -export type TelegramTopicConfig = { - requireMention?: boolean; - /** If specified, only load these skills for this topic. Omit = all skills; empty = no skills. */ - skills?: string[]; - /** If false, disable the bot for this topic. */ - enabled?: boolean; - /** Optional allowlist for topic senders (ids or usernames). */ - allowFrom?: Array; - /** Optional system prompt snippet for this topic. */ - systemPrompt?: string; -}; - -export type TelegramGroupConfig = { - requireMention?: boolean; - /** If specified, only load these skills for this group (when no topic). Omit = all skills; empty = no skills. */ - skills?: string[]; - /** Per-topic configuration (key is message_thread_id as string) */ - topics?: Record; - /** If false, disable the bot for this group (and its topics). */ - enabled?: boolean; - /** Optional allowlist for group senders (ids or usernames). */ - allowFrom?: Array; - /** Optional system prompt snippet for this group. */ - systemPrompt?: string; -}; - -export type TelegramConfig = { +export type TelegramAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; /** * Controls how Telegram direct chats (DMs) are handled: * - "pairing" (default): unknown senders get a pairing code; owner must approve @@ -293,10 +271,10 @@ export type TelegramConfig = { * - "disabled": ignore all inbound DMs */ dmPolicy?: DmPolicy; - /** If false, do not start the Telegram provider. Default: true. */ + /** If false, do not start this Telegram account. Default: true. */ enabled?: boolean; botToken?: string; - /** Path to file containing bot token (for secret managers like agenix) */ + /** Path to file containing bot token (for secret managers like agenix). */ tokenFile?: string; /** Control reply threading when reply tags are present (off|first|all). */ replyToMode?: ReplyToMode; @@ -326,6 +304,37 @@ export type TelegramConfig = { actions?: TelegramActionConfig; }; +export type TelegramTopicConfig = { + requireMention?: boolean; + /** If specified, only load these skills for this topic. Omit = all skills; empty = no skills. */ + skills?: string[]; + /** If false, disable the bot for this topic. */ + enabled?: boolean; + /** Optional allowlist for topic senders (ids or usernames). */ + allowFrom?: Array; + /** Optional system prompt snippet for this topic. */ + systemPrompt?: string; +}; + +export type TelegramGroupConfig = { + requireMention?: boolean; + /** If specified, only load these skills for this group (when no topic). Omit = all skills; empty = no skills. */ + skills?: string[]; + /** Per-topic configuration (key is message_thread_id as string) */ + topics?: Record; + /** If false, disable the bot for this group (and its topics). */ + enabled?: boolean; + /** Optional allowlist for group senders (ids or usernames). */ + allowFrom?: Array; + /** Optional system prompt snippet for this group. */ + systemPrompt?: string; +}; + +export type TelegramConfig = { + /** Optional per-account Telegram configuration (multi-account). */ + accounts?: Record; +} & TelegramAccountConfig; + export type DiscordDmConfig = { /** If false, ignore all incoming Discord DMs. Default: true. */ enabled?: boolean; @@ -387,8 +396,10 @@ export type DiscordActionConfig = { stickerUploads?: boolean; }; -export type DiscordConfig = { - /** If false, do not start the Discord provider. Default: true. */ +export type DiscordAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** If false, do not start this Discord account. Default: true. */ enabled?: boolean; token?: string; /** @@ -413,6 +424,11 @@ export type DiscordConfig = { guilds?: Record; }; +export type DiscordConfig = { + /** Optional per-account Discord configuration (multi-account). */ + accounts?: Record; +} & DiscordAccountConfig; + export type SlackDmConfig = { /** If false, ignore all incoming Slack DMs. Default: true. */ enabled?: boolean; @@ -465,8 +481,10 @@ export type SlackSlashCommandConfig = { ephemeral?: boolean; }; -export type SlackConfig = { - /** If false, do not start the Slack provider. Default: true. */ +export type SlackAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** If false, do not start this Slack account. Default: true. */ enabled?: boolean; botToken?: string; appToken?: string; @@ -491,8 +509,15 @@ export type SlackConfig = { channels?: Record; }; -export type SignalConfig = { - /** If false, do not start the Signal provider. Default: true. */ +export type SlackConfig = { + /** Optional per-account Slack configuration (multi-account). */ + accounts?: Record; +} & SlackAccountConfig; + +export type SignalAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** If false, do not start this Signal account. Default: true. */ enabled?: boolean; /** Optional explicit E.164 account for signal-cli. */ account?: string; @@ -527,8 +552,15 @@ export type SignalConfig = { mediaMaxMb?: number; }; -export type IMessageConfig = { - /** If false, do not start the iMessage provider. Default: true. */ +export type SignalConfig = { + /** Optional per-account Signal configuration (multi-account). */ + accounts?: Record; +} & SignalAccountConfig; + +export type IMessageAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** If false, do not start this iMessage account. Default: true. */ enabled?: boolean; /** imsg CLI binary path (default: imsg). */ cliPath?: string; @@ -565,6 +597,11 @@ export type IMessageConfig = { >; }; +export type IMessageConfig = { + /** Optional per-account iMessage configuration (multi-account). */ + accounts?: Record; +} & IMessageAccountConfig; + export type QueueMode = | "steer" | "followup" diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index c8f294c90..67845e6d5 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -89,6 +89,26 @@ const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]); const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]); +const normalizeAllowFrom = (values?: Array): string[] => + (values ?? []).map((v) => String(v).trim()).filter(Boolean); + +const requireOpenAllowFrom = (params: { + policy?: string; + allowFrom?: Array; + ctx: z.RefinementCtx; + path: Array; + message: string; +}) => { + if (params.policy !== "open") return; + const allow = normalizeAllowFrom(params.allowFrom); + if (allow.includes("*")) return; + params.ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: params.path, + message: params.message, + }); +}; + const RetryConfigSchema = z .object({ attempts: z.number().int().min(1).optional(), @@ -121,6 +141,316 @@ const HexColorSchema = z .string() .regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)"); +const TelegramTopicSchema = z.object({ + requireMention: z.boolean().optional(), + skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + systemPrompt: z.string().optional(), +}); + +const TelegramGroupSchema = z.object({ + requireMention: z.boolean().optional(), + skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + systemPrompt: z.string().optional(), + topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(), +}); + +const TelegramAccountSchemaBase = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + botToken: z.string().optional(), + tokenFile: z.string().optional(), + replyToMode: ReplyToModeSchema.optional(), + groups: z.record(z.string(), TelegramGroupSchema.optional()).optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), + textChunkLimit: z.number().int().positive().optional(), + streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"), + mediaMaxMb: z.number().positive().optional(), + retry: RetryConfigSchema, + proxy: z.string().optional(), + webhookUrl: z.string().optional(), + webhookSecret: z.string().optional(), + webhookPath: z.string().optional(), + actions: z + .object({ + reactions: z.boolean().optional(), + }) + .optional(), +}); + +const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine( + (value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'telegram.dmPolicy="open" requires telegram.allowFrom to include "*"', + }); + }, +); + +const TelegramConfigSchema = TelegramAccountSchemaBase.extend({ + accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(), +}).superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'telegram.dmPolicy="open" requires telegram.allowFrom to include "*"', + }); +}); + +const DiscordDmSchema = z + .object({ + enabled: z.boolean().optional(), + policy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupEnabled: z.boolean().optional(), + groupChannels: z.array(z.union([z.string(), z.number()])).optional(), + }) + .superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.policy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'discord.dm.policy="open" requires discord.dm.allowFrom to include "*"', + }); + }); + +const DiscordGuildChannelSchema = z.object({ + allow: z.boolean().optional(), + requireMention: z.boolean().optional(), + skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + users: z.array(z.union([z.string(), z.number()])).optional(), + systemPrompt: z.string().optional(), +}); + +const DiscordGuildSchema = z.object({ + slug: z.string().optional(), + requireMention: z.boolean().optional(), + reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), + users: z.array(z.union([z.string(), z.number()])).optional(), + channels: z + .record(z.string(), DiscordGuildChannelSchema.optional()) + .optional(), +}); + +const DiscordAccountSchema = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + token: z.string().optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), + textChunkLimit: z.number().int().positive().optional(), + mediaMaxMb: z.number().positive().optional(), + historyLimit: z.number().int().min(0).optional(), + retry: RetryConfigSchema, + actions: z + .object({ + reactions: z.boolean().optional(), + stickers: z.boolean().optional(), + polls: z.boolean().optional(), + permissions: z.boolean().optional(), + messages: z.boolean().optional(), + threads: z.boolean().optional(), + pins: z.boolean().optional(), + search: z.boolean().optional(), + memberInfo: z.boolean().optional(), + roleInfo: z.boolean().optional(), + roles: z.boolean().optional(), + channelInfo: z.boolean().optional(), + voiceStatus: z.boolean().optional(), + events: z.boolean().optional(), + moderation: z.boolean().optional(), + }) + .optional(), + replyToMode: ReplyToModeSchema.optional(), + dm: DiscordDmSchema.optional(), + guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(), +}); + +const DiscordConfigSchema = DiscordAccountSchema.extend({ + accounts: z.record(z.string(), DiscordAccountSchema.optional()).optional(), +}); + +const SlackDmSchema = z + .object({ + enabled: z.boolean().optional(), + policy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupEnabled: z.boolean().optional(), + groupChannels: z.array(z.union([z.string(), z.number()])).optional(), + }) + .superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.policy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'slack.dm.policy="open" requires slack.dm.allowFrom to include "*"', + }); + }); + +const SlackChannelSchema = z.object({ + enabled: z.boolean().optional(), + allow: z.boolean().optional(), + requireMention: z.boolean().optional(), + users: z.array(z.union([z.string(), z.number()])).optional(), + skills: z.array(z.string()).optional(), + systemPrompt: z.string().optional(), +}); + +const SlackAccountSchema = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + botToken: z.string().optional(), + appToken: z.string().optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), + textChunkLimit: z.number().int().positive().optional(), + mediaMaxMb: z.number().positive().optional(), + reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), + reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(), + actions: z + .object({ + reactions: z.boolean().optional(), + messages: z.boolean().optional(), + pins: z.boolean().optional(), + search: z.boolean().optional(), + permissions: z.boolean().optional(), + memberInfo: z.boolean().optional(), + channelInfo: z.boolean().optional(), + emojiList: z.boolean().optional(), + }) + .optional(), + slashCommand: z + .object({ + enabled: z.boolean().optional(), + name: z.string().optional(), + sessionPrefix: z.string().optional(), + ephemeral: z.boolean().optional(), + }) + .optional(), + dm: SlackDmSchema.optional(), + channels: z.record(z.string(), SlackChannelSchema.optional()).optional(), +}); + +const SlackConfigSchema = SlackAccountSchema.extend({ + accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(), +}); + +const SignalAccountSchemaBase = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + account: z.string().optional(), + httpUrl: z.string().optional(), + httpHost: z.string().optional(), + httpPort: z.number().int().positive().optional(), + cliPath: z.string().optional(), + autoStart: z.boolean().optional(), + receiveMode: z.union([z.literal("on-start"), z.literal("manual")]).optional(), + ignoreAttachments: z.boolean().optional(), + ignoreStories: z.boolean().optional(), + sendReadReceipts: z.boolean().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), + textChunkLimit: z.number().int().positive().optional(), + mediaMaxMb: z.number().int().positive().optional(), +}); + +const SignalAccountSchema = SignalAccountSchemaBase.superRefine( + (value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'signal.dmPolicy="open" requires signal.allowFrom to include "*"', + }); + }, +); + +const SignalConfigSchema = SignalAccountSchemaBase.extend({ + accounts: z.record(z.string(), SignalAccountSchema.optional()).optional(), +}).superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: 'signal.dmPolicy="open" requires signal.allowFrom to include "*"', + }); +}); + +const IMessageAccountSchemaBase = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + cliPath: z.string().optional(), + dbPath: z.string().optional(), + service: z + .union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]) + .optional(), + region: z.string().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("open"), + includeAttachments: z.boolean().optional(), + mediaMaxMb: z.number().int().positive().optional(), + textChunkLimit: z.number().int().positive().optional(), + groups: z + .record( + z.string(), + z + .object({ + requireMention: z.boolean().optional(), + }) + .optional(), + ) + .optional(), +}); + +const IMessageAccountSchema = IMessageAccountSchemaBase.superRefine( + (value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'imessage.dmPolicy="open" requires imessage.allowFrom to include "*"', + }); + }, +); + +const IMessageConfigSchema = IMessageAccountSchemaBase.extend({ + accounts: z.record(z.string(), IMessageAccountSchema.optional()).optional(), +}).superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'imessage.dmPolicy="open" requires imessage.allowFrom to include "*"', + }); +}); + const SessionSchema = z .object({ scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(), @@ -777,6 +1107,7 @@ export const ClawdbotSchema = z.object({ z.string(), z .object({ + name: z.string().optional(), enabled: z.boolean().optional(), /** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */ authDir: z.string().optional(), @@ -849,311 +1180,12 @@ export const ClawdbotSchema = z.object({ }); }) .optional(), - telegram: z - .object({ - enabled: z.boolean().optional(), - dmPolicy: DmPolicySchema.optional().default("pairing"), - botToken: z.string().optional(), - tokenFile: z.string().optional(), - replyToMode: ReplyToModeSchema.optional(), - groups: z - .record( - z.string(), - z - .object({ - requireMention: z.boolean().optional(), - skills: z.array(z.string()).optional(), - enabled: z.boolean().optional(), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), - systemPrompt: z.string().optional(), - topics: z - .record( - z.string(), - z - .object({ - requireMention: z.boolean().optional(), - skills: z.array(z.string()).optional(), - enabled: z.boolean().optional(), - allowFrom: z - .array(z.union([z.string(), z.number()])) - .optional(), - systemPrompt: z.string().optional(), - }) - .optional(), - ) - .optional(), - }) - .optional(), - ) - .optional(), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), - groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), - groupPolicy: GroupPolicySchema.optional().default("open"), - textChunkLimit: z.number().int().positive().optional(), - streamMode: z - .enum(["off", "partial", "block"]) - .optional() - .default("partial"), - mediaMaxMb: z.number().positive().optional(), - retry: RetryConfigSchema, - proxy: z.string().optional(), - webhookUrl: z.string().optional(), - webhookSecret: z.string().optional(), - webhookPath: z.string().optional(), - actions: z - .object({ - reactions: z.boolean().optional(), - }) - .optional(), - }) - .superRefine((value, ctx) => { - if (value.dmPolicy !== "open") return; - const allow = (value.allowFrom ?? []) - .map((v) => String(v).trim()) - .filter(Boolean); - if (allow.includes("*")) return; - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["allowFrom"], - message: - 'telegram.dmPolicy="open" requires telegram.allowFrom to include "*"', - }); - }) - .optional(), - discord: z - .object({ - enabled: z.boolean().optional(), - token: z.string().optional(), - groupPolicy: GroupPolicySchema.optional().default("open"), - textChunkLimit: z.number().int().positive().optional(), - mediaMaxMb: z.number().positive().optional(), - historyLimit: z.number().int().min(0).optional(), - retry: RetryConfigSchema, - actions: z - .object({ - reactions: z.boolean().optional(), - stickers: z.boolean().optional(), - polls: z.boolean().optional(), - permissions: z.boolean().optional(), - messages: z.boolean().optional(), - threads: z.boolean().optional(), - pins: z.boolean().optional(), - search: z.boolean().optional(), - memberInfo: z.boolean().optional(), - roleInfo: z.boolean().optional(), - roles: z.boolean().optional(), - channelInfo: z.boolean().optional(), - voiceStatus: z.boolean().optional(), - events: z.boolean().optional(), - moderation: z.boolean().optional(), - }) - .optional(), - replyToMode: ReplyToModeSchema.optional(), - dm: z - .object({ - enabled: z.boolean().optional(), - policy: DmPolicySchema.optional().default("pairing"), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), - groupEnabled: z.boolean().optional(), - groupChannels: z.array(z.union([z.string(), z.number()])).optional(), - }) - .superRefine((value, ctx) => { - if (value.policy !== "open") return; - const allow = (value.allowFrom ?? []) - .map((v) => String(v).trim()) - .filter(Boolean); - if (allow.includes("*")) return; - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["allowFrom"], - message: - 'discord.dm.policy="open" requires discord.dm.allowFrom to include "*"', - }); - }) - .optional(), - guilds: z - .record( - z.string(), - z - .object({ - slug: z.string().optional(), - requireMention: z.boolean().optional(), - reactionNotifications: z - .enum(["off", "own", "all", "allowlist"]) - .optional(), - users: z.array(z.union([z.string(), z.number()])).optional(), - channels: z - .record( - z.string(), - z - .object({ - allow: z.boolean().optional(), - requireMention: z.boolean().optional(), - skills: z.array(z.string()).optional(), - enabled: z.boolean().optional(), - users: z - .array(z.union([z.string(), z.number()])) - .optional(), - systemPrompt: z.string().optional(), - }) - .optional(), - ) - .optional(), - }) - .optional(), - ) - .optional(), - }) - .optional(), - slack: z - .object({ - enabled: z.boolean().optional(), - botToken: z.string().optional(), - appToken: z.string().optional(), - groupPolicy: GroupPolicySchema.optional().default("open"), - textChunkLimit: z.number().int().positive().optional(), - mediaMaxMb: z.number().positive().optional(), - reactionNotifications: z - .enum(["off", "own", "all", "allowlist"]) - .optional(), - reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(), - replyToMode: ReplyToModeSchema.optional(), - actions: z - .object({ - reactions: z.boolean().optional(), - messages: z.boolean().optional(), - pins: z.boolean().optional(), - search: z.boolean().optional(), - permissions: z.boolean().optional(), - memberInfo: z.boolean().optional(), - channelInfo: z.boolean().optional(), - emojiList: z.boolean().optional(), - }) - .optional(), - slashCommand: z - .object({ - enabled: z.boolean().optional(), - name: z.string().optional(), - sessionPrefix: z.string().optional(), - ephemeral: z.boolean().optional(), - }) - .optional(), - dm: z - .object({ - enabled: z.boolean().optional(), - policy: DmPolicySchema.optional().default("pairing"), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), - groupEnabled: z.boolean().optional(), - groupChannels: z.array(z.union([z.string(), z.number()])).optional(), - }) - .superRefine((value, ctx) => { - if (value.policy !== "open") return; - const allow = (value.allowFrom ?? []) - .map((v) => String(v).trim()) - .filter(Boolean); - if (allow.includes("*")) return; - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["allowFrom"], - message: - 'slack.dm.policy="open" requires slack.dm.allowFrom to include "*"', - }); - }) - .optional(), - channels: z - .record( - z.string(), - z - .object({ - enabled: z.boolean().optional(), - allow: z.boolean().optional(), - requireMention: z.boolean().optional(), - users: z.array(z.union([z.string(), z.number()])).optional(), - skills: z.array(z.string()).optional(), - systemPrompt: z.string().optional(), - }) - .optional(), - ) - .optional(), - }) - .optional(), - signal: z - .object({ - enabled: z.boolean().optional(), - account: z.string().optional(), - httpUrl: z.string().optional(), - httpHost: z.string().optional(), - httpPort: z.number().int().positive().optional(), - cliPath: z.string().optional(), - autoStart: z.boolean().optional(), - receiveMode: z - .union([z.literal("on-start"), z.literal("manual")]) - .optional(), - ignoreAttachments: z.boolean().optional(), - ignoreStories: z.boolean().optional(), - sendReadReceipts: z.boolean().optional(), - dmPolicy: DmPolicySchema.optional().default("pairing"), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), - groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), - groupPolicy: GroupPolicySchema.optional().default("open"), - textChunkLimit: z.number().int().positive().optional(), - mediaMaxMb: z.number().int().positive().optional(), - }) - .superRefine((value, ctx) => { - if (value.dmPolicy !== "open") return; - const allow = (value.allowFrom ?? []) - .map((v) => String(v).trim()) - .filter(Boolean); - if (allow.includes("*")) return; - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["allowFrom"], - message: - 'signal.dmPolicy="open" requires signal.allowFrom to include "*"', - }); - }) - .optional(), - imessage: z - .object({ - enabled: z.boolean().optional(), - cliPath: z.string().optional(), - dbPath: z.string().optional(), - service: z - .union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]) - .optional(), - region: z.string().optional(), - dmPolicy: DmPolicySchema.optional().default("pairing"), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), - groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), - groupPolicy: GroupPolicySchema.optional().default("open"), - includeAttachments: z.boolean().optional(), - mediaMaxMb: z.number().int().positive().optional(), - textChunkLimit: z.number().int().positive().optional(), - groups: z - .record( - z.string(), - z - .object({ - requireMention: z.boolean().optional(), - }) - .optional(), - ) - .optional(), - }) - .superRefine((value, ctx) => { - if (value.dmPolicy !== "open") return; - const allow = (value.allowFrom ?? []) - .map((v) => String(v).trim()) - .filter(Boolean); - if (allow.includes("*")) return; - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["allowFrom"], - message: - 'imessage.dmPolicy="open" requires imessage.allowFrom to include "*"', - }); - }) - .optional(), + + telegram: TelegramConfigSchema.optional(), + discord: DiscordConfigSchema.optional(), + slack: SlackConfigSchema.optional(), + signal: SignalConfigSchema.optional(), + imessage: IMessageConfigSchema.optional(), bridge: z .object({ enabled: z.boolean().optional(), diff --git a/src/discord/accounts.ts b/src/discord/accounts.ts new file mode 100644 index 000000000..1728c0d76 --- /dev/null +++ b/src/discord/accounts.ts @@ -0,0 +1,81 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import type { DiscordAccountConfig } from "../config/types.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../routing/session-key.js"; +import { resolveDiscordToken } from "./token.js"; + +export type ResolvedDiscordAccount = { + accountId: string; + enabled: boolean; + name?: string; + token: string; + tokenSource: "env" | "config" | "none"; + config: DiscordAccountConfig; +}; + +function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] { + const accounts = cfg.discord?.accounts; + if (!accounts || typeof accounts !== "object") return []; + return Object.keys(accounts).filter(Boolean); +} + +export function listDiscordAccountIds(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 resolveDefaultDiscordAccountId(cfg: ClawdbotConfig): string { + const ids = listDiscordAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function resolveAccountConfig( + cfg: ClawdbotConfig, + accountId: string, +): DiscordAccountConfig | undefined { + const accounts = cfg.discord?.accounts; + if (!accounts || typeof accounts !== "object") return undefined; + return accounts[accountId] as DiscordAccountConfig | undefined; +} + +function mergeDiscordAccountConfig( + cfg: ClawdbotConfig, + accountId: string, +): DiscordAccountConfig { + const { accounts: _ignored, ...base } = (cfg.discord ?? + {}) as DiscordAccountConfig & { accounts?: unknown }; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account }; +} + +export function resolveDiscordAccount(params: { + cfg: ClawdbotConfig; + accountId?: string | null; +}): ResolvedDiscordAccount { + const accountId = normalizeAccountId(params.accountId); + const baseEnabled = params.cfg.discord?.enabled !== false; + const merged = mergeDiscordAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + const tokenResolution = resolveDiscordToken(params.cfg, { accountId }); + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: tokenResolution.token, + tokenSource: tokenResolution.source, + config: merged, + }; +} + +export function listEnabledDiscordAccounts( + cfg: ClawdbotConfig, +): ResolvedDiscordAccount[] { + return listDiscordAccountIds(cfg) + .map((accountId) => resolveDiscordAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts index 9606b0388..26c824a6d 100644 --- a/src/discord/monitor.tool-result.test.ts +++ b/src/discord/monitor.tool-result.test.ts @@ -46,6 +46,8 @@ describe("discord tool result dispatch", () => { const runtimeError = vi.fn(); const handler = createDiscordMessageHandler({ cfg, + discordConfig: cfg.discord, + accountId: "default", token: "token", runtime: { log: vi.fn(), @@ -115,6 +117,8 @@ describe("discord tool result dispatch", () => { const handler = createDiscordMessageHandler({ cfg, + discordConfig: cfg.discord, + accountId: "default", token: "token", runtime: { log: vi.fn(), @@ -197,6 +201,8 @@ describe("discord tool result dispatch", () => { const handler = createDiscordMessageHandler({ cfg, + discordConfig: cfg.discord, + accountId: "default", token: "token", runtime: { log: vi.fn(), @@ -306,6 +312,8 @@ describe("discord tool result dispatch", () => { const handler = createDiscordMessageHandler({ cfg, + discordConfig: cfg.discord, + accountId: "default", token: "token", runtime: { log: vi.fn(), diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 0c3805ea7..ca6b2d254 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -42,7 +42,7 @@ import { } from "../auto-reply/reply/reply-dispatcher.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; -import type { ReplyToMode } from "../config/config.js"; +import type { ClawdbotConfig, ReplyToMode } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; @@ -62,12 +62,15 @@ import { import { resolveThreadSessionKeys } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { loadWebMedia } from "../web/media.js"; +import { resolveDiscordAccount } from "./accounts.js"; import { fetchDiscordApplicationId } from "./probe.js"; import { reactMessageDiscord, sendMessageDiscord } from "./send.js"; import { normalizeDiscordToken } from "./token.js"; export type MonitorDiscordOpts = { token?: string; + accountId?: string; + config?: ClawdbotConfig; runtime?: RuntimeEnv; abortSignal?: AbortSignal; mediaMaxMb?: number; @@ -244,16 +247,15 @@ function summarizeGuilds(entries?: Record) { } export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { - const cfg = loadConfig(); - const token = normalizeDiscordToken( - opts.token ?? - process.env.DISCORD_BOT_TOKEN ?? - cfg.discord?.token ?? - undefined, - ); + const cfg = opts.config ?? loadConfig(); + const account = resolveDiscordAccount({ + cfg, + accountId: opts.accountId, + }); + const token = normalizeDiscordToken(opts.token ?? undefined) ?? account.token; if (!token) { throw new Error( - "DISCORD_BOT_TOKEN or discord.token is required for Discord gateway", + `Discord bot token missing for account "${account.accountId}" (set discord.accounts.${account.accountId}.token or DISCORD_BOT_TOKEN for default).`, ); } @@ -265,18 +267,19 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }, }; - const dmConfig = cfg.discord?.dm; - const guildEntries = cfg.discord?.guilds; - const groupPolicy = cfg.discord?.groupPolicy ?? "open"; + const discordCfg = account.config; + const dmConfig = discordCfg.dm; + const guildEntries = discordCfg.guilds; + const groupPolicy = discordCfg.groupPolicy ?? "open"; const allowFrom = dmConfig?.allowFrom; const mediaMaxBytes = - (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; - const textLimit = resolveTextChunkLimit(cfg, "discord"); + (opts.mediaMaxMb ?? discordCfg.mediaMaxMb ?? 8) * 1024 * 1024; + const textLimit = resolveTextChunkLimit(cfg, "discord", account.accountId); const historyLimit = Math.max( 0, - opts.historyLimit ?? cfg.discord?.historyLimit ?? 20, + opts.historyLimit ?? discordCfg.historyLimit ?? 20, ); - const replyToMode = opts.replyToMode ?? cfg.discord?.replyToMode ?? "off"; + const replyToMode = opts.replyToMode ?? discordCfg.replyToMode ?? "off"; const dmEnabled = dmConfig?.enabled ?? true; const dmPolicy = dmConfig?.policy ?? "pairing"; const groupDmEnabled = dmConfig?.groupEnabled ?? false; @@ -303,6 +306,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { createDiscordNativeCommand({ command: spec, cfg, + discordConfig: discordCfg, + accountId: account.accountId, sessionPrefix, ephemeralDefault, }), @@ -359,6 +364,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const messageHandler = createDiscordMessageHandler({ cfg, + discordConfig: discordCfg, + accountId: account.accountId, token, runtime, botUserId, @@ -377,6 +384,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { client.listeners.push(new DiscordMessageListener(messageHandler, logger)); client.listeners.push( new DiscordReactionListener({ + cfg, + accountId: account.accountId, runtime, botUserId, guildEntries, @@ -385,6 +394,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { ); client.listeners.push( new DiscordReactionRemoveListener({ + cfg, + accountId: account.accountId, runtime, botUserId, guildEntries, @@ -431,6 +442,8 @@ async function clearDiscordNativeCommands(params: { export function createDiscordMessageHandler(params: { cfg: ReturnType; + discordConfig: ClawdbotConfig["discord"]; + accountId: string; token: string; runtime: RuntimeEnv; botUserId?: string; @@ -447,6 +460,8 @@ export function createDiscordMessageHandler(params: { }): DiscordMessageHandler { const { cfg, + discordConfig, + accountId, token, runtime, botUserId, @@ -465,7 +480,7 @@ export function createDiscordMessageHandler(params: { const mentionRegexes = buildMentionRegexes(cfg); const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; - const groupPolicy = cfg.discord?.groupPolicy ?? "open"; + const groupPolicy = discordConfig?.groupPolicy ?? "open"; return async (data, client) => { try { @@ -490,7 +505,7 @@ export function createDiscordMessageHandler(params: { return; } - const dmPolicy = cfg.discord?.dm?.policy ?? "pairing"; + const dmPolicy = discordConfig?.dm?.policy ?? "pairing"; let commandAuthorized = true; if (isDirectMessage) { if (dmPolicy === "disabled") { @@ -539,7 +554,7 @@ export function createDiscordMessageHandler(params: { "Ask the bot owner to approve with:", "clawdbot pairing approve --provider discord ", ].join("\n"), - { token, rest: client.rest }, + { token, rest: client.rest, accountId }, ); } catch (err) { logVerbose( @@ -633,6 +648,7 @@ export function createDiscordMessageHandler(params: { const route = resolveAgentRoute({ cfg, provider: "discord", + accountId, guildId: data.guild_id ?? undefined, peer: { kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel", @@ -988,6 +1004,7 @@ export function createDiscordMessageHandler(params: { replies: [payload], target: replyTarget, token, + accountId, rest: client.rest, runtime, replyToMode, @@ -1068,6 +1085,8 @@ class DiscordMessageListener extends MessageCreateListener { class DiscordReactionListener extends MessageReactionAddListener { constructor( private params: { + cfg: ReturnType; + accountId: string; runtime: RuntimeEnv; botUserId?: string; guildEntries?: Record; @@ -1084,6 +1103,8 @@ class DiscordReactionListener extends MessageReactionAddListener { data, client, action: "added", + cfg: this.params.cfg, + accountId: this.params.accountId, botUserId: this.params.botUserId, guildEntries: this.params.guildEntries, logger: this.params.logger, @@ -1102,6 +1123,8 @@ class DiscordReactionListener extends MessageReactionAddListener { class DiscordReactionRemoveListener extends MessageReactionRemoveListener { constructor( private params: { + cfg: ReturnType; + accountId: string; runtime: RuntimeEnv; botUserId?: string; guildEntries?: Record; @@ -1118,6 +1141,8 @@ class DiscordReactionRemoveListener extends MessageReactionRemoveListener { data, client, action: "removed", + cfg: this.params.cfg, + accountId: this.params.accountId, botUserId: this.params.botUserId, guildEntries: this.params.guildEntries, logger: this.params.logger, @@ -1137,6 +1162,8 @@ async function handleDiscordReactionEvent(params: { data: DiscordReactionEvent; client: Client; action: "added" | "removed"; + cfg: ReturnType; + accountId: string; botUserId?: string; guildEntries?: Record; logger: ReturnType; @@ -1202,10 +1229,10 @@ async function handleDiscordReactionEvent(params: { : undefined; const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${data.message_id}`; const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; - const cfg = loadConfig(); const route = resolveAgentRoute({ - cfg, + cfg: params.cfg, provider: "discord", + accountId: params.accountId, guildId: data.guild_id ?? undefined, peer: { kind: "channel", id: data.channel_id }, }); @@ -1227,10 +1254,19 @@ function createDiscordNativeCommand(params: { acceptsArgs: boolean; }; cfg: ReturnType; + discordConfig: ClawdbotConfig["discord"]; + accountId: string; sessionPrefix: string; ephemeralDefault: boolean; }) { - const { command, cfg, sessionPrefix, ephemeralDefault } = params; + const { + command, + cfg, + discordConfig, + accountId, + sessionPrefix, + ephemeralDefault, + } = params; return new (class extends Command { name = command.name; description = command.description; @@ -1266,7 +1302,7 @@ function createDiscordNativeCommand(params: { ); const guildInfo = resolveDiscordGuildEntry({ guild: interaction.guild ?? undefined, - guildEntries: cfg.discord?.guilds, + guildEntries: discordConfig?.guilds, }); const channelConfig = interaction.guild ? resolveDiscordChannelConfig({ @@ -1294,7 +1330,7 @@ function createDiscordNativeCommand(params: { Object.keys(guildInfo?.channels ?? {}).length > 0; const channelAllowed = channelConfig?.allowed !== false; const allowByPolicy = isDiscordGroupAllowedByPolicy({ - groupPolicy: cfg.discord?.groupPolicy ?? "open", + groupPolicy: discordConfig?.groupPolicy ?? "open", channelAllowlistConfigured, channelAllowed, }); @@ -1305,8 +1341,8 @@ function createDiscordNativeCommand(params: { return; } } - const dmEnabled = cfg.discord?.dm?.enabled ?? true; - const dmPolicy = cfg.discord?.dm?.policy ?? "pairing"; + const dmEnabled = discordConfig?.dm?.enabled ?? true; + const dmPolicy = discordConfig?.dm?.policy ?? "pairing"; let commandAuthorized = true; if (isDirectMessage) { if (!dmEnabled || dmPolicy === "disabled") { @@ -1318,7 +1354,7 @@ function createDiscordNativeCommand(params: { "discord", ).catch(() => []); const effectiveAllowFrom = [ - ...(cfg.discord?.dm?.allowFrom ?? []), + ...(discordConfig?.dm?.allowFrom ?? []), ...storeAllowFrom, ]; const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [ @@ -1384,7 +1420,7 @@ function createDiscordNativeCommand(params: { } } } - if (isGroupDm && cfg.discord?.dm?.groupEnabled === false) { + if (isGroupDm && discordConfig?.dm?.groupEnabled === false) { await interaction.reply({ content: "Discord group DMs are disabled." }); return; } @@ -1395,6 +1431,7 @@ function createDiscordNativeCommand(params: { const route = resolveAgentRoute({ cfg, provider: "discord", + accountId, guildId: interaction.guild?.id ?? undefined, peer: { kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel", @@ -1544,6 +1581,7 @@ async function deliverDiscordReply(params: { replies: ReplyPayload[]; target: string; token: string; + accountId?: string; rest?: RequestClient; runtime: RuntimeEnv; textLimit: number; @@ -1563,6 +1601,7 @@ async function deliverDiscordReply(params: { await sendMessageDiscord(params.target, trimmed, { token: params.token, rest: params.rest, + accountId: params.accountId, }); } continue; @@ -1574,12 +1613,14 @@ async function deliverDiscordReply(params: { token: params.token, rest: params.rest, mediaUrl: firstMedia, + accountId: params.accountId, }); for (const extra of mediaList.slice(1)) { await sendMessageDiscord(params.target, "", { token: params.token, rest: params.rest, mediaUrl: extra, + accountId: params.accountId, }); } } diff --git a/src/discord/send.ts b/src/discord/send.ts index f07040dfb..979f90547 100644 --- a/src/discord/send.ts +++ b/src/discord/send.ts @@ -30,6 +30,7 @@ import { type PollInput, } from "../polls.js"; import { loadWebMedia, loadWebMediaRaw } from "../web/media.js"; +import { resolveDiscordAccount } from "./accounts.js"; import { normalizeDiscordToken } from "./token.js"; const DISCORD_TEXT_LIMIT = 2000; @@ -74,6 +75,7 @@ type DiscordRecipient = type DiscordSendOpts = { token?: string; + accountId?: string; mediaUrl?: string; verbose?: boolean; rest?: RequestClient; @@ -88,6 +90,7 @@ export type DiscordSendResult = { export type DiscordReactOpts = { token?: string; + accountId?: string; rest?: RequestClient; verbose?: boolean; retry?: RetryConfig; @@ -179,17 +182,20 @@ export type DiscordStickerUpload = { mediaUrl: string; }; -function resolveToken(explicit?: string) { - const cfgToken = loadConfig().discord?.token; - const token = normalizeDiscordToken( - explicit ?? process.env.DISCORD_BOT_TOKEN ?? cfgToken ?? undefined, - ); - if (!token) { +function resolveToken(params: { + explicit?: string; + accountId: string; + fallbackToken?: string; +}) { + const explicit = normalizeDiscordToken(params.explicit); + if (explicit) return explicit; + const fallback = normalizeDiscordToken(params.fallbackToken); + if (!fallback) { throw new Error( - "DISCORD_BOT_TOKEN or discord.token is required for Discord sends", + `Discord bot token missing for account "${params.accountId}" (set discord.accounts.${params.accountId}.token or DISCORD_BOT_TOKEN for default).`, ); } - return token; + return fallback; } function resolveRest(token: string, rest?: RequestClient) { @@ -198,22 +204,32 @@ function resolveRest(token: string, rest?: RequestClient) { type DiscordClientOpts = { token?: string; + accountId?: string; rest?: RequestClient; retry?: RetryConfig; verbose?: boolean; }; function createDiscordClient(opts: DiscordClientOpts, cfg = loadConfig()) { - const token = resolveToken(opts.token); + const account = resolveDiscordAccount({ cfg, accountId: opts.accountId }); + const token = resolveToken({ + explicit: opts.token, + accountId: account.accountId, + fallbackToken: account.token, + }); const rest = resolveRest(token, opts.rest); const request = createDiscordRetryRunner({ retry: opts.retry, - configRetry: cfg.discord?.retry, + configRetry: account.config.retry, verbose: opts.verbose, }); return { token, rest, request }; } +function resolveDiscordRest(opts: DiscordClientOpts) { + return createDiscordClient(opts).rest; +} + function normalizeReactionEmoji(raw: string) { const trimmed = raw.trim(); if (!trimmed) { @@ -635,8 +651,7 @@ export async function removeReactionDiscord( emoji: string, opts: DiscordReactOpts = {}, ) { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); const encoded = normalizeReactionEmoji(emoji); await rest.delete( Routes.channelMessageOwnReaction(channelId, messageId, encoded), @@ -649,8 +664,7 @@ export async function removeOwnReactionsDiscord( messageId: string, opts: DiscordReactOpts = {}, ): Promise<{ ok: true; removed: string[] }> { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); const message = (await rest.get( Routes.channelMessage(channelId, messageId), )) as { @@ -683,8 +697,7 @@ export async function fetchReactionsDiscord( messageId: string, opts: DiscordReactOpts & { limit?: number } = {}, ): Promise { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); const message = (await rest.get( Routes.channelMessage(channelId, messageId), )) as { @@ -733,8 +746,7 @@ export async function fetchChannelPermissionsDiscord( channelId: string, opts: DiscordReactOpts = {}, ): Promise { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); const channel = (await rest.get(Routes.channel(channelId))) as APIChannel; const channelType = "type" in channel ? channel.type : undefined; const guildId = "guild_id" in channel ? channel.guild_id : undefined; @@ -808,8 +820,7 @@ export async function readMessagesDiscord( query: DiscordMessageQuery = {}, opts: DiscordReactOpts = {}, ): Promise { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); const limit = typeof query.limit === "number" && Number.isFinite(query.limit) ? Math.min(Math.max(Math.floor(query.limit), 1), 100) @@ -831,8 +842,7 @@ export async function editMessageDiscord( payload: DiscordMessageEdit, opts: DiscordReactOpts = {}, ): Promise { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); return (await rest.patch(Routes.channelMessage(channelId, messageId), { body: { content: payload.content }, })) as APIMessage; @@ -843,8 +853,7 @@ export async function deleteMessageDiscord( messageId: string, opts: DiscordReactOpts = {}, ) { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); await rest.delete(Routes.channelMessage(channelId, messageId)); return { ok: true }; } @@ -854,8 +863,7 @@ export async function pinMessageDiscord( messageId: string, opts: DiscordReactOpts = {}, ) { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); await rest.put(Routes.channelPin(channelId, messageId)); return { ok: true }; } @@ -865,8 +873,7 @@ export async function unpinMessageDiscord( messageId: string, opts: DiscordReactOpts = {}, ) { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); await rest.delete(Routes.channelPin(channelId, messageId)); return { ok: true }; } @@ -875,8 +882,7 @@ export async function listPinsDiscord( channelId: string, opts: DiscordReactOpts = {}, ): Promise { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); return (await rest.get(Routes.channelPins(channelId))) as APIMessage[]; } @@ -885,8 +891,7 @@ export async function createThreadDiscord( payload: DiscordThreadCreate, opts: DiscordReactOpts = {}, ) { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); const body: Record = { name: payload.name }; if (payload.autoArchiveMinutes) { body.auto_archive_duration = payload.autoArchiveMinutes; @@ -899,8 +904,7 @@ export async function listThreadsDiscord( payload: DiscordThreadList, opts: DiscordReactOpts = {}, ) { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); if (payload.includeArchived) { if (!payload.channelId) { throw new Error("channelId required to list archived threads"); @@ -920,8 +924,7 @@ export async function searchMessagesDiscord( query: DiscordSearchQuery, opts: DiscordReactOpts = {}, ) { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); const params = new URLSearchParams(); params.set("content", query.content); if (query.channelIds?.length) { @@ -947,8 +950,7 @@ export async function listGuildEmojisDiscord( guildId: string, opts: DiscordReactOpts = {}, ) { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); return await rest.get(Routes.guildEmojis(guildId)); } @@ -956,8 +958,7 @@ export async function uploadEmojiDiscord( payload: DiscordEmojiUpload, opts: DiscordReactOpts = {}, ) { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); const media = await loadWebMediaRaw( payload.mediaUrl, DISCORD_MAX_EMOJI_BYTES, @@ -986,8 +987,7 @@ export async function uploadStickerDiscord( payload: DiscordStickerUpload, opts: DiscordReactOpts = {}, ) { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); const media = await loadWebMediaRaw( payload.mediaUrl, DISCORD_MAX_STICKER_BYTES, @@ -1025,8 +1025,7 @@ export async function fetchMemberInfoDiscord( userId: string, opts: DiscordReactOpts = {}, ): Promise { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); return (await rest.get( Routes.guildMember(guildId, userId), )) as APIGuildMember; @@ -1036,8 +1035,7 @@ export async function fetchRoleInfoDiscord( guildId: string, opts: DiscordReactOpts = {}, ): Promise { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); return (await rest.get(Routes.guildRoles(guildId))) as APIRole[]; } @@ -1045,8 +1043,7 @@ export async function addRoleDiscord( payload: DiscordRoleChange, opts: DiscordReactOpts = {}, ) { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); await rest.put( Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId), ); @@ -1057,8 +1054,7 @@ export async function removeRoleDiscord( payload: DiscordRoleChange, opts: DiscordReactOpts = {}, ) { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); await rest.delete( Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId), ); @@ -1069,8 +1065,7 @@ export async function fetchChannelInfoDiscord( channelId: string, opts: DiscordReactOpts = {}, ): Promise { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); return (await rest.get(Routes.channel(channelId))) as APIChannel; } @@ -1078,8 +1073,7 @@ export async function listGuildChannelsDiscord( guildId: string, opts: DiscordReactOpts = {}, ): Promise { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); return (await rest.get(Routes.guildChannels(guildId))) as APIChannel[]; } @@ -1088,8 +1082,7 @@ export async function fetchVoiceStatusDiscord( userId: string, opts: DiscordReactOpts = {}, ): Promise { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); return (await rest.get( Routes.guildVoiceState(guildId, userId), )) as APIVoiceState; @@ -1099,8 +1092,7 @@ export async function listScheduledEventsDiscord( guildId: string, opts: DiscordReactOpts = {}, ): Promise { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); return (await rest.get( Routes.guildScheduledEvents(guildId), )) as APIGuildScheduledEvent[]; @@ -1111,8 +1103,7 @@ export async function createScheduledEventDiscord( payload: RESTPostAPIGuildScheduledEventJSONBody, opts: DiscordReactOpts = {}, ): Promise { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); return (await rest.post(Routes.guildScheduledEvents(guildId), { body: payload, })) as APIGuildScheduledEvent; @@ -1122,8 +1113,7 @@ export async function timeoutMemberDiscord( payload: DiscordTimeoutTarget, opts: DiscordReactOpts = {}, ): Promise { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); let until = payload.until; if (!until && payload.durationMinutes) { const ms = payload.durationMinutes * 60 * 1000; @@ -1144,8 +1134,7 @@ export async function kickMemberDiscord( payload: DiscordModerationTarget, opts: DiscordReactOpts = {}, ) { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); await rest.delete(Routes.guildMember(payload.guildId, payload.userId), { headers: payload.reason ? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) } @@ -1158,8 +1147,7 @@ export async function banMemberDiscord( payload: DiscordModerationTarget & { deleteMessageDays?: number }, opts: DiscordReactOpts = {}, ) { - const token = resolveToken(opts.token); - const rest = resolveRest(token, opts.rest); + const rest = resolveDiscordRest(opts); const deleteMessageDays = typeof payload.deleteMessageDays === "number" && Number.isFinite(payload.deleteMessageDays) diff --git a/src/discord/token.ts b/src/discord/token.ts index 83b292833..db15d08bc 100644 --- a/src/discord/token.ts +++ b/src/discord/token.ts @@ -1,6 +1,45 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../routing/session-key.js"; + +export type DiscordTokenSource = "env" | "config" | "none"; + +export type DiscordTokenResolution = { + token: string; + source: DiscordTokenSource; +}; + export function normalizeDiscordToken(raw?: string | null): string | undefined { if (!raw) return undefined; const trimmed = raw.trim(); if (!trimmed) return undefined; return trimmed.replace(/^Bot\s+/i, ""); } + +export function resolveDiscordToken( + cfg?: ClawdbotConfig, + opts: { accountId?: string | null; envToken?: string | null } = {}, +): DiscordTokenResolution { + const accountId = normalizeAccountId(opts.accountId); + const accountCfg = + accountId !== DEFAULT_ACCOUNT_ID + ? cfg?.discord?.accounts?.[accountId] + : cfg?.discord?.accounts?.[DEFAULT_ACCOUNT_ID]; + const accountToken = normalizeDiscordToken(accountCfg?.token ?? undefined); + if (accountToken) return { token: accountToken, source: "config" }; + + const allowEnv = accountId === DEFAULT_ACCOUNT_ID; + const envToken = allowEnv + ? normalizeDiscordToken(opts.envToken ?? process.env.DISCORD_BOT_TOKEN) + : undefined; + if (envToken) return { token: envToken, source: "env" }; + + const configToken = allowEnv + ? normalizeDiscordToken(cfg?.discord?.token ?? undefined) + : undefined; + if (configToken) return { token: configToken, source: "config" }; + + return { token: "", source: "none" }; +} diff --git a/src/gateway/server-methods/providers.ts b/src/gateway/server-methods/providers.ts index 00c2f7e10..a9318a0ee 100644 --- a/src/gateway/server-methods/providers.ts +++ b/src/gateway/server-methods/providers.ts @@ -4,16 +4,36 @@ import { readConfigFileSnapshot, writeConfigFile, } from "../../config/config.js"; +import { + listDiscordAccountIds, + resolveDefaultDiscordAccountId, + resolveDiscordAccount, +} from "../../discord/accounts.js"; import { type DiscordProbe, probeDiscord } from "../../discord/probe.js"; +import { + listIMessageAccountIds, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, +} from "../../imessage/accounts.js"; import { type IMessageProbe, probeIMessage } from "../../imessage/probe.js"; +import { + listSignalAccountIds, + resolveDefaultSignalAccountId, + resolveSignalAccount, +} from "../../signal/accounts.js"; import { probeSignal, type SignalProbe } from "../../signal/probe.js"; +import { + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, +} from "../../slack/accounts.js"; import { probeSlack, type SlackProbe } from "../../slack/probe.js"; import { - resolveSlackAppToken, - resolveSlackBotToken, -} from "../../slack/token.js"; + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, +} from "../../telegram/accounts.js"; import { probeTelegram, type TelegramProbe } from "../../telegram/probe.js"; -import { resolveTelegramToken } from "../../telegram/token.js"; import { listEnabledWhatsAppAccounts, resolveDefaultWhatsAppAccountId, @@ -50,112 +70,193 @@ export const providersHandlers: GatewayRequestHandlers = { const timeoutMs = typeof timeoutMsRaw === "number" ? Math.max(1000, timeoutMsRaw) : 10_000; const cfg = loadConfig(); - const telegramCfg = cfg.telegram; - const telegramEnabled = - Boolean(telegramCfg) && telegramCfg?.enabled !== false; - const { token: telegramToken, source: tokenSource } = telegramEnabled - ? resolveTelegramToken(cfg) - : { token: "", source: "none" as const }; - let telegramProbe: TelegramProbe | undefined; - let lastProbeAt: number | null = null; - if (probe && telegramToken && telegramEnabled) { - telegramProbe = await probeTelegram( - telegramToken, - timeoutMs, - telegramCfg?.proxy, - ); - lastProbeAt = Date.now(); - } + const runtime = context.getRuntimeSnapshot(); - const discordCfg = cfg.discord; - const discordEnabled = Boolean(discordCfg) && discordCfg?.enabled !== false; - const discordEnvToken = discordEnabled - ? process.env.DISCORD_BOT_TOKEN?.trim() - : ""; - const discordConfigToken = discordEnabled ? discordCfg?.token?.trim() : ""; - const discordToken = discordEnvToken || discordConfigToken || ""; - const discordTokenSource = discordEnvToken - ? "env" - : discordConfigToken - ? "config" - : "none"; - let discordProbe: DiscordProbe | undefined; - let discordLastProbeAt: number | null = null; - if (probe && discordToken && discordEnabled) { - discordProbe = await probeDiscord(discordToken, timeoutMs); - discordLastProbeAt = Date.now(); - } + const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg); + const defaultDiscordAccountId = resolveDefaultDiscordAccountId(cfg); + const defaultSlackAccountId = resolveDefaultSlackAccountId(cfg); + const defaultSignalAccountId = resolveDefaultSignalAccountId(cfg); + const defaultIMessageAccountId = resolveDefaultIMessageAccountId(cfg); - const slackCfg = cfg.slack; - const slackEnabled = slackCfg?.enabled !== false; - const slackBotEnvToken = slackEnabled - ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) - : undefined; - const slackBotConfigToken = slackEnabled - ? resolveSlackBotToken(slackCfg?.botToken) - : undefined; - const slackBotToken = slackBotEnvToken ?? slackBotConfigToken ?? ""; - const slackBotTokenSource = slackBotEnvToken - ? "env" - : slackBotConfigToken - ? "config" - : "none"; - const slackAppEnvToken = slackEnabled - ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) - : undefined; - const slackAppConfigToken = slackEnabled - ? resolveSlackAppToken(slackCfg?.appToken) - : undefined; - const slackAppToken = slackAppEnvToken ?? slackAppConfigToken ?? ""; - const slackAppTokenSource = slackAppEnvToken - ? "env" - : slackAppConfigToken - ? "config" - : "none"; - const slackConfigured = - slackEnabled && Boolean(slackBotToken) && Boolean(slackAppToken); - let slackProbe: SlackProbe | undefined; - let slackLastProbeAt: number | null = null; - if (probe && slackConfigured) { - slackProbe = await probeSlack(slackBotToken, timeoutMs); - slackLastProbeAt = Date.now(); - } + const telegramAccounts = await Promise.all( + listTelegramAccountIds(cfg).map(async (accountId) => { + const account = resolveTelegramAccount({ cfg, accountId }); + const rt = + runtime.telegramAccounts?.[account.accountId] ?? + (account.accountId === defaultTelegramAccountId + ? runtime.telegram + : undefined); + const configured = Boolean(account.token); + let telegramProbe: TelegramProbe | undefined; + let lastProbeAt: number | null = null; + if (probe && configured && account.enabled) { + telegramProbe = await probeTelegram( + account.token, + timeoutMs, + account.config.proxy, + ); + lastProbeAt = Date.now(); + } + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + tokenSource: account.tokenSource, + running: rt?.running ?? false, + mode: rt?.mode ?? (account.config.webhookUrl ? "webhook" : "polling"), + lastStartAt: rt?.lastStartAt ?? null, + lastStopAt: rt?.lastStopAt ?? null, + lastError: rt?.lastError ?? null, + probe: telegramProbe, + lastProbeAt, + }; + }), + ); + const defaultTelegramAccount = + telegramAccounts.find( + (account) => account.accountId === defaultTelegramAccountId, + ) ?? telegramAccounts[0]; - const signalCfg = cfg.signal; - const signalEnabled = signalCfg?.enabled !== false; - const signalHost = signalCfg?.httpHost?.trim() || "127.0.0.1"; - const signalPort = signalCfg?.httpPort ?? 8080; - const signalBaseUrl = - signalCfg?.httpUrl?.trim() || `http://${signalHost}:${signalPort}`; - const signalConfigured = - Boolean(signalCfg) && - signalEnabled && - Boolean( - signalCfg?.account?.trim() || - signalCfg?.httpUrl?.trim() || - signalCfg?.cliPath?.trim() || - signalCfg?.httpHost?.trim() || - typeof signalCfg?.httpPort === "number" || - typeof signalCfg?.autoStart === "boolean", - ); - let signalProbe: SignalProbe | undefined; - let signalLastProbeAt: number | null = null; - if (probe && signalConfigured) { - signalProbe = await probeSignal(signalBaseUrl, timeoutMs); - signalLastProbeAt = Date.now(); - } + const discordAccounts = await Promise.all( + listDiscordAccountIds(cfg).map(async (accountId) => { + const account = resolveDiscordAccount({ cfg, accountId }); + const rt = + runtime.discordAccounts?.[account.accountId] ?? + (account.accountId === defaultDiscordAccountId + ? runtime.discord + : undefined); + const configured = Boolean(account.token); + let discordProbe: DiscordProbe | undefined; + let lastProbeAt: number | null = null; + if (probe && configured && account.enabled) { + discordProbe = await probeDiscord(account.token, timeoutMs); + lastProbeAt = Date.now(); + } + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + tokenSource: account.tokenSource, + running: rt?.running ?? false, + lastStartAt: rt?.lastStartAt ?? null, + lastStopAt: rt?.lastStopAt ?? null, + lastError: rt?.lastError ?? null, + probe: discordProbe, + lastProbeAt, + }; + }), + ); + const defaultDiscordAccount = + discordAccounts.find( + (account) => account.accountId === defaultDiscordAccountId, + ) ?? discordAccounts[0]; - const imessageCfg = cfg.imessage; - const imessageEnabled = imessageCfg?.enabled !== false; - const imessageConfigured = Boolean(imessageCfg) && imessageEnabled; + const slackAccounts = await Promise.all( + listSlackAccountIds(cfg).map(async (accountId) => { + const account = resolveSlackAccount({ cfg, accountId }); + const rt = + runtime.slackAccounts?.[account.accountId] ?? + (account.accountId === defaultSlackAccountId + ? runtime.slack + : undefined); + const configured = Boolean(account.botToken && account.appToken); + let slackProbe: SlackProbe | undefined; + let lastProbeAt: number | null = null; + if (probe && configured && account.enabled && account.botToken) { + slackProbe = await probeSlack(account.botToken, timeoutMs); + lastProbeAt = Date.now(); + } + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + botTokenSource: account.botTokenSource, + appTokenSource: account.appTokenSource, + running: rt?.running ?? false, + lastStartAt: rt?.lastStartAt ?? null, + lastStopAt: rt?.lastStopAt ?? null, + lastError: rt?.lastError ?? null, + probe: slackProbe, + lastProbeAt, + }; + }), + ); + const defaultSlackAccount = + slackAccounts.find( + (account) => account.accountId === defaultSlackAccountId, + ) ?? slackAccounts[0]; + + const signalAccounts = await Promise.all( + listSignalAccountIds(cfg).map(async (accountId) => { + const account = resolveSignalAccount({ cfg, accountId }); + const rt = + runtime.signalAccounts?.[account.accountId] ?? + (account.accountId === defaultSignalAccountId + ? runtime.signal + : undefined); + const configured = account.configured; + let signalProbe: SignalProbe | undefined; + let lastProbeAt: number | null = null; + if (probe && configured && account.enabled) { + signalProbe = await probeSignal(account.baseUrl, timeoutMs); + lastProbeAt = Date.now(); + } + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + baseUrl: account.baseUrl, + running: rt?.running ?? false, + lastStartAt: rt?.lastStartAt ?? null, + lastStopAt: rt?.lastStopAt ?? null, + lastError: rt?.lastError ?? null, + probe: signalProbe, + lastProbeAt, + }; + }), + ); + const defaultSignalAccount = + signalAccounts.find( + (account) => account.accountId === defaultSignalAccountId, + ) ?? signalAccounts[0]; + + const imessageBaseConfigured = Boolean(cfg.imessage); let imessageProbe: IMessageProbe | undefined; let imessageLastProbeAt: number | null = null; - if (probe && imessageConfigured) { + if (probe && imessageBaseConfigured) { imessageProbe = await probeIMessage(timeoutMs); imessageLastProbeAt = Date.now(); } - - const runtime = context.getRuntimeSnapshot(); + const imessageAccounts = listIMessageAccountIds(cfg).map((accountId) => { + const account = resolveIMessageAccount({ cfg, accountId }); + const rt = + runtime.imessageAccounts?.[account.accountId] ?? + (account.accountId === defaultIMessageAccountId + ? runtime.imessage + : undefined); + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: imessageBaseConfigured, + running: rt?.running ?? false, + lastStartAt: rt?.lastStartAt ?? null, + lastStopAt: rt?.lastStopAt ?? null, + lastError: rt?.lastError ?? null, + cliPath: rt?.cliPath ?? account.config.cliPath ?? null, + dbPath: rt?.dbPath ?? account.config.dbPath ?? null, + probe: imessageProbe, + lastProbeAt: imessageLastProbeAt, + }; + }); + const defaultIMessageAccount = + imessageAccounts.find( + (account) => account.accountId === defaultIMessageAccountId, + ) ?? imessageAccounts[0]; const defaultWhatsAppAccountId = resolveDefaultWhatsAppAccountId(cfg); const enabledWhatsAppAccounts = listEnabledWhatsAppAccounts(cfg); const defaultWhatsAppAccount = @@ -226,58 +327,68 @@ export const providersHandlers: GatewayRequestHandlers = { whatsappAccounts, whatsappDefaultAccountId: defaultWhatsAppAccountId, telegram: { - configured: telegramEnabled && Boolean(telegramToken), - tokenSource, - running: runtime.telegram.running, - mode: runtime.telegram.mode ?? null, - lastStartAt: runtime.telegram.lastStartAt ?? null, - lastStopAt: runtime.telegram.lastStopAt ?? null, - lastError: runtime.telegram.lastError ?? null, - probe: telegramProbe, - lastProbeAt, + configured: defaultTelegramAccount?.configured ?? false, + tokenSource: defaultTelegramAccount?.tokenSource ?? "none", + running: defaultTelegramAccount?.running ?? false, + mode: defaultTelegramAccount?.mode ?? null, + lastStartAt: defaultTelegramAccount?.lastStartAt ?? null, + lastStopAt: defaultTelegramAccount?.lastStopAt ?? null, + lastError: defaultTelegramAccount?.lastError ?? null, + probe: defaultTelegramAccount?.probe, + lastProbeAt: defaultTelegramAccount?.lastProbeAt ?? null, }, + telegramAccounts, + telegramDefaultAccountId: defaultTelegramAccountId, discord: { - configured: discordEnabled && Boolean(discordToken), - tokenSource: discordTokenSource, - running: runtime.discord.running, - lastStartAt: runtime.discord.lastStartAt ?? null, - lastStopAt: runtime.discord.lastStopAt ?? null, - lastError: runtime.discord.lastError ?? null, - probe: discordProbe, - lastProbeAt: discordLastProbeAt, + configured: defaultDiscordAccount?.configured ?? false, + tokenSource: defaultDiscordAccount?.tokenSource ?? "none", + running: defaultDiscordAccount?.running ?? false, + lastStartAt: defaultDiscordAccount?.lastStartAt ?? null, + lastStopAt: defaultDiscordAccount?.lastStopAt ?? null, + lastError: defaultDiscordAccount?.lastError ?? null, + probe: defaultDiscordAccount?.probe, + lastProbeAt: defaultDiscordAccount?.lastProbeAt ?? null, }, + discordAccounts, + discordDefaultAccountId: defaultDiscordAccountId, slack: { - configured: slackConfigured, - botTokenSource: slackBotTokenSource, - appTokenSource: slackAppTokenSource, - running: runtime.slack.running, - lastStartAt: runtime.slack.lastStartAt ?? null, - lastStopAt: runtime.slack.lastStopAt ?? null, - lastError: runtime.slack.lastError ?? null, - probe: slackProbe, - lastProbeAt: slackLastProbeAt, + configured: defaultSlackAccount?.configured ?? false, + botTokenSource: defaultSlackAccount?.botTokenSource ?? "none", + appTokenSource: defaultSlackAccount?.appTokenSource ?? "none", + running: defaultSlackAccount?.running ?? false, + lastStartAt: defaultSlackAccount?.lastStartAt ?? null, + lastStopAt: defaultSlackAccount?.lastStopAt ?? null, + lastError: defaultSlackAccount?.lastError ?? null, + probe: defaultSlackAccount?.probe, + lastProbeAt: defaultSlackAccount?.lastProbeAt ?? null, }, + slackAccounts, + slackDefaultAccountId: defaultSlackAccountId, signal: { - configured: signalConfigured, - baseUrl: signalBaseUrl, - running: runtime.signal.running, - lastStartAt: runtime.signal.lastStartAt ?? null, - lastStopAt: runtime.signal.lastStopAt ?? null, - lastError: runtime.signal.lastError ?? null, - probe: signalProbe, - lastProbeAt: signalLastProbeAt, + configured: defaultSignalAccount?.configured ?? false, + baseUrl: defaultSignalAccount?.baseUrl ?? null, + running: defaultSignalAccount?.running ?? false, + lastStartAt: defaultSignalAccount?.lastStartAt ?? null, + lastStopAt: defaultSignalAccount?.lastStopAt ?? null, + lastError: defaultSignalAccount?.lastError ?? null, + probe: defaultSignalAccount?.probe, + lastProbeAt: defaultSignalAccount?.lastProbeAt ?? null, }, + signalAccounts, + signalDefaultAccountId: defaultSignalAccountId, imessage: { - configured: imessageConfigured, - running: runtime.imessage.running, - lastStartAt: runtime.imessage.lastStartAt ?? null, - lastStopAt: runtime.imessage.lastStopAt ?? null, - lastError: runtime.imessage.lastError ?? null, - cliPath: runtime.imessage.cliPath ?? null, - dbPath: runtime.imessage.dbPath ?? null, - probe: imessageProbe, - lastProbeAt: imessageLastProbeAt, + configured: defaultIMessageAccount?.configured ?? false, + running: defaultIMessageAccount?.running ?? false, + lastStartAt: defaultIMessageAccount?.lastStartAt ?? null, + lastStopAt: defaultIMessageAccount?.lastStopAt ?? null, + lastError: defaultIMessageAccount?.lastError ?? null, + cliPath: defaultIMessageAccount?.cliPath ?? null, + dbPath: defaultIMessageAccount?.dbPath ?? null, + probe: defaultIMessageAccount?.probe, + lastProbeAt: defaultIMessageAccount?.lastProbeAt ?? null, }, + imessageAccounts, + imessageDefaultAccountId: defaultIMessageAccountId, }, undefined, ); diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 08301d968..7da830bbf 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -5,7 +5,6 @@ import { sendMessageIMessage } from "../../imessage/index.js"; import { sendMessageSignal } from "../../signal/index.js"; import { sendMessageSlack } from "../../slack/send.js"; import { sendMessageTelegram } from "../../telegram/send.js"; -import { resolveTelegramToken } from "../../telegram/token.js"; import { normalizeMessageProvider } from "../../utils/message-provider.js"; import { resolveDefaultWhatsAppAccountId } from "../../web/accounts.js"; import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; @@ -53,14 +52,16 @@ export const sendHandlers: GatewayRequestHandlers = { const to = request.to.trim(); const message = request.message.trim(); const provider = normalizeMessageProvider(request.provider) ?? "whatsapp"; + const accountId = + typeof request.accountId === "string" && request.accountId.trim().length + ? request.accountId.trim() + : undefined; try { if (provider === "telegram") { - const cfg = loadConfig(); - const { token } = resolveTelegramToken(cfg); const result = await sendMessageTelegram(to, message, { mediaUrl: request.mediaUrl, verbose: shouldLogVerbose(), - token: token || undefined, + accountId, }); const payload = { runId: idem, @@ -77,7 +78,7 @@ export const sendHandlers: GatewayRequestHandlers = { } else if (provider === "discord") { const result = await sendMessageDiscord(to, message, { mediaUrl: request.mediaUrl, - token: process.env.DISCORD_BOT_TOKEN, + accountId, }); const payload = { runId: idem, @@ -94,6 +95,7 @@ export const sendHandlers: GatewayRequestHandlers = { } else if (provider === "slack") { const result = await sendMessageSlack(to, message, { mediaUrl: request.mediaUrl, + accountId, }); const payload = { runId: idem, @@ -108,14 +110,9 @@ export const sendHandlers: GatewayRequestHandlers = { }); respond(true, payload, undefined, { provider }); } else if (provider === "signal") { - const cfg = loadConfig(); - const host = cfg.signal?.httpHost?.trim() || "127.0.0.1"; - const port = cfg.signal?.httpPort ?? 8080; - const baseUrl = cfg.signal?.httpUrl?.trim() || `http://${host}:${port}`; const result = await sendMessageSignal(to, message, { mediaUrl: request.mediaUrl, - baseUrl, - account: cfg.signal?.account, + accountId, }); const payload = { runId: idem, @@ -129,14 +126,9 @@ export const sendHandlers: GatewayRequestHandlers = { }); respond(true, payload, undefined, { provider }); } else if (provider === "imessage") { - const cfg = loadConfig(); const result = await sendMessageIMessage(to, message, { mediaUrl: request.mediaUrl, - cliPath: cfg.imessage?.cliPath, - dbPath: cfg.imessage?.dbPath, - maxBytes: cfg.imessage?.mediaMaxMb - ? cfg.imessage.mediaMaxMb * 1024 * 1024 - : undefined, + accountId, }); const payload = { runId: idem, @@ -151,16 +143,13 @@ export const sendHandlers: GatewayRequestHandlers = { respond(true, payload, undefined, { provider }); } else { const cfg = loadConfig(); - const accountId = - typeof request.accountId === "string" && - request.accountId.trim().length > 0 - ? request.accountId.trim() - : resolveDefaultWhatsAppAccountId(cfg); + const targetAccountId = + accountId ?? resolveDefaultWhatsAppAccountId(cfg); const result = await sendMessageWhatsApp(to, message, { mediaUrl: request.mediaUrl, verbose: shouldLogVerbose(), gifPlayback: request.gifPlayback, - accountId, + accountId: targetAccountId, }); const payload = { runId: idem, @@ -238,9 +227,13 @@ export const sendHandlers: GatewayRequestHandlers = { maxSelections: request.maxSelections, durationHours: request.durationHours, }; + const accountId = + typeof request.accountId === "string" && request.accountId.trim().length + ? request.accountId.trim() + : undefined; try { if (provider === "discord") { - const result = await sendPollDiscord(to, poll); + const result = await sendPollDiscord(to, poll, { accountId }); const payload = { runId: idem, messageId: result.messageId, diff --git a/src/gateway/server-methods/types.ts b/src/gateway/server-methods/types.ts index 04b7897bf..6cb18672a 100644 --- a/src/gateway/server-methods/types.ts +++ b/src/gateway/server-methods/types.ts @@ -71,7 +71,16 @@ export type GatewayRequestContext = { getRuntimeSnapshot: () => ProviderRuntimeSnapshot; startWhatsAppProvider: (accountId?: string) => Promise; stopWhatsAppProvider: (accountId?: string) => Promise; - stopTelegramProvider: () => Promise; + startTelegramProvider: (accountId?: string) => Promise; + stopTelegramProvider: (accountId?: string) => Promise; + startDiscordProvider: (accountId?: string) => Promise; + stopDiscordProvider: (accountId?: string) => Promise; + startSlackProvider: (accountId?: string) => Promise; + stopSlackProvider: (accountId?: string) => Promise; + startSignalProvider: (accountId?: string) => Promise; + stopSignalProvider: (accountId?: string) => Promise; + startIMessageProvider: (accountId?: string) => Promise; + stopIMessageProvider: (accountId?: string) => Promise; markWhatsAppLoggedOut: (cleared: boolean, accountId?: string) => void; wizardRunner: ( opts: import("../../commands/onboard-types.js").OnboardOptions, diff --git a/src/gateway/server-providers.ts b/src/gateway/server-providers.ts index 645985b83..846c3ec1d 100644 --- a/src/gateway/server-providers.ts +++ b/src/gateway/server-providers.ts @@ -1,20 +1,40 @@ import type { ClawdbotConfig } from "../config/config.js"; +import { + listDiscordAccountIds, + resolveDefaultDiscordAccountId, + resolveDiscordAccount, +} from "../discord/accounts.js"; import { monitorDiscordProvider } from "../discord/index.js"; import { probeDiscord } from "../discord/probe.js"; import { shouldLogVerbose } from "../globals.js"; +import { + listIMessageAccountIds, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, +} from "../imessage/accounts.js"; import { monitorIMessageProvider } from "../imessage/index.js"; import type { createSubsystemLogger } from "../logging.js"; import { monitorWebProvider, webAuthExists } from "../providers/web/index.js"; import type { RuntimeEnv } from "../runtime.js"; +import { + listSignalAccountIds, + resolveDefaultSignalAccountId, + resolveSignalAccount, +} from "../signal/accounts.js"; import { monitorSignalProvider } from "../signal/index.js"; import { - monitorSlackProvider, - resolveSlackAppToken, - resolveSlackBotToken, -} from "../slack/index.js"; + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, +} from "../slack/accounts.js"; +import { monitorSlackProvider } from "../slack/index.js"; +import { + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, +} from "../telegram/accounts.js"; import { monitorTelegramProvider } from "../telegram/monitor.js"; import { probeTelegram } from "../telegram/probe.js"; -import { resolveTelegramToken } from "../telegram/token.js"; import { listEnabledWhatsAppAccounts, resolveDefaultWhatsAppAccountId, @@ -66,10 +86,15 @@ export type ProviderRuntimeSnapshot = { whatsapp: WebProviderStatus; whatsappAccounts?: Record; telegram: TelegramRuntimeStatus; + telegramAccounts?: Record; discord: DiscordRuntimeStatus; + discordAccounts?: Record; slack: SlackRuntimeStatus; + slackAccounts?: Record; signal: SignalRuntimeStatus; + signalAccounts?: Record; imessage: IMessageRuntimeStatus; + imessageAccounts?: Record; }; type SubsystemLogger = ReturnType; @@ -95,16 +120,16 @@ export type ProviderManager = { startProviders: () => Promise; startWhatsAppProvider: (accountId?: string) => Promise; stopWhatsAppProvider: (accountId?: string) => Promise; - startTelegramProvider: () => Promise; - stopTelegramProvider: () => Promise; - startDiscordProvider: () => Promise; - stopDiscordProvider: () => Promise; - startSlackProvider: () => Promise; - stopSlackProvider: () => Promise; - startSignalProvider: () => Promise; - stopSignalProvider: () => Promise; - startIMessageProvider: () => Promise; - stopIMessageProvider: () => Promise; + startTelegramProvider: (accountId?: string) => Promise; + stopTelegramProvider: (accountId?: string) => Promise; + startDiscordProvider: (accountId?: string) => Promise; + stopDiscordProvider: (accountId?: string) => Promise; + startSlackProvider: (accountId?: string) => Promise; + stopSlackProvider: (accountId?: string) => Promise; + startSignalProvider: (accountId?: string) => Promise; + stopSignalProvider: (accountId?: string) => Promise; + startIMessageProvider: (accountId?: string) => Promise; + stopIMessageProvider: (accountId?: string) => Promise; markWhatsAppLoggedOut: (cleared: boolean, accountId?: string) => void; }; @@ -128,17 +153,17 @@ export function createProviderManager( } = opts; const whatsappAborts = new Map(); - let telegramAbort: AbortController | null = null; - let discordAbort: AbortController | null = null; - let slackAbort: AbortController | null = null; - let signalAbort: AbortController | null = null; - let imessageAbort: AbortController | null = null; + const telegramAborts = new Map(); + const discordAborts = new Map(); + const slackAborts = new Map(); + const signalAborts = new Map(); + const imessageAborts = new Map(); const whatsappTasks = new Map>(); - let telegramTask: Promise | null = null; - let discordTask: Promise | null = null; - let slackTask: Promise | null = null; - let signalTask: Promise | null = null; - let imessageTask: Promise | null = null; + const telegramTasks = new Map>(); + const discordTasks = new Map>(); + const slackTasks = new Map>(); + const signalTasks = new Map>(); + const imessageTasks = new Map>(); const whatsappRuntimes = new Map(); const defaultWhatsAppStatus = (): WebProviderStatus => ({ @@ -151,40 +176,46 @@ export function createProviderManager( lastEventAt: null, lastError: null, }); - let telegramRuntime: TelegramRuntimeStatus = { + const telegramRuntimes = new Map(); + const discordRuntimes = new Map(); + const slackRuntimes = new Map(); + const signalRuntimes = new Map(); + const imessageRuntimes = new Map(); + + const defaultTelegramStatus = (): TelegramRuntimeStatus => ({ running: false, lastStartAt: null, lastStopAt: null, lastError: null, mode: null, - }; - let discordRuntime: DiscordRuntimeStatus = { + }); + const defaultDiscordStatus = (): DiscordRuntimeStatus => ({ running: false, lastStartAt: null, lastStopAt: null, lastError: null, - }; - let slackRuntime: SlackRuntimeStatus = { + }); + const defaultSlackStatus = (): SlackRuntimeStatus => ({ running: false, lastStartAt: null, lastStopAt: null, lastError: null, - }; - let signalRuntime: SignalRuntimeStatus = { + }); + const defaultSignalStatus = (): SignalRuntimeStatus => ({ running: false, lastStartAt: null, lastStopAt: null, lastError: null, baseUrl: null, - }; - let imessageRuntime: IMessageRuntimeStatus = { + }); + const defaultIMessageStatus = (): IMessageRuntimeStatus => ({ running: false, lastStartAt: null, lastStopAt: null, lastError: null, cliPath: null, dbPath: null, - }; + }); const updateWhatsAppStatus = (accountId: string, next: WebProviderStatus) => { whatsappRuntimes.set(accountId, next); @@ -316,15 +347,18 @@ export function createProviderManager( ); }; - const startTelegramProvider = async () => { - if (telegramTask) return; + const startTelegramProvider = async (accountId?: string) => { const cfg = loadConfig(); + const accountIds = accountId ? [accountId] : listTelegramAccountIds(cfg); if (cfg.telegram?.enabled === false) { - telegramRuntime = { - ...telegramRuntime, - running: false, - lastError: "disabled", - }; + for (const id of accountIds) { + const latest = telegramRuntimes.get(id) ?? defaultTelegramStatus(); + telegramRuntimes.set(id, { + ...latest, + running: false, + lastError: "disabled", + }); + } if (shouldLogVerbose()) { logTelegram.debug( "telegram provider disabled (telegram.enabled=false)", @@ -332,392 +366,530 @@ export function createProviderManager( } return; } - const { token: telegramToken } = resolveTelegramToken(cfg, { - logMissingFile: (message) => logTelegram.warn(message), - }); - if (!telegramToken.trim()) { - telegramRuntime = { - ...telegramRuntime, - running: false, - lastError: "not configured", - }; - // keep quiet by default; this is a normal state - if (shouldLogVerbose()) { - logTelegram.debug( - "telegram provider not configured (no TELEGRAM_BOT_TOKEN)", + + await Promise.all( + accountIds.map(async (id) => { + const account = resolveTelegramAccount({ cfg, accountId: id }); + if (!account.enabled) { + const latest = + telegramRuntimes.get(account.accountId) ?? defaultTelegramStatus(); + telegramRuntimes.set(account.accountId, { + ...latest, + running: false, + lastError: "disabled", + }); + return; + } + if (telegramTasks.has(account.accountId)) return; + const token = account.token.trim(); + if (!token) { + const latest = + telegramRuntimes.get(account.accountId) ?? defaultTelegramStatus(); + telegramRuntimes.set(account.accountId, { + ...latest, + running: false, + lastError: "not configured", + }); + if (shouldLogVerbose()) { + logTelegram.debug( + `[${account.accountId}] telegram provider not configured (no TELEGRAM_BOT_TOKEN)`, + ); + } + return; + } + + let telegramBotLabel = ""; + try { + const probe = await probeTelegram(token, 2500, account.config.proxy); + const username = probe.ok ? probe.bot?.username?.trim() : null; + if (username) telegramBotLabel = ` (@${username})`; + } catch (err) { + if (shouldLogVerbose()) { + logTelegram.debug( + `[${account.accountId}] bot probe failed: ${String(err)}`, + ); + } + } + + logTelegram.info( + `[${account.accountId}] starting provider${telegramBotLabel}`, ); - } - return; - } - let telegramBotLabel = ""; - try { - const probe = await probeTelegram( - telegramToken.trim(), - 2500, - cfg.telegram?.proxy, - ); - const username = probe.ok ? probe.bot?.username?.trim() : null; - if (username) telegramBotLabel = ` (@${username})`; - } catch (err) { - if (shouldLogVerbose()) { - logTelegram.debug(`bot probe failed: ${String(err)}`); - } - } - logTelegram.info( - `starting provider${telegramBotLabel}${cfg.telegram ? "" : " (no telegram config; token via env)"}`, + const abort = new AbortController(); + telegramAborts.set(account.accountId, abort); + const latest = + telegramRuntimes.get(account.accountId) ?? defaultTelegramStatus(); + telegramRuntimes.set(account.accountId, { + ...latest, + running: true, + lastStartAt: Date.now(), + lastError: null, + mode: account.config.webhookUrl ? "webhook" : "polling", + }); + const task = monitorTelegramProvider({ + token, + accountId: account.accountId, + config: cfg, + runtime: telegramRuntimeEnv, + abortSignal: abort.signal, + useWebhook: Boolean(account.config.webhookUrl), + webhookUrl: account.config.webhookUrl, + webhookSecret: account.config.webhookSecret, + webhookPath: account.config.webhookPath, + }) + .catch((err) => { + const current = + telegramRuntimes.get(account.accountId) ?? + defaultTelegramStatus(); + telegramRuntimes.set(account.accountId, { + ...current, + lastError: formatError(err), + }); + logTelegram.error( + `[${account.accountId}] provider exited: ${formatError(err)}`, + ); + }) + .finally(() => { + telegramAborts.delete(account.accountId); + telegramTasks.delete(account.accountId); + const current = + telegramRuntimes.get(account.accountId) ?? + defaultTelegramStatus(); + telegramRuntimes.set(account.accountId, { + ...current, + running: false, + lastStopAt: Date.now(), + }); + }); + telegramTasks.set(account.accountId, task); + }), ); - telegramAbort = new AbortController(); - telegramRuntime = { - ...telegramRuntime, - running: true, - lastStartAt: Date.now(), - lastError: null, - mode: cfg.telegram?.webhookUrl ? "webhook" : "polling", - }; - const task = monitorTelegramProvider({ - token: telegramToken.trim(), - runtime: telegramRuntimeEnv, - abortSignal: telegramAbort.signal, - useWebhook: Boolean(cfg.telegram?.webhookUrl), - webhookUrl: cfg.telegram?.webhookUrl, - webhookSecret: cfg.telegram?.webhookSecret, - webhookPath: cfg.telegram?.webhookPath, - }) - .catch((err) => { - telegramRuntime = { - ...telegramRuntime, - lastError: formatError(err), - }; - logTelegram.error(`provider exited: ${formatError(err)}`); - }) - .finally(() => { - telegramAbort = null; - telegramTask = null; - telegramRuntime = { - ...telegramRuntime, + }; + + const stopTelegramProvider = async (accountId?: string) => { + const ids = accountId + ? [accountId] + : Array.from( + new Set([...telegramAborts.keys(), ...telegramTasks.keys()]), + ); + await Promise.all( + ids.map(async (id) => { + const abort = telegramAborts.get(id); + const task = telegramTasks.get(id); + if (!abort && !task) return; + abort?.abort(); + try { + await task; + } catch { + // ignore + } + telegramAborts.delete(id); + telegramTasks.delete(id); + const latest = telegramRuntimes.get(id) ?? defaultTelegramStatus(); + telegramRuntimes.set(id, { + ...latest, running: false, lastStopAt: Date.now(), - }; - }); - telegramTask = task; + }); + }), + ); }; - const stopTelegramProvider = async () => { - if (!telegramAbort && !telegramTask) return; - telegramAbort?.abort(); - try { - await telegramTask; - } catch { - // ignore - } - telegramAbort = null; - telegramTask = null; - telegramRuntime = { - ...telegramRuntime, - running: false, - lastStopAt: Date.now(), - }; - }; - - const startDiscordProvider = async () => { - if (discordTask) return; + const startDiscordProvider = async (accountId?: string) => { const cfg = loadConfig(); + const accountIds = accountId ? [accountId] : listDiscordAccountIds(cfg); if (cfg.discord?.enabled === false) { - discordRuntime = { - ...discordRuntime, - running: false, - lastError: "disabled", - }; + for (const id of accountIds) { + const latest = discordRuntimes.get(id) ?? defaultDiscordStatus(); + discordRuntimes.set(id, { + ...latest, + running: false, + lastError: "disabled", + }); + } if (shouldLogVerbose()) { logDiscord.debug("discord provider disabled (discord.enabled=false)"); } return; } - const discordToken = - process.env.DISCORD_BOT_TOKEN ?? cfg.discord?.token ?? ""; - if (!discordToken.trim()) { - discordRuntime = { - ...discordRuntime, - running: false, - lastError: "not configured", - }; - // keep quiet by default; this is a normal state - if (shouldLogVerbose()) { - logDiscord.debug( - "discord provider not configured (no DISCORD_BOT_TOKEN)", + + await Promise.all( + accountIds.map(async (id) => { + const account = resolveDiscordAccount({ cfg, accountId: id }); + if (!account.enabled) { + const latest = + discordRuntimes.get(account.accountId) ?? defaultDiscordStatus(); + discordRuntimes.set(account.accountId, { + ...latest, + running: false, + lastError: "disabled", + }); + return; + } + if (discordTasks.has(account.accountId)) return; + const token = account.token.trim(); + if (!token) { + const latest = + discordRuntimes.get(account.accountId) ?? defaultDiscordStatus(); + discordRuntimes.set(account.accountId, { + ...latest, + running: false, + lastError: "not configured", + }); + if (shouldLogVerbose()) { + logDiscord.debug( + `[${account.accountId}] discord provider not configured (no DISCORD_BOT_TOKEN)`, + ); + } + return; + } + let discordBotLabel = ""; + try { + const probe = await probeDiscord(token, 2500); + const username = probe.ok ? probe.bot?.username?.trim() : null; + if (username) discordBotLabel = ` (@${username})`; + } catch (err) { + if (shouldLogVerbose()) { + logDiscord.debug( + `[${account.accountId}] bot probe failed: ${String(err)}`, + ); + } + } + logDiscord.info( + `[${account.accountId}] starting provider${discordBotLabel}`, ); - } - return; - } - let discordBotLabel = ""; - try { - const probe = await probeDiscord(discordToken.trim(), 2500); - const username = probe.ok ? probe.bot?.username?.trim() : null; - if (username) discordBotLabel = ` (@${username})`; - } catch (err) { - if (shouldLogVerbose()) { - logDiscord.debug(`bot probe failed: ${String(err)}`); - } - } - logDiscord.info( - `starting provider${discordBotLabel}${cfg.discord ? "" : " (no discord config; token via env)"}`, + const abort = new AbortController(); + discordAborts.set(account.accountId, abort); + const latest = + discordRuntimes.get(account.accountId) ?? defaultDiscordStatus(); + discordRuntimes.set(account.accountId, { + ...latest, + running: true, + lastStartAt: Date.now(), + lastError: null, + }); + const task = monitorDiscordProvider({ + token, + accountId: account.accountId, + config: cfg, + runtime: discordRuntimeEnv, + abortSignal: abort.signal, + mediaMaxMb: account.config.mediaMaxMb, + historyLimit: account.config.historyLimit, + }) + .catch((err) => { + const current = + discordRuntimes.get(account.accountId) ?? defaultDiscordStatus(); + discordRuntimes.set(account.accountId, { + ...current, + lastError: formatError(err), + }); + logDiscord.error( + `[${account.accountId}] provider exited: ${formatError(err)}`, + ); + }) + .finally(() => { + discordAborts.delete(account.accountId); + discordTasks.delete(account.accountId); + const current = + discordRuntimes.get(account.accountId) ?? defaultDiscordStatus(); + discordRuntimes.set(account.accountId, { + ...current, + running: false, + lastStopAt: Date.now(), + }); + }); + discordTasks.set(account.accountId, task); + }), ); - discordAbort = new AbortController(); - discordRuntime = { - ...discordRuntime, - running: true, - lastStartAt: Date.now(), - lastError: null, - }; - const task = monitorDiscordProvider({ - token: discordToken.trim(), - runtime: discordRuntimeEnv, - abortSignal: discordAbort.signal, - mediaMaxMb: cfg.discord?.mediaMaxMb, - historyLimit: cfg.discord?.historyLimit, - }) - .catch((err) => { - discordRuntime = { - ...discordRuntime, - lastError: formatError(err), - }; - logDiscord.error(`provider exited: ${formatError(err)}`); - }) - .finally(() => { - discordAbort = null; - discordTask = null; - discordRuntime = { - ...discordRuntime, + }; + + const stopDiscordProvider = async (accountId?: string) => { + const ids = accountId + ? [accountId] + : Array.from(new Set([...discordAborts.keys(), ...discordTasks.keys()])); + await Promise.all( + ids.map(async (id) => { + const abort = discordAborts.get(id); + const task = discordTasks.get(id); + if (!abort && !task) return; + abort?.abort(); + try { + await task; + } catch { + // ignore + } + discordAborts.delete(id); + discordTasks.delete(id); + const latest = discordRuntimes.get(id) ?? defaultDiscordStatus(); + discordRuntimes.set(id, { + ...latest, running: false, lastStopAt: Date.now(), - }; - }); - discordTask = task; + }); + }), + ); }; - const stopDiscordProvider = async () => { - if (!discordAbort && !discordTask) return; - discordAbort?.abort(); - try { - await discordTask; - } catch { - // ignore - } - discordAbort = null; - discordTask = null; - discordRuntime = { - ...discordRuntime, - running: false, - lastStopAt: Date.now(), - }; - }; - - const startSlackProvider = async () => { - if (slackTask) return; + const startSlackProvider = async (accountId?: string) => { const cfg = loadConfig(); + const accountIds = accountId ? [accountId] : listSlackAccountIds(cfg); if (cfg.slack?.enabled === false) { - slackRuntime = { - ...slackRuntime, - running: false, - lastError: "disabled", - }; + for (const id of accountIds) { + const latest = slackRuntimes.get(id) ?? defaultSlackStatus(); + slackRuntimes.set(id, { + ...latest, + running: false, + lastError: "disabled", + }); + } if (shouldLogVerbose()) { logSlack.debug("slack provider disabled (slack.enabled=false)"); } return; } - const botToken = resolveSlackBotToken( - process.env.SLACK_BOT_TOKEN ?? cfg.slack?.botToken ?? undefined, + + await Promise.all( + accountIds.map(async (id) => { + const account = resolveSlackAccount({ cfg, accountId: id }); + if (!account.enabled) { + const latest = + slackRuntimes.get(account.accountId) ?? defaultSlackStatus(); + slackRuntimes.set(account.accountId, { + ...latest, + running: false, + lastError: "disabled", + }); + return; + } + if (slackTasks.has(account.accountId)) return; + const botToken = account.botToken?.trim(); + const appToken = account.appToken?.trim(); + if (!botToken || !appToken) { + const latest = + slackRuntimes.get(account.accountId) ?? defaultSlackStatus(); + slackRuntimes.set(account.accountId, { + ...latest, + running: false, + lastError: "not configured", + }); + if (shouldLogVerbose()) { + logSlack.debug( + `[${account.accountId}] slack provider not configured (missing SLACK_BOT_TOKEN/SLACK_APP_TOKEN)`, + ); + } + return; + } + logSlack.info(`[${account.accountId}] starting provider`); + const abort = new AbortController(); + slackAborts.set(account.accountId, abort); + const latest = + slackRuntimes.get(account.accountId) ?? defaultSlackStatus(); + slackRuntimes.set(account.accountId, { + ...latest, + running: true, + lastStartAt: Date.now(), + lastError: null, + }); + const task = monitorSlackProvider({ + botToken, + appToken, + accountId: account.accountId, + config: cfg, + runtime: slackRuntimeEnv, + abortSignal: abort.signal, + mediaMaxMb: account.config.mediaMaxMb, + slashCommand: account.config.slashCommand, + }) + .catch((err) => { + const current = + slackRuntimes.get(account.accountId) ?? defaultSlackStatus(); + slackRuntimes.set(account.accountId, { + ...current, + lastError: formatError(err), + }); + logSlack.error( + `[${account.accountId}] provider exited: ${formatError(err)}`, + ); + }) + .finally(() => { + slackAborts.delete(account.accountId); + slackTasks.delete(account.accountId); + const current = + slackRuntimes.get(account.accountId) ?? defaultSlackStatus(); + slackRuntimes.set(account.accountId, { + ...current, + running: false, + lastStopAt: Date.now(), + }); + }); + slackTasks.set(account.accountId, task); + }), ); - const appToken = resolveSlackAppToken( - process.env.SLACK_APP_TOKEN ?? cfg.slack?.appToken ?? undefined, - ); - if (!botToken || !appToken) { - slackRuntime = { - ...slackRuntime, - running: false, - lastError: "not configured", - }; - if (shouldLogVerbose()) { - logSlack.debug( - "slack provider not configured (missing SLACK_BOT_TOKEN/SLACK_APP_TOKEN)", - ); - } - return; - } - logSlack.info( - `starting provider${cfg.slack ? "" : " (no slack config; tokens via env)"}`, - ); - slackAbort = new AbortController(); - slackRuntime = { - ...slackRuntime, - running: true, - lastStartAt: Date.now(), - lastError: null, - }; - const task = monitorSlackProvider({ - botToken, - appToken, - runtime: slackRuntimeEnv, - abortSignal: slackAbort.signal, - mediaMaxMb: cfg.slack?.mediaMaxMb, - slashCommand: cfg.slack?.slashCommand, - }) - .catch((err) => { - slackRuntime = { - ...slackRuntime, - lastError: formatError(err), - }; - logSlack.error(`provider exited: ${formatError(err)}`); - }) - .finally(() => { - slackAbort = null; - slackTask = null; - slackRuntime = { - ...slackRuntime, + }; + + const stopSlackProvider = async (accountId?: string) => { + const ids = accountId + ? [accountId] + : Array.from(new Set([...slackAborts.keys(), ...slackTasks.keys()])); + await Promise.all( + ids.map(async (id) => { + const abort = slackAborts.get(id); + const task = slackTasks.get(id); + if (!abort && !task) return; + abort?.abort(); + try { + await task; + } catch { + // ignore + } + slackAborts.delete(id); + slackTasks.delete(id); + const latest = slackRuntimes.get(id) ?? defaultSlackStatus(); + slackRuntimes.set(id, { + ...latest, running: false, lastStopAt: Date.now(), - }; - }); - slackTask = task; + }); + }), + ); }; - const stopSlackProvider = async () => { - if (!slackAbort && !slackTask) return; - slackAbort?.abort(); - try { - await slackTask; - } catch { - // ignore - } - slackAbort = null; - slackTask = null; - slackRuntime = { - ...slackRuntime, - running: false, - lastStopAt: Date.now(), - }; - }; - - const startSignalProvider = async () => { - if (signalTask) return; + const startSignalProvider = async (accountId?: string) => { const cfg = loadConfig(); + const accountIds = accountId ? [accountId] : listSignalAccountIds(cfg); if (!cfg.signal) { - signalRuntime = { - ...signalRuntime, - running: false, - lastError: "not configured", - }; - // keep quiet by default; this is a normal state + for (const id of accountIds) { + const latest = signalRuntimes.get(id) ?? defaultSignalStatus(); + signalRuntimes.set(id, { + ...latest, + running: false, + lastError: "not configured", + }); + } if (shouldLogVerbose()) { logSignal.debug("signal provider not configured (no signal config)"); } return; } - if (cfg.signal?.enabled === false) { - signalRuntime = { - ...signalRuntime, - running: false, - lastError: "disabled", - }; - if (shouldLogVerbose()) { - logSignal.debug("signal provider disabled (signal.enabled=false)"); - } - return; - } - const signalCfg = cfg.signal; - const signalMeaningfullyConfigured = Boolean( - signalCfg.account?.trim() || - signalCfg.httpUrl?.trim() || - signalCfg.cliPath?.trim() || - signalCfg.httpHost?.trim() || - typeof signalCfg.httpPort === "number" || - typeof signalCfg.autoStart === "boolean", - ); - if (!signalMeaningfullyConfigured) { - signalRuntime = { - ...signalRuntime, - running: false, - lastError: "not configured", - }; - // keep quiet by default; this is a normal state - if (shouldLogVerbose()) { - logSignal.debug( - "signal provider not configured (signal config present but missing required fields)", + + await Promise.all( + accountIds.map(async (id) => { + const account = resolveSignalAccount({ cfg, accountId: id }); + if (!account.enabled) { + const latest = + signalRuntimes.get(account.accountId) ?? defaultSignalStatus(); + signalRuntimes.set(account.accountId, { + ...latest, + running: false, + lastError: "disabled", + baseUrl: account.baseUrl, + }); + return; + } + if (!account.configured) { + const latest = + signalRuntimes.get(account.accountId) ?? defaultSignalStatus(); + signalRuntimes.set(account.accountId, { + ...latest, + running: false, + lastError: "not configured", + baseUrl: account.baseUrl, + }); + if (shouldLogVerbose()) { + logSignal.debug( + `[${account.accountId}] signal provider not configured (missing signal config)`, + ); + } + return; + } + if (signalTasks.has(account.accountId)) return; + logSignal.info( + `[${account.accountId}] starting provider (${account.baseUrl})`, ); - } - return; - } - const host = cfg.signal?.httpHost?.trim() || "127.0.0.1"; - const port = cfg.signal?.httpPort ?? 8080; - const baseUrl = cfg.signal?.httpUrl?.trim() || `http://${host}:${port}`; - logSignal.info(`starting provider (${baseUrl})`); - signalAbort = new AbortController(); - signalRuntime = { - ...signalRuntime, - running: true, - lastStartAt: Date.now(), - lastError: null, - baseUrl, - }; - const task = monitorSignalProvider({ - baseUrl, - account: cfg.signal?.account, - cliPath: cfg.signal?.cliPath, - httpHost: cfg.signal?.httpHost, - httpPort: cfg.signal?.httpPort, - autoStart: - typeof cfg.signal?.autoStart === "boolean" - ? cfg.signal.autoStart - : undefined, - runtime: signalRuntimeEnv, - abortSignal: signalAbort.signal, - }) - .catch((err) => { - signalRuntime = { - ...signalRuntime, - lastError: formatError(err), - }; - logSignal.error(`provider exited: ${formatError(err)}`); - }) - .finally(() => { - signalAbort = null; - signalTask = null; - signalRuntime = { - ...signalRuntime, + const abort = new AbortController(); + signalAborts.set(account.accountId, abort); + const latest = + signalRuntimes.get(account.accountId) ?? defaultSignalStatus(); + signalRuntimes.set(account.accountId, { + ...latest, + running: true, + lastStartAt: Date.now(), + lastError: null, + baseUrl: account.baseUrl, + }); + const task = monitorSignalProvider({ + accountId: account.accountId, + config: cfg, + runtime: signalRuntimeEnv, + abortSignal: abort.signal, + mediaMaxMb: account.config.mediaMaxMb, + }) + .catch((err) => { + const current = + signalRuntimes.get(account.accountId) ?? defaultSignalStatus(); + signalRuntimes.set(account.accountId, { + ...current, + lastError: formatError(err), + }); + logSignal.error( + `[${account.accountId}] provider exited: ${formatError(err)}`, + ); + }) + .finally(() => { + signalAborts.delete(account.accountId); + signalTasks.delete(account.accountId); + const current = + signalRuntimes.get(account.accountId) ?? defaultSignalStatus(); + signalRuntimes.set(account.accountId, { + ...current, + running: false, + lastStopAt: Date.now(), + }); + }); + signalTasks.set(account.accountId, task); + }), + ); + }; + + const stopSignalProvider = async (accountId?: string) => { + const ids = accountId + ? [accountId] + : Array.from(new Set([...signalAborts.keys(), ...signalTasks.keys()])); + await Promise.all( + ids.map(async (id) => { + const abort = signalAborts.get(id); + const task = signalTasks.get(id); + if (!abort && !task) return; + abort?.abort(); + try { + await task; + } catch { + // ignore + } + signalAborts.delete(id); + signalTasks.delete(id); + const latest = signalRuntimes.get(id) ?? defaultSignalStatus(); + signalRuntimes.set(id, { + ...latest, running: false, lastStopAt: Date.now(), - }; - }); - signalTask = task; + }); + }), + ); }; - const stopSignalProvider = async () => { - if (!signalAbort && !signalTask) return; - signalAbort?.abort(); - try { - await signalTask; - } catch { - // ignore - } - signalAbort = null; - signalTask = null; - signalRuntime = { - ...signalRuntime, - running: false, - lastStopAt: Date.now(), - }; - }; - - const startIMessageProvider = async () => { - if (imessageTask) return; + const startIMessageProvider = async (accountId?: string) => { const cfg = loadConfig(); + const accountIds = accountId ? [accountId] : listIMessageAccountIds(cfg); if (!cfg.imessage) { - imessageRuntime = { - ...imessageRuntime, - running: false, - lastError: "not configured", - }; + for (const id of accountIds) { + const latest = imessageRuntimes.get(id) ?? defaultIMessageStatus(); + imessageRuntimes.set(id, { + ...latest, + running: false, + lastError: "not configured", + }); + } // keep quiet by default; this is a normal state if (shouldLogVerbose()) { logIMessage.debug( @@ -726,76 +898,105 @@ export function createProviderManager( } return; } - if (cfg.imessage?.enabled === false) { - imessageRuntime = { - ...imessageRuntime, - running: false, - lastError: "disabled", - }; - if (shouldLogVerbose()) { - logIMessage.debug( - "imessage provider disabled (imessage.enabled=false)", + + await Promise.all( + accountIds.map(async (id) => { + const account = resolveIMessageAccount({ cfg, accountId: id }); + if (!account.enabled) { + const latest = + imessageRuntimes.get(account.accountId) ?? defaultIMessageStatus(); + imessageRuntimes.set(account.accountId, { + ...latest, + running: false, + lastError: "disabled", + }); + if (shouldLogVerbose()) { + logIMessage.debug( + `[${account.accountId}] imessage provider disabled (imessage.enabled=false)`, + ); + } + return; + } + if (imessageTasks.has(account.accountId)) return; + const cliPath = account.config.cliPath?.trim() || "imsg"; + const dbPath = account.config.dbPath?.trim(); + logIMessage.info( + `[${account.accountId}] starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`, ); - } - return; - } - const cliPath = cfg.imessage?.cliPath?.trim() || "imsg"; - const dbPath = cfg.imessage?.dbPath?.trim(); - logIMessage.info( - `starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`, + const abort = new AbortController(); + imessageAborts.set(account.accountId, abort); + const latest = + imessageRuntimes.get(account.accountId) ?? defaultIMessageStatus(); + imessageRuntimes.set(account.accountId, { + ...latest, + running: true, + lastStartAt: Date.now(), + lastError: null, + cliPath, + dbPath: dbPath ?? null, + }); + const task = monitorIMessageProvider({ + accountId: account.accountId, + config: cfg, + runtime: imessageRuntimeEnv, + abortSignal: abort.signal, + }) + .catch((err) => { + const current = + imessageRuntimes.get(account.accountId) ?? + defaultIMessageStatus(); + imessageRuntimes.set(account.accountId, { + ...current, + lastError: formatError(err), + }); + logIMessage.error( + `[${account.accountId}] provider exited: ${formatError(err)}`, + ); + }) + .finally(() => { + imessageAborts.delete(account.accountId); + imessageTasks.delete(account.accountId); + const current = + imessageRuntimes.get(account.accountId) ?? + defaultIMessageStatus(); + imessageRuntimes.set(account.accountId, { + ...current, + running: false, + lastStopAt: Date.now(), + }); + }); + imessageTasks.set(account.accountId, task); + }), ); - imessageAbort = new AbortController(); - imessageRuntime = { - ...imessageRuntime, - running: true, - lastStartAt: Date.now(), - lastError: null, - cliPath, - dbPath: dbPath ?? null, - }; - const task = monitorIMessageProvider({ - cliPath, - dbPath, - allowFrom: cfg.imessage?.allowFrom, - includeAttachments: cfg.imessage?.includeAttachments, - mediaMaxMb: cfg.imessage?.mediaMaxMb, - runtime: imessageRuntimeEnv, - abortSignal: imessageAbort.signal, - }) - .catch((err) => { - imessageRuntime = { - ...imessageRuntime, - lastError: formatError(err), - }; - logIMessage.error(`provider exited: ${formatError(err)}`); - }) - .finally(() => { - imessageAbort = null; - imessageTask = null; - imessageRuntime = { - ...imessageRuntime, - running: false, - lastStopAt: Date.now(), - }; - }); - imessageTask = task; }; - const stopIMessageProvider = async () => { - if (!imessageAbort && !imessageTask) return; - imessageAbort?.abort(); - try { - await imessageTask; - } catch { - // ignore - } - imessageAbort = null; - imessageTask = null; - imessageRuntime = { - ...imessageRuntime, - running: false, - lastStopAt: Date.now(), - }; + const stopIMessageProvider = async (accountId?: string) => { + const ids = accountId + ? [accountId] + : Array.from( + new Set([...imessageAborts.keys(), ...imessageTasks.keys()]), + ); + await Promise.all( + ids.map(async (id) => { + const abort = imessageAborts.get(id); + const task = imessageTasks.get(id); + if (!abort && !task) return; + abort?.abort(); + try { + await task; + } catch { + // ignore + } + imessageAborts.delete(id); + imessageTasks.delete(id); + const latest = imessageRuntimes.get(id) ?? defaultIMessageStatus(); + imessageRuntimes.set(id, { + ...latest, + running: false, + lastStopAt: Date.now(), + }); + }), + ); }; const startProviders = async () => { @@ -821,22 +1022,137 @@ export function createProviderManager( const getRuntimeSnapshot = (): ProviderRuntimeSnapshot => { const cfg = loadConfig(); - const defaultId = resolveDefaultWhatsAppAccountId(cfg); - const whatsapp = whatsappRuntimes.get(defaultId) ?? defaultWhatsAppStatus(); + const defaultWhatsAppId = resolveDefaultWhatsAppAccountId(cfg); + const whatsapp = + whatsappRuntimes.get(defaultWhatsAppId) ?? defaultWhatsAppStatus(); const whatsappAccounts = Object.fromEntries( Array.from(whatsappRuntimes.entries()).map(([id, status]) => [ id, { ...status }, ]), ); + + const telegramAccounts = Object.fromEntries( + listTelegramAccountIds(cfg).map((id) => { + const account = resolveTelegramAccount({ cfg, accountId: id }); + const current = + telegramRuntimes.get(account.accountId) ?? defaultTelegramStatus(); + const status: TelegramRuntimeStatus = { + ...current, + mode: + current.mode ?? (account.config.webhookUrl ? "webhook" : "polling"), + }; + if (!status.running) { + if (!account.enabled) { + status.lastError ??= "disabled"; + } else if (!account.token) { + status.lastError ??= "not configured"; + } + } + return [account.accountId, status]; + }), + ); + const telegramDefaultId = resolveDefaultTelegramAccountId(cfg); + const telegram = + telegramAccounts[telegramDefaultId] ?? defaultTelegramStatus(); + + const discordAccounts = Object.fromEntries( + listDiscordAccountIds(cfg).map((id) => { + const account = resolveDiscordAccount({ cfg, accountId: id }); + const current = + discordRuntimes.get(account.accountId) ?? defaultDiscordStatus(); + const status: DiscordRuntimeStatus = { ...current }; + if (!status.running) { + if (!account.enabled) { + status.lastError ??= "disabled"; + } else if (!account.token) { + status.lastError ??= "not configured"; + } + } + return [account.accountId, status]; + }), + ); + const discordDefaultId = resolveDefaultDiscordAccountId(cfg); + const discord = discordAccounts[discordDefaultId] ?? defaultDiscordStatus(); + + const slackAccounts = Object.fromEntries( + listSlackAccountIds(cfg).map((id) => { + const account = resolveSlackAccount({ cfg, accountId: id }); + const current = + slackRuntimes.get(account.accountId) ?? defaultSlackStatus(); + const status: SlackRuntimeStatus = { ...current }; + if (!status.running) { + if (!account.enabled) { + status.lastError ??= "disabled"; + } else if (!account.botToken || !account.appToken) { + status.lastError ??= "not configured"; + } + } + return [account.accountId, status]; + }), + ); + const slackDefaultId = resolveDefaultSlackAccountId(cfg); + const slack = slackAccounts[slackDefaultId] ?? defaultSlackStatus(); + + const signalAccounts = Object.fromEntries( + listSignalAccountIds(cfg).map((id) => { + const account = resolveSignalAccount({ cfg, accountId: id }); + const current = + signalRuntimes.get(account.accountId) ?? defaultSignalStatus(); + const status: SignalRuntimeStatus = { + ...current, + baseUrl: current.baseUrl ?? account.baseUrl, + }; + if (!status.running) { + if (!account.enabled) { + status.lastError ??= "disabled"; + } else if (!account.configured) { + status.lastError ??= "not configured"; + } + } + return [account.accountId, status]; + }), + ); + const signalDefaultId = resolveDefaultSignalAccountId(cfg); + const signal = signalAccounts[signalDefaultId] ?? defaultSignalStatus(); + + const imessageAccounts = Object.fromEntries( + listIMessageAccountIds(cfg).map((id) => { + const account = resolveIMessageAccount({ cfg, accountId: id }); + const current = + imessageRuntimes.get(account.accountId) ?? defaultIMessageStatus(); + const cliPath = account.config.cliPath?.trim() || "imsg"; + const dbPath = account.config.dbPath?.trim() || null; + const status: IMessageRuntimeStatus = { + ...current, + cliPath: current.cliPath ?? cliPath, + dbPath: current.dbPath ?? dbPath, + }; + if (!status.running && !account.enabled) { + status.lastError ??= "disabled"; + } + if (!status.running && !cfg.imessage) { + status.lastError ??= "not configured"; + } + return [account.accountId, status]; + }), + ); + const imessageDefaultId = resolveDefaultIMessageAccountId(cfg); + const imessage = + imessageAccounts[imessageDefaultId] ?? defaultIMessageStatus(); return { whatsapp: { ...whatsapp }, whatsappAccounts, - telegram: { ...telegramRuntime }, - discord: { ...discordRuntime }, - slack: { ...slackRuntime }, - signal: { ...signalRuntime }, - imessage: { ...imessageRuntime }, + telegram, + telegramAccounts, + discord, + discordAccounts, + slack, + slackAccounts, + signal, + signalAccounts, + imessage, + imessageAccounts, }; }; diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index 21f99b403..9eeb1356b 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -42,6 +42,7 @@ const hoisted = vi.hoisted(() => { lastEventAt: null, lastError: null, }, + whatsappAccounts: {}, telegram: { running: false, lastStartAt: null, @@ -49,18 +50,21 @@ const hoisted = vi.hoisted(() => { lastError: null, mode: null, }, + telegramAccounts: {}, discord: { running: false, lastStartAt: null, lastStopAt: null, lastError: null, }, + discordAccounts: {}, slack: { running: false, lastStartAt: null, lastStopAt: null, lastError: null, }, + slackAccounts: {}, signal: { running: false, lastStartAt: null, @@ -68,6 +72,7 @@ const hoisted = vi.hoisted(() => { lastError: null, baseUrl: null, }, + signalAccounts: {}, imessage: { running: false, lastStartAt: null, @@ -76,6 +81,7 @@ const hoisted = vi.hoisted(() => { cliPath: null, dbPath: null, }, + imessageAccounts: {}, })), startProviders: vi.fn(async () => {}), startWhatsAppProvider: vi.fn(async () => {}), diff --git a/src/gateway/server.ts b/src/gateway/server.ts index ab63b3fd1..397834937 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -1542,7 +1542,16 @@ export async function startGatewayServer( getRuntimeSnapshot, startWhatsAppProvider, stopWhatsAppProvider, + startTelegramProvider, stopTelegramProvider, + startDiscordProvider, + stopDiscordProvider, + startSlackProvider, + stopSlackProvider, + startSignalProvider, + stopSignalProvider, + startIMessageProvider, + stopIMessageProvider, markWhatsAppLoggedOut, wizardRunner, broadcastVoiceWakeChanged, diff --git a/src/imessage/accounts.ts b/src/imessage/accounts.ts new file mode 100644 index 000000000..b2adcdaf7 --- /dev/null +++ b/src/imessage/accounts.ts @@ -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); +} diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index d913b81ab..b30f066b6 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -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; groupAllowFrom?: Array; 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) { + return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean); } async function deliverReplies(params: { replies: ReplyPayload[]; target: string; client: Awaited>; + 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 { 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") { diff --git a/src/imessage/send.ts b/src/imessage/send.ts index 00df16bff..8d2542fe3 100644 --- a/src/imessage/send.ts +++ b/src/imessage/send.ts @@ -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 { - 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 = { text: message, - service: resolveService(service), + service: (service || "auto") as IMessageService, region, }; if (filePath) params.file = filePath; diff --git a/src/infra/heartbeat-runner.test.ts b/src/infra/heartbeat-runner.test.ts index f73ab120d..b3def41a5 100644 --- a/src/infra/heartbeat-runner.test.ts +++ b/src/infra/heartbeat-runner.test.ts @@ -411,7 +411,7 @@ describe("runHeartbeatOnce", () => { expect(sendTelegram).toHaveBeenCalledWith( "123456", "Hello from heartbeat", - expect.objectContaining({ token: "test-bot-token-123" }), + expect.objectContaining({ accountId: "default", verbose: false }), ); } finally { replySpy.mockRestore(); diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 63d22665c..0cf53236a 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -7,7 +7,7 @@ import { } from "./deliver.js"; describe("deliverOutboundPayloads", () => { - it("chunks telegram markdown and passes config token", async () => { + it("chunks telegram markdown and passes account id", async () => { const sendTelegram = vi .fn() .mockResolvedValue({ messageId: "m1", chatId: "c1" }); @@ -28,7 +28,7 @@ describe("deliverOutboundPayloads", () => { expect(sendTelegram).toHaveBeenCalledTimes(2); for (const call of sendTelegram.mock.calls) { expect(call[2]).toEqual( - expect.objectContaining({ token: "tok-1", verbose: false }), + expect.objectContaining({ accountId: "default", verbose: false }), ); } expect(results).toHaveLength(2); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index d437c9312..5644de226 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -7,10 +7,10 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { sendMessageDiscord } from "../../discord/send.js"; import { sendMessageIMessage } from "../../imessage/send.js"; +import { normalizeAccountId } from "../../routing/session-key.js"; import { sendMessageSignal } from "../../signal/send.js"; import { sendMessageSlack } from "../../slack/send.js"; import { sendMessageTelegram } from "../../telegram/send.js"; -import { resolveTelegramToken } from "../../telegram/token.js"; import { sendMessageWhatsApp } from "../../web/outbound.js"; import type { NormalizedOutboundPayload } from "./payloads.js"; import { normalizeOutboundPayloads } from "./payloads.js"; @@ -64,9 +64,15 @@ type ProviderHandler = { function resolveMediaMaxBytes( cfg: ClawdbotConfig, provider: "signal" | "imessage", + accountId?: string | null, ): number | undefined { + const normalizedAccountId = normalizeAccountId(accountId); const providerLimit = - provider === "signal" ? cfg.signal?.mediaMaxMb : cfg.imessage?.mediaMaxMb; + provider === "signal" + ? (cfg.signal?.accounts?.[normalizedAccountId]?.mediaMaxMb ?? + cfg.signal?.mediaMaxMb) + : (cfg.imessage?.accounts?.[normalizedAccountId]?.mediaMaxMb ?? + cfg.imessage?.mediaMaxMb); if (providerLimit) return providerLimit * MB; if (cfg.agent?.mediaMaxMb) return cfg.agent.mediaMaxMb * MB; return undefined; @@ -76,20 +82,18 @@ function createProviderHandler(params: { cfg: ClawdbotConfig; provider: Exclude; to: string; + accountId?: string; deps: Required; }): ProviderHandler { const { cfg, to, deps } = params; - const telegramToken = - params.provider === "telegram" - ? resolveTelegramToken(cfg).token || undefined - : undefined; + const accountId = normalizeAccountId(params.accountId); const signalMaxBytes = params.provider === "signal" - ? resolveMediaMaxBytes(cfg, "signal") + ? resolveMediaMaxBytes(cfg, "signal", accountId) : undefined; const imessageMaxBytes = params.provider === "imessage" - ? resolveMediaMaxBytes(cfg, "imessage") + ? resolveMediaMaxBytes(cfg, "imessage", accountId) : undefined; const handlers: Record, ProviderHandler> = { @@ -97,13 +101,17 @@ function createProviderHandler(params: { chunker: providerCaps.whatsapp.chunker, sendText: async (text) => ({ provider: "whatsapp", - ...(await deps.sendWhatsApp(to, text, { verbose: false })), + ...(await deps.sendWhatsApp(to, text, { + verbose: false, + accountId, + })), }), sendMedia: async (caption, mediaUrl) => ({ provider: "whatsapp", ...(await deps.sendWhatsApp(to, caption, { verbose: false, mediaUrl, + accountId, })), }), }, @@ -113,7 +121,7 @@ function createProviderHandler(params: { provider: "telegram", ...(await deps.sendTelegram(to, text, { verbose: false, - token: telegramToken, + accountId, })), }), sendMedia: async (caption, mediaUrl) => ({ @@ -121,7 +129,7 @@ function createProviderHandler(params: { ...(await deps.sendTelegram(to, caption, { verbose: false, mediaUrl, - token: telegramToken, + accountId, })), }), }, @@ -129,13 +137,17 @@ function createProviderHandler(params: { chunker: providerCaps.discord.chunker, sendText: async (text) => ({ provider: "discord", - ...(await deps.sendDiscord(to, text, { verbose: false })), + ...(await deps.sendDiscord(to, text, { + verbose: false, + accountId, + })), }), sendMedia: async (caption, mediaUrl) => ({ provider: "discord", ...(await deps.sendDiscord(to, caption, { verbose: false, mediaUrl, + accountId, })), }), }, @@ -143,24 +155,33 @@ function createProviderHandler(params: { chunker: providerCaps.slack.chunker, sendText: async (text) => ({ provider: "slack", - ...(await deps.sendSlack(to, text)), + ...(await deps.sendSlack(to, text, { + accountId, + })), }), sendMedia: async (caption, mediaUrl) => ({ provider: "slack", - ...(await deps.sendSlack(to, caption, { mediaUrl })), + ...(await deps.sendSlack(to, caption, { + mediaUrl, + accountId, + })), }), }, signal: { chunker: providerCaps.signal.chunker, sendText: async (text) => ({ provider: "signal", - ...(await deps.sendSignal(to, text, { maxBytes: signalMaxBytes })), + ...(await deps.sendSignal(to, text, { + maxBytes: signalMaxBytes, + accountId, + })), }), sendMedia: async (caption, mediaUrl) => ({ provider: "signal", ...(await deps.sendSignal(to, caption, { mediaUrl, maxBytes: signalMaxBytes, + accountId, })), }), }, @@ -168,13 +189,17 @@ function createProviderHandler(params: { chunker: providerCaps.imessage.chunker, sendText: async (text) => ({ provider: "imessage", - ...(await deps.sendIMessage(to, text, { maxBytes: imessageMaxBytes })), + ...(await deps.sendIMessage(to, text, { + maxBytes: imessageMaxBytes, + accountId, + })), }), sendMedia: async (caption, mediaUrl) => ({ provider: "imessage", ...(await deps.sendIMessage(to, caption, { mediaUrl, maxBytes: imessageMaxBytes, + accountId, })), }), }, @@ -187,6 +212,7 @@ export async function deliverOutboundPayloads(params: { cfg: ClawdbotConfig; provider: Exclude; to: string; + accountId?: string; payloads: ReplyPayload[]; deps?: OutboundSendDeps; bestEffort?: boolean; @@ -194,6 +220,7 @@ export async function deliverOutboundPayloads(params: { onPayload?: (payload: NormalizedOutboundPayload) => void; }): Promise { const { cfg, provider, to, payloads } = params; + const accountId = normalizeAccountId(params.accountId); const deps = { sendWhatsApp: params.deps?.sendWhatsApp ?? sendMessageWhatsApp, sendTelegram: params.deps?.sendTelegram ?? sendMessageTelegram, @@ -208,9 +235,10 @@ export async function deliverOutboundPayloads(params: { provider, to, deps, + accountId, }); const textLimit = handler.chunker - ? resolveTextChunkLimit(cfg, provider) + ? resolveTextChunkLimit(cfg, provider, accountId) : undefined; const sendTextChunks = async (text: string) => { diff --git a/src/signal/accounts.ts b/src/signal/accounts.ts new file mode 100644 index 000000000..d752fd6cb --- /dev/null +++ b/src/signal/accounts.ts @@ -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); +} diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index be3e0a05a..89d58cb2a 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -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[] { + 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 { 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 | 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 ", ].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, diff --git a/src/signal/send.ts b/src/signal/send.ts index b05765e0e..50e392783 100644 --- a/src/signal/send.ts +++ b/src/signal/send.ts @@ -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 { - 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()) { diff --git a/src/slack/accounts.ts b/src/slack/accounts.ts new file mode 100644 index 000000000..6030cce8b --- /dev/null +++ b/src/slack/accounts.ts @@ -0,0 +1,113 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import type { SlackAccountConfig } from "../config/types.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../routing/session-key.js"; +import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; + +export type SlackTokenSource = "env" | "config" | "none"; + +export type ResolvedSlackAccount = { + accountId: string; + enabled: boolean; + name?: string; + botToken?: string; + appToken?: string; + botTokenSource: SlackTokenSource; + appTokenSource: SlackTokenSource; + config: SlackAccountConfig; +}; + +function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] { + const accounts = cfg.slack?.accounts; + if (!accounts || typeof accounts !== "object") return []; + return Object.keys(accounts).filter(Boolean); +} + +export function listSlackAccountIds(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 resolveDefaultSlackAccountId(cfg: ClawdbotConfig): string { + const ids = listSlackAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function resolveAccountConfig( + cfg: ClawdbotConfig, + accountId: string, +): SlackAccountConfig | undefined { + const accounts = cfg.slack?.accounts; + if (!accounts || typeof accounts !== "object") return undefined; + return accounts[accountId] as SlackAccountConfig | undefined; +} + +function mergeSlackAccountConfig( + cfg: ClawdbotConfig, + accountId: string, +): SlackAccountConfig { + const { accounts: _ignored, ...base } = (cfg.slack ?? + {}) as SlackAccountConfig & { accounts?: unknown }; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account }; +} + +export function resolveSlackAccount(params: { + cfg: ClawdbotConfig; + accountId?: string | null; +}): ResolvedSlackAccount { + const accountId = normalizeAccountId(params.accountId); + const baseEnabled = params.cfg.slack?.enabled !== false; + const merged = mergeSlackAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + const allowEnv = accountId === DEFAULT_ACCOUNT_ID; + + const botToken = resolveSlackBotToken( + merged.botToken ?? + (allowEnv ? process.env.SLACK_BOT_TOKEN : undefined) ?? + (allowEnv ? params.cfg.slack?.botToken : undefined), + ); + const appToken = resolveSlackAppToken( + merged.appToken ?? + (allowEnv ? process.env.SLACK_APP_TOKEN : undefined) ?? + (allowEnv ? params.cfg.slack?.appToken : undefined), + ); + const botTokenSource: SlackTokenSource = merged.botToken + ? "config" + : allowEnv && process.env.SLACK_BOT_TOKEN + ? "env" + : allowEnv && params.cfg.slack?.botToken + ? "config" + : "none"; + const appTokenSource: SlackTokenSource = merged.appToken + ? "config" + : allowEnv && process.env.SLACK_APP_TOKEN + ? "env" + : allowEnv && params.cfg.slack?.appToken + ? "config" + : "none"; + + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + botToken, + appToken, + botTokenSource, + appTokenSource, + config: merged, + }; +} + +export function listEnabledSlackAccounts( + cfg: ClawdbotConfig, +): ResolvedSlackAccount[] { + return listSlackAccountIds(cfg) + .map((accountId) => resolveSlackAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index 042849126..7d57c9f43 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -28,6 +28,7 @@ import { getReplyFromConfig } from "../auto-reply/reply.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import type { + ClawdbotConfig, SlackReactionNotificationMode, SlackSlashCommandConfig, } from "../config/config.js"; @@ -49,6 +50,7 @@ import { import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; +import { resolveSlackAccount } from "./accounts.js"; import { reactSlackMessage } from "./actions.js"; import { sendMessageSlack } from "./send.js"; import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; @@ -56,6 +58,8 @@ import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; export type MonitorSlackOpts = { botToken?: string; appToken?: string; + accountId?: string; + config?: ClawdbotConfig; runtime?: RuntimeEnv; abortSignal?: AbortSignal; mediaMaxMb?: number; @@ -436,7 +440,11 @@ async function resolveSlackThreadStarter(params: { } export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { - const cfg = loadConfig(); + const cfg = opts.config ?? loadConfig(); + const account = resolveSlackAccount({ + cfg, + accountId: opts.accountId, + }); const sessionCfg = cfg.session; const sessionScope = sessionCfg?.scope ?? "per-sender"; const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; @@ -462,21 +470,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { mainKey, ); }; - const botToken = resolveSlackBotToken( - opts.botToken ?? - process.env.SLACK_BOT_TOKEN ?? - cfg.slack?.botToken ?? - undefined, - ); - const appToken = resolveSlackAppToken( - opts.appToken ?? - process.env.SLACK_APP_TOKEN ?? - cfg.slack?.appToken ?? - undefined, - ); + const botToken = resolveSlackBotToken(opts.botToken ?? account.botToken); + const appToken = resolveSlackAppToken(opts.appToken ?? account.appToken); if (!botToken || !appToken) { throw new Error( - "SLACK_BOT_TOKEN and SLACK_APP_TOKEN (or slack.botToken/slack.appToken) are required for Slack socket mode", + `Slack bot + app tokens missing for account "${account.accountId}" (set slack.accounts.${account.accountId}.botToken/appToken or SLACK_BOT_TOKEN/SLACK_APP_TOKEN for default).`, ); } @@ -488,26 +486,27 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { }, }; - const dmConfig = cfg.slack?.dm; + const slackCfg = account.config; + const dmConfig = slackCfg.dm; const dmPolicy = dmConfig?.policy ?? "pairing"; const allowFrom = normalizeAllowList(dmConfig?.allowFrom); const groupDmEnabled = dmConfig?.groupEnabled ?? false; const groupDmChannels = normalizeAllowList(dmConfig?.groupChannels); - const channelsConfig = cfg.slack?.channels; + const channelsConfig = slackCfg.channels; const dmEnabled = dmConfig?.enabled ?? true; - const groupPolicy = cfg.slack?.groupPolicy ?? "open"; + const groupPolicy = slackCfg.groupPolicy ?? "open"; const useAccessGroups = cfg.commands?.useAccessGroups !== false; - const reactionMode = cfg.slack?.reactionNotifications ?? "own"; - const reactionAllowlist = cfg.slack?.reactionAllowlist ?? []; + const reactionMode = slackCfg.reactionNotifications ?? "own"; + const reactionAllowlist = slackCfg.reactionAllowlist ?? []; const slashCommand = resolveSlackSlashCommandConfig( - opts.slashCommand ?? cfg.slack?.slashCommand, + opts.slashCommand ?? slackCfg.slashCommand, ); - const textLimit = resolveTextChunkLimit(cfg, "slack"); + const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId); const mentionRegexes = buildMentionRegexes(cfg); const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const mediaMaxBytes = - (opts.mediaMaxMb ?? cfg.slack?.mediaMaxMb ?? 20) * 1024 * 1024; + (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024; const logger = getChildLogger({ module: "slack-auto-reply" }); const channelCache = new Map< @@ -790,7 +789,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { "Ask the bot owner to approve with:", "clawdbot pairing approve --provider slack ", ].join("\n"), - { token: botToken, client: app.client }, + { + token: botToken, + client: app.client, + accountId: account.accountId, + }, ); } catch (err) { logVerbose( @@ -922,6 +925,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const route = resolveAgentRoute({ cfg, provider: "slack", + accountId: account.accountId, teamId: teamId || undefined, peer: { kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group", @@ -1071,6 +1075,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { replies: [payload], target: replyTarget, token: botToken, + accountId: account.accountId, runtime, textLimit, threadTs: incomingThreadTs, @@ -1749,6 +1754,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const route = resolveAgentRoute({ cfg, provider: "slack", + accountId: account.accountId, teamId: teamId || undefined, peer: { kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group", @@ -1875,6 +1881,7 @@ async function deliverReplies(params: { replies: ReplyPayload[]; target: string; token: string; + accountId?: string; runtime: RuntimeEnv; textLimit: number; threadTs?: string; @@ -1893,6 +1900,7 @@ async function deliverReplies(params: { await sendMessageSlack(params.target, trimmed, { token: params.token, threadTs: params.threadTs, + accountId: params.accountId, }); } } else { @@ -1904,6 +1912,7 @@ async function deliverReplies(params: { token: params.token, mediaUrl, threadTs: params.threadTs, + accountId: params.accountId, }); } } diff --git a/src/slack/send.ts b/src/slack/send.ts index b53a0d4e2..ad12fdbb8 100644 --- a/src/slack/send.ts +++ b/src/slack/send.ts @@ -6,6 +6,7 @@ import { } from "../auto-reply/chunk.js"; import { loadConfig } from "../config/config.js"; import { loadWebMedia } from "../web/media.js"; +import { resolveSlackAccount } from "./accounts.js"; import { resolveSlackBotToken } from "./token.js"; const SLACK_TEXT_LIMIT = 4000; @@ -22,6 +23,7 @@ type SlackRecipient = type SlackSendOpts = { token?: string; + accountId?: string; mediaUrl?: string; client?: WebClient; threadTs?: string; @@ -32,17 +34,20 @@ export type SlackSendResult = { channelId: string; }; -function resolveToken(explicit?: string) { - const cfgToken = loadConfig().slack?.botToken; - const token = resolveSlackBotToken( - explicit ?? process.env.SLACK_BOT_TOKEN ?? cfgToken ?? undefined, - ); - if (!token) { +function resolveToken(params: { + explicit?: string; + accountId: string; + fallbackToken?: string; +}) { + const explicit = resolveSlackBotToken(params.explicit); + if (explicit) return explicit; + const fallback = resolveSlackBotToken(params.fallbackToken); + if (!fallback) { throw new Error( - "SLACK_BOT_TOKEN or slack.botToken is required for Slack sends", + `Slack bot token missing for account "${params.accountId}" (set slack.accounts.${params.accountId}.botToken or SLACK_BOT_TOKEN for default).`, ); } - return token; + return fallback; } function parseRecipient(raw: string): SlackRecipient { @@ -140,17 +145,25 @@ export async function sendMessageSlack( if (!trimmedMessage && !opts.mediaUrl) { throw new Error("Slack send requires text or media"); } - const token = resolveToken(opts.token); + const cfg = loadConfig(); + const account = resolveSlackAccount({ + cfg, + accountId: opts.accountId, + }); + const token = resolveToken({ + explicit: opts.token, + accountId: account.accountId, + fallbackToken: account.botToken, + }); const client = opts.client ?? new WebClient(token); const recipient = parseRecipient(to); const { channelId } = await resolveChannelId(client, recipient); - const cfg = loadConfig(); - const textLimit = resolveTextChunkLimit(cfg, "slack"); + const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId); const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT); const chunks = chunkMarkdownText(trimmedMessage, chunkLimit); const mediaMaxBytes = - typeof cfg.slack?.mediaMaxMb === "number" - ? cfg.slack.mediaMaxMb * 1024 * 1024 + typeof account.config.mediaMaxMb === "number" + ? account.config.mediaMaxMb * 1024 * 1024 : undefined; let lastMessageId = ""; diff --git a/src/telegram/accounts.ts b/src/telegram/accounts.ts new file mode 100644 index 000000000..f76113880 --- /dev/null +++ b/src/telegram/accounts.ts @@ -0,0 +1,81 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import type { TelegramAccountConfig } from "../config/types.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../routing/session-key.js"; +import { resolveTelegramToken } from "./token.js"; + +export type ResolvedTelegramAccount = { + accountId: string; + enabled: boolean; + name?: string; + token: string; + tokenSource: "env" | "tokenFile" | "config" | "none"; + config: TelegramAccountConfig; +}; + +function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] { + const accounts = cfg.telegram?.accounts; + if (!accounts || typeof accounts !== "object") return []; + return Object.keys(accounts).filter(Boolean); +} + +export function listTelegramAccountIds(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 resolveDefaultTelegramAccountId(cfg: ClawdbotConfig): string { + const ids = listTelegramAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function resolveAccountConfig( + cfg: ClawdbotConfig, + accountId: string, +): TelegramAccountConfig | undefined { + const accounts = cfg.telegram?.accounts; + if (!accounts || typeof accounts !== "object") return undefined; + return accounts[accountId] as TelegramAccountConfig | undefined; +} + +function mergeTelegramAccountConfig( + cfg: ClawdbotConfig, + accountId: string, +): TelegramAccountConfig { + const { accounts: _ignored, ...base } = (cfg.telegram ?? + {}) as TelegramAccountConfig & { accounts?: unknown }; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account }; +} + +export function resolveTelegramAccount(params: { + cfg: ClawdbotConfig; + accountId?: string | null; +}): ResolvedTelegramAccount { + const accountId = normalizeAccountId(params.accountId); + const baseEnabled = params.cfg.telegram?.enabled !== false; + const merged = mergeTelegramAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + const tokenResolution = resolveTelegramToken(params.cfg, { accountId }); + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: tokenResolution.token, + tokenSource: tokenResolution.source, + config: merged, + }; +} + +export function listEnabledTelegramAccounts( + cfg: ClawdbotConfig, +): ResolvedTelegramAccount[] { + return listTelegramAccountIds(cfg) + .map((accountId) => resolveTelegramAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 6ea2d7a10..68d149e56 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -50,6 +50,7 @@ import { import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; import { loadWebMedia } from "../web/media.js"; +import { resolveTelegramAccount } from "./accounts.js"; import { createTelegramDraftStream } from "./draft-stream.js"; import { readTelegramAllowFromStore, @@ -105,6 +106,7 @@ type TelegramContext = { export type TelegramBotOptions = { token: string; + accountId?: string; runtime?: RuntimeEnv; requireMention?: boolean; allowFrom?: Array; @@ -158,14 +160,19 @@ export function createTelegramBot(opts: TelegramBotOptions) { const mediaGroupBuffer = new Map(); const cfg = opts.config ?? loadConfig(); - const textLimit = resolveTextChunkLimit(cfg, "telegram"); - const dmPolicy = cfg.telegram?.dmPolicy ?? "pairing"; - const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom; + const account = resolveTelegramAccount({ + cfg, + accountId: opts.accountId, + }); + const telegramCfg = account.config; + const textLimit = resolveTextChunkLimit(cfg, "telegram", account.accountId); + const dmPolicy = telegramCfg.dmPolicy ?? "pairing"; + const allowFrom = opts.allowFrom ?? telegramCfg.allowFrom; const groupAllowFrom = opts.groupAllowFrom ?? - cfg.telegram?.groupAllowFrom ?? - (cfg.telegram?.allowFrom && cfg.telegram.allowFrom.length > 0 - ? cfg.telegram.allowFrom + telegramCfg.groupAllowFrom ?? + (telegramCfg.allowFrom && telegramCfg.allowFrom.length > 0 + ? telegramCfg.allowFrom : undefined) ?? (opts.allowFrom && opts.allowFrom.length > 0 ? opts.allowFrom : undefined); const normalizeAllowFrom = (list?: Array) => { @@ -205,15 +212,15 @@ export function createTelegramBot(opts: TelegramBotOptions) { (entry) => entry === username || entry === `@${username}`, ); }; - const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "first"; - const streamMode = resolveTelegramStreamMode(cfg); + const replyToMode = opts.replyToMode ?? telegramCfg.replyToMode ?? "first"; + const streamMode = resolveTelegramStreamMode(telegramCfg); const nativeEnabled = cfg.commands?.native === true; const nativeDisabledExplicit = cfg.commands?.native === false; const useAccessGroups = cfg.commands?.useAccessGroups !== false; const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const mediaMaxBytes = - (opts.mediaMaxMb ?? cfg.telegram?.mediaMaxMb ?? 5) * 1024 * 1024; + (opts.mediaMaxMb ?? telegramCfg.mediaMaxMb ?? 5) * 1024 * 1024; const logger = getChildLogger({ module: "telegram-auto-reply" }); const mentionRegexes = buildMentionRegexes(cfg); let botHasTopicsEnabled: boolean | undefined; @@ -237,6 +244,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { resolveProviderGroupPolicy({ cfg, provider: "telegram", + accountId: account.accountId, groupId: String(chatId), }); const resolveGroupActivation = (params: { @@ -264,6 +272,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { resolveProviderGroupRequireMention({ cfg, provider: "telegram", + accountId: account.accountId, groupId: String(chatId), requireMentionOverride: opts.requireMention, overrideOrder: "after-config", @@ -272,7 +281,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { chatId: string | number, messageThreadId?: number, ) => { - const groups = cfg.telegram?.groups; + const groups = telegramCfg.groups; if (!groups) return { groupConfig: undefined, topicConfig: undefined }; const groupKey = String(chatId); const groupConfig = groups[groupKey] ?? groups["*"]; @@ -304,6 +313,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { const route = resolveAgentRoute({ cfg, provider: "telegram", + accountId: account.accountId, peer: { kind: isGroup ? "group" : "dm", id: peerId, @@ -814,7 +824,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { } if (isGroup && useAccessGroups) { - const groupPolicy = cfg.telegram?.groupPolicy ?? "open"; + const groupPolicy = telegramCfg.groupPolicy ?? "open"; if (groupPolicy === "disabled") { await bot.api.sendMessage( chatId, @@ -881,6 +891,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { const route = resolveAgentRoute({ cfg, provider: "telegram", + accountId: account.accountId, peer: { kind: isGroup ? "group" : "dm", id: isGroup @@ -1009,7 +1020,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { // - "open" (default): groups bypass allowFrom, only mention-gating applies // - "disabled": block all group messages entirely // - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom - const groupPolicy = cfg.telegram?.groupPolicy ?? "open"; + const groupPolicy = telegramCfg.groupPolicy ?? "open"; if (groupPolicy === "disabled") { logVerbose(`Blocked telegram group message (groupPolicy: disabled)`); return; @@ -1260,9 +1271,9 @@ function buildTelegramThreadParams(messageThreadId?: number) { } function resolveTelegramStreamMode( - cfg: ReturnType, + telegramCfg: ClawdbotConfig["telegram"], ): TelegramStreamMode { - const raw = cfg.telegram?.streamMode?.trim().toLowerCase(); + const raw = telegramCfg?.streamMode?.trim().toLowerCase(); if (raw === "off" || raw === "partial" || raw === "block") return raw; return "partial"; } diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index 6be0da70a..25eddc299 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -2,13 +2,15 @@ import { type RunOptions, run } from "@grammyjs/runner"; import type { ClawdbotConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; +import { resolveTelegramAccount } from "./accounts.js"; import { createTelegramBot } from "./bot.js"; import { makeProxyFetch } from "./proxy.js"; -import { resolveTelegramToken } from "./token.js"; import { startTelegramWebhook } from "./webhook.js"; export type MonitorTelegramOpts = { token?: string; + accountId?: string; + config?: ClawdbotConfig; runtime?: RuntimeEnv; abortSignal?: AbortSignal; useWebhook?: boolean; @@ -36,20 +38,22 @@ export function createTelegramRunnerOptions( } export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { - const cfg = loadConfig(); - const { token } = resolveTelegramToken(cfg, { - envToken: opts.token, + const cfg = opts.config ?? loadConfig(); + const account = resolveTelegramAccount({ + cfg, + accountId: opts.accountId, }); + const token = opts.token?.trim() || account.token; if (!token) { throw new Error( - "TELEGRAM_BOT_TOKEN or telegram.botToken/tokenFile is required for Telegram gateway", + `Telegram bot token missing for account "${account.accountId}" (set telegram.accounts.${account.accountId}.botToken/tokenFile or TELEGRAM_BOT_TOKEN for default).`, ); } const proxyFetch = opts.proxyFetch ?? - (cfg.telegram?.proxy - ? makeProxyFetch(cfg.telegram?.proxy as string) + (account.config.proxy + ? makeProxyFetch(account.config.proxy as string) : undefined); const bot = createTelegramBot({ @@ -57,6 +61,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { runtime: opts.runtime, proxyFetch, config: cfg, + accountId: account.accountId, }); if (opts.useWebhook) { diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 80b9e7f2c..9b1b01c5d 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -1,17 +1,17 @@ import type { ReactionType, ReactionTypeEmoji } from "@grammyjs/types"; import { Bot, InputFile } from "grammy"; import { loadConfig } from "../config/config.js"; -import type { ClawdbotConfig } from "../config/types.js"; import { formatErrorMessage } from "../infra/errors.js"; import type { RetryConfig } from "../infra/retry.js"; import { createTelegramRetryRunner } from "../infra/retry-policy.js"; import { mediaKindFromMime } from "../media/constants.js"; import { isGifMedia } from "../media/mime.js"; import { loadWebMedia } from "../web/media.js"; -import { resolveTelegramToken } from "./token.js"; +import { resolveTelegramAccount } from "./accounts.js"; type TelegramSendOpts = { token?: string; + accountId?: string; verbose?: boolean; mediaUrl?: string; maxBytes?: number; @@ -30,6 +30,7 @@ type TelegramSendResult = { type TelegramReactionOpts = { token?: string; + accountId?: string; api?: Bot["api"]; remove?: boolean; verbose?: boolean; @@ -39,15 +40,17 @@ type TelegramReactionOpts = { const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; -function resolveToken(explicit?: string, cfg?: ClawdbotConfig): string { +function resolveToken( + explicit: string | undefined, + params: { accountId: string; token: string }, +) { if (explicit?.trim()) return explicit.trim(); - const { token } = resolveTelegramToken(cfg); - if (!token) { + if (!params.token) { throw new Error( - "TELEGRAM_BOT_TOKEN (or telegram.botToken/tokenFile) is required for Telegram sends (Bot API)", + `Telegram bot token missing for account "${params.accountId}" (set telegram.accounts.${params.accountId}.botToken/tokenFile or TELEGRAM_BOT_TOKEN for default).`, ); } - return token.trim(); + return params.token.trim(); } function normalizeChatId(to: string): string { @@ -97,7 +100,11 @@ export async function sendMessageTelegram( opts: TelegramSendOpts = {}, ): Promise { const cfg = loadConfig(); - const token = resolveToken(opts.token, cfg); + const account = resolveTelegramAccount({ + cfg, + accountId: opts.accountId, + }); + const token = resolveToken(opts.token, account); const chatId = normalizeChatId(to); // Use provided api or create a new Bot instance. The nullish coalescing // operator ensures api is always defined (Bot.api is always non-null). @@ -116,7 +123,7 @@ export async function sendMessageTelegram( const hasThreadParams = Object.keys(threadParams).length > 0; const request = createTelegramRetryRunner({ retry: opts.retry, - configRetry: cfg.telegram?.retry, + configRetry: account.config.retry, verbose: opts.verbose, }); @@ -236,13 +243,17 @@ export async function reactMessageTelegram( opts: TelegramReactionOpts = {}, ): Promise<{ ok: true }> { const cfg = loadConfig(); - const token = resolveToken(opts.token, cfg); + const account = resolveTelegramAccount({ + cfg, + accountId: opts.accountId, + }); + const token = resolveToken(opts.token, account); const chatId = normalizeChatId(String(chatIdInput)); const messageId = normalizeMessageId(messageIdInput); const api = opts.api ?? new Bot(token).api; const request = createTelegramRetryRunner({ retry: opts.retry, - configRetry: cfg.telegram?.retry, + configRetry: account.config.retry, verbose: opts.verbose, }); const remove = opts.remove === true; diff --git a/src/telegram/token.ts b/src/telegram/token.ts index 88dd194e6..9f9a1fe58 100644 --- a/src/telegram/token.ts +++ b/src/telegram/token.ts @@ -1,6 +1,10 @@ import fs from "node:fs"; import type { ClawdbotConfig } from "../config/config.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../routing/session-key.js"; export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none"; @@ -11,6 +15,7 @@ export type TelegramTokenResolution = { type ResolveTelegramTokenOpts = { envToken?: string | null; + accountId?: string | null; logMissingFile?: (message: string) => void; }; @@ -18,13 +23,48 @@ export function resolveTelegramToken( cfg?: ClawdbotConfig, opts: ResolveTelegramTokenOpts = {}, ): TelegramTokenResolution { - const envToken = (opts.envToken ?? process.env.TELEGRAM_BOT_TOKEN)?.trim(); + const accountId = normalizeAccountId(opts.accountId); + const accountCfg = + accountId !== DEFAULT_ACCOUNT_ID + ? cfg?.telegram?.accounts?.[accountId] + : cfg?.telegram?.accounts?.[DEFAULT_ACCOUNT_ID]; + const accountTokenFile = accountCfg?.tokenFile?.trim(); + if (accountTokenFile) { + if (!fs.existsSync(accountTokenFile)) { + opts.logMissingFile?.( + `telegram.accounts.${accountId}.tokenFile not found: ${accountTokenFile}`, + ); + return { token: "", source: "none" }; + } + try { + const token = fs.readFileSync(accountTokenFile, "utf-8").trim(); + if (token) { + return { token, source: "tokenFile" }; + } + } catch (err) { + opts.logMissingFile?.( + `telegram.accounts.${accountId}.tokenFile read failed: ${String(err)}`, + ); + return { token: "", source: "none" }; + } + return { token: "", source: "none" }; + } + + const accountToken = accountCfg?.botToken?.trim(); + if (accountToken) { + return { token: accountToken, source: "config" }; + } + + const allowEnv = accountId === DEFAULT_ACCOUNT_ID; + const envToken = allowEnv + ? (opts.envToken ?? process.env.TELEGRAM_BOT_TOKEN)?.trim() + : ""; if (envToken) { return { token: envToken, source: "env" }; } const tokenFile = cfg?.telegram?.tokenFile?.trim(); - if (tokenFile) { + if (tokenFile && allowEnv) { if (!fs.existsSync(tokenFile)) { opts.logMissingFile?.(`telegram.tokenFile not found: ${tokenFile}`); return { token: "", source: "none" }; @@ -38,11 +78,10 @@ export function resolveTelegramToken( opts.logMissingFile?.(`telegram.tokenFile read failed: ${String(err)}`); return { token: "", source: "none" }; } - return { token: "", source: "none" }; } const configToken = cfg?.telegram?.botToken?.trim(); - if (configToken) { + if (configToken && allowEnv) { return { token: configToken, source: "config" }; } diff --git a/src/utils.ts b/src/utils.ts index 589b5b4c0..879bf6f47 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -142,5 +142,25 @@ export function shortenHomeInString(input: string): string { return input.split(home).join("~"); } +export function formatTerminalLink( + label: string, + url: string, + opts?: { fallback?: string; force?: boolean }, +): string { + const esc = "\u001b"; + const safeLabel = label.replaceAll(esc, ""); + const safeUrl = url.replaceAll(esc, ""); + const allow = + opts?.force === true + ? true + : opts?.force === false + ? false + : Boolean(process.stdout.isTTY); + if (!allow) { + return opts?.fallback ?? `${safeLabel} (${safeUrl})`; + } + return `\u001b]8;;${safeUrl}\u0007${safeLabel}\u001b]8;;\u0007`; +} + // Configuration root; can be overridden via CLAWDBOT_STATE_DIR. export const CONFIG_DIR = resolveConfigDir(); diff --git a/src/web/accounts.ts b/src/web/accounts.ts index 1ed06a4a3..17d0c0618 100644 --- a/src/web/accounts.ts +++ b/src/web/accounts.ts @@ -9,6 +9,7 @@ import { resolveUserPath } from "../utils.js"; export type ResolvedWhatsAppAccount = { accountId: string; + name?: string; enabled: boolean; authDir: string; isLegacyAuthDir: boolean; @@ -101,6 +102,7 @@ export function resolveWhatsAppAccount(params: { }); return { accountId, + name: accountCfg?.name?.trim() || undefined, enabled, authDir, isLegacyAuthDir: isLegacy,