feat: add providers CLI and multi-account onboarding

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

View File

@ -260,6 +260,33 @@ Notes:
- Outbound commands default to account `default` if present; otherwise the first configured account id (sorted). - 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`. - 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` ### `routing.groupChat`
Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats. 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`. 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. 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 ```json5
{ {
@ -609,6 +637,7 @@ Retry policy defaults and behavior are documented in [Retry policy](/concepts/re
### `discord` (bot transport) ### `discord` (bot transport)
Configure the Discord bot by setting the bot token and optional gating: 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 ```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:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands. Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands.
Reaction notification modes: 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: Notes:
- Requires Full Disk Access to the Messages DB. - Requires Full Disk Access to the Messages DB.
- The first send will prompt for Messages automation permission. - The first send will prompt for Messages automation permission.
- Prefer `chat_id:<id>` targets. Use `imsg chats --limit 20` to list chats. - Prefer `chat_id:<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` ### `agent.workspace`

View File

@ -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 #### Allowlist + channel routing
Example “single server, only allow me, only allow #help”: Example “single server, only allow me, only allow #help”:

View File

@ -19,11 +19,28 @@ Status: external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio
- macOS with Messages signed in. - macOS with Messages signed in.
- Full Disk Access for Clawdbot + `imsg` (Messages DB access). - Full Disk Access for Clawdbot + `imsg` (Messages DB access).
- Automation permission when sending. - 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) ## Setup (fast path)
1) Ensure Messages is signed in on this Mac. 1) Ensure Messages is signed in on this Mac.
2) Configure iMessage and start the gateway. 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: Example:
```json5 ```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) ## Access control (DMs + groups)
DMs: DMs:
- Default: `imessage.dmPolicy = "pairing"`. - Default: `imessage.dmPolicy = "pairing"`.

View File

@ -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) ## Access control (DMs + groups)
DMs: DMs:
- Default: `signal.dmPolicy = "pairing"`. - Default: `signal.dmPolicy = "pairing"`.

View File

@ -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. 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) ## Manifest (optional)
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Use this Slack app manifest to create the app quickly (adjust the name/command if you want).

View File

@ -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. 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. 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. 5) For groups: add the bot, disable privacy mode (or make it admin), then set `telegram.groups` to control mention gating + allowlists.

View File

@ -85,6 +85,20 @@ describe("resolveTextChunkLimit", () => {
expect(resolveTextChunkLimit(cfg, "telegram")).toBe(1234); 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", () => { it("uses the matching provider override", () => {
const cfg = { const cfg = {
discord: { textChunkLimit: 111 }, discord: { textChunkLimit: 111 },

View File

@ -8,6 +8,7 @@ import {
isSafeFenceBreak, isSafeFenceBreak,
parseFenceSpans, parseFenceSpans,
} from "../markdown/fences.js"; } from "../markdown/fences.js";
import { normalizeAccountId } from "../routing/session-key.js";
export type TextChunkProvider = export type TextChunkProvider =
| "whatsapp" | "whatsapp"
@ -31,15 +32,44 @@ const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record<TextChunkProvider, number> = {
export function resolveTextChunkLimit( export function resolveTextChunkLimit(
cfg: ClawdbotConfig | undefined, cfg: ClawdbotConfig | undefined,
provider?: TextChunkProvider, provider?: TextChunkProvider,
accountId?: string | null,
): number { ): number {
const providerOverride = (() => { const providerOverride = (() => {
if (!provider) return undefined; if (!provider) return undefined;
if (provider === "whatsapp") return cfg?.whatsapp?.textChunkLimit; const normalizedAccountId = normalizeAccountId(accountId);
if (provider === "telegram") return cfg?.telegram?.textChunkLimit; if (provider === "whatsapp") {
if (provider === "discord") return cfg?.discord?.textChunkLimit; return cfg?.whatsapp?.textChunkLimit;
if (provider === "slack") return cfg?.slack?.textChunkLimit; }
if (provider === "signal") return cfg?.signal?.textChunkLimit; if (provider === "telegram") {
if (provider === "imessage") return cfg?.imessage?.textChunkLimit; 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; return undefined;
})(); })();
if (typeof providerOverride === "number" && providerOverride > 0) { if (typeof providerOverride === "number" && providerOverride > 0) {

View File

@ -85,6 +85,7 @@ export async function routeReply(
mediaUrl, mediaUrl,
messageThreadId: threadId, messageThreadId: threadId,
replyToMessageId: resolvedReplyToMessageId, replyToMessageId: resolvedReplyToMessageId,
accountId,
}); });
return { ok: true, messageId: result.messageId }; return { ok: true, messageId: result.messageId };
} }
@ -93,6 +94,7 @@ export async function routeReply(
const result = await sendMessageSlack(to, text, { const result = await sendMessageSlack(to, text, {
mediaUrl, mediaUrl,
threadTs: replyToId, threadTs: replyToId,
accountId,
}); });
return { ok: true, messageId: result.messageId }; return { ok: true, messageId: result.messageId };
} }
@ -101,17 +103,24 @@ export async function routeReply(
const result = await sendMessageDiscord(to, text, { const result = await sendMessageDiscord(to, text, {
mediaUrl, mediaUrl,
replyTo: replyToId, replyTo: replyToId,
accountId,
}); });
return { ok: true, messageId: result.messageId }; return { ok: true, messageId: result.messageId };
} }
case "signal": { case "signal": {
const result = await sendMessageSignal(to, text, { mediaUrl }); const result = await sendMessageSignal(to, text, {
mediaUrl,
accountId,
});
return { ok: true, messageId: result.messageId }; return { ok: true, messageId: result.messageId };
} }
case "imessage": { case "imessage": {
const result = await sendMessageIMessage(to, text, { mediaUrl }); const result = await sendMessageIMessage(to, text, {
mediaUrl,
accountId,
});
return { ok: true, messageId: result.messageId }; return { ok: true, messageId: result.messageId };
} }

View File

@ -42,6 +42,7 @@ import { registerModelsCli } from "./models-cli.js";
import { registerNodesCli } from "./nodes-cli.js"; import { registerNodesCli } from "./nodes-cli.js";
import { registerPairingCli } from "./pairing-cli.js"; import { registerPairingCli } from "./pairing-cli.js";
import { forceFreePort } from "./ports.js"; import { forceFreePort } from "./ports.js";
import { registerProvidersCli } from "./providers-cli.js";
import { registerTelegramCli } from "./telegram-cli.js"; import { registerTelegramCli } from "./telegram-cli.js";
import { registerTuiCli } from "./tui-cli.js"; import { registerTuiCli } from "./tui-cli.js";
@ -637,6 +638,7 @@ Examples:
registerDocsCli(program); registerDocsCli(program);
registerHooksCli(program); registerHooksCli(program);
registerPairingCli(program); registerPairingCli(program);
registerProvidersCli(program);
registerTelegramCli(program); registerTelegramCli(program);
program program

130
src/cli/providers-cli.ts Normal file
View File

@ -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 <ms>", "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 <name>",
"Provider (whatsapp|telegram|discord|slack|signal|imessage)",
)
.option("--account <id>", "Account id (default when omitted)")
.option("--name <name>", "Display name for this account")
.option("--token <token>", "Bot token (Telegram/Discord)")
.option("--token-file <path>", "Bot token file (Telegram)")
.option("--bot-token <token>", "Slack bot token (xoxb-...)")
.option("--app-token <token>", "Slack app token (xapp-...)")
.option("--signal-number <e164>", "Signal account number (E.164)")
.option("--cli-path <path>", "CLI path (signal-cli or imsg)")
.option("--db-path <path>", "iMessage database path")
.option("--service <service>", "iMessage service (imessage|sms|auto)")
.option("--region <region>", "iMessage region (for SMS)")
.option("--auth-dir <path>", "WhatsApp auth directory override")
.option("--http-url <url>", "Signal HTTP daemon base URL")
.option("--http-host <host>", "Signal HTTP host")
.option("--http-port <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 <name>",
"Provider (whatsapp|telegram|discord|slack|signal|imessage)",
)
.option("--account <id>", "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);
}
});
}

View File

@ -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) => { await withTempHome(async (home) => {
const store = path.join(home, "sessions.json"); const store = path.join(home, "sessions.json");
mockConfig(home, store, undefined, undefined, { botToken: "t-1" }); mockConfig(home, store, undefined, undefined, { botToken: "t-1" });
@ -297,7 +297,7 @@ describe("agentCommand", () => {
expect(deps.sendMessageTelegram).toHaveBeenCalledWith( expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"123", "123",
"ok", "ok",
expect.objectContaining({ token: "t-1" }), expect.objectContaining({ accountId: "default", verbose: false }),
); );
} finally { } finally {
if (prevTelegramToken === undefined) { if (prevTelegramToken === undefined) {

View File

@ -322,17 +322,18 @@ function buildProviderBindings(params: {
agentId: string; agentId: string;
selection: ProviderChoice[]; selection: ProviderChoice[];
config: ClawdbotConfig; config: ClawdbotConfig;
whatsappAccountId?: string; accountIds?: Partial<Record<ProviderChoice, string>>;
}): AgentBinding[] { }): AgentBinding[] {
const bindings: AgentBinding[] = []; const bindings: AgentBinding[] = [];
const agentId = normalizeAgentId(params.agentId); const agentId = normalizeAgentId(params.agentId);
for (const provider of params.selection) { for (const provider of params.selection) {
const match: AgentBinding["match"] = { provider }; const match: AgentBinding["match"] = { provider };
if (provider === "whatsapp") { const accountId = params.accountIds?.[provider]?.trim();
const accountId = if (accountId) {
params.whatsappAccountId?.trim() || match.accountId = accountId;
resolveDefaultWhatsAppAccountId(params.config); } else if (provider === "whatsapp") {
match.accountId = accountId || DEFAULT_ACCOUNT_ID; const defaultId = resolveDefaultWhatsAppAccountId(params.config);
match.accountId = defaultId || DEFAULT_ACCOUNT_ID;
} }
bindings.push({ agentId, match }); bindings.push({ agentId, match });
} }
@ -493,15 +494,15 @@ export async function agentsAddCommand(
}); });
let selection: ProviderChoice[] = []; let selection: ProviderChoice[] = [];
let whatsappAccountId: string | undefined; const providerAccountIds: Partial<Record<ProviderChoice, string>> = {};
nextConfig = await setupProviders(nextConfig, runtime, prompter, { nextConfig = await setupProviders(nextConfig, runtime, prompter, {
allowSignalInstall: true, allowSignalInstall: true,
onSelection: (value) => { onSelection: (value) => {
selection = value; selection = value;
}, },
promptWhatsAppAccountId: true, promptAccountIds: true,
onWhatsAppAccountId: (value) => { onAccountId: (provider, accountId) => {
whatsappAccountId = value; providerAccountIds[provider] = accountId;
}, },
}); });
@ -516,7 +517,7 @@ export async function agentsAddCommand(
agentId, agentId,
selection, selection,
config: nextConfig, config: nextConfig,
whatsappAccountId, accountIds: providerAccountIds,
}); });
const result = applyAgentBindings(nextConfig, desiredBindings); const result = applyAgentBindings(nextConfig, desiredBindings);
nextConfig = result.config; nextConfig = result.config;

View File

@ -44,13 +44,17 @@ function normalizeDefaultWorkspacePath(
return next === resolved ? value : next; 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; if (!value) return value;
const replacedClawdis = value.replace(/clawdis/g, "clawdbot"); const replacedClawdis = value.replace(/clawdis/g, "clawdbot");
return replacedClawdis.replace(/clawd(?!bot)/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) return value;
if (!value.includes("clawdbot")) return value; if (!value.includes("clawdbot")) return value;
return value.replace(/clawdbot/g, "clawdis"); return value.replace(/clawdbot/g, "clawdis");

View File

@ -11,8 +11,8 @@ import {
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { runCommandWithTimeout, runExec } from "../process/exec.js"; import { runCommandWithTimeout, runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
import { replaceModernName } from "./doctor-legacy-config.js"; import { replaceModernName } from "./doctor-legacy-config.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
type SandboxScriptInfo = { type SandboxScriptInfo = {
scriptPath: string; scriptPath: string;

View File

@ -257,8 +257,10 @@ export async function noteStateIntegrity(
const recent = entries const recent = entries
.slice() .slice()
.sort((a, b) => { .sort((a, b) => {
const aUpdated = typeof a[1].updatedAt === "number" ? a[1].updatedAt : 0; const aUpdated =
const bUpdated = typeof b[1].updatedAt === "number" ? b[1].updatedAt : 0; typeof a[1].updatedAt === "number" ? a[1].updatedAt : 0;
const bUpdated =
typeof b[1].updatedAt === "number" ? b[1].updatedAt : 0;
return bUpdated - aUpdated; return bUpdated - aUpdated;
}) })
.slice(0, 5); .slice(0, 5);

View File

@ -13,28 +13,25 @@ import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js"; import { resolveUserPath, sleep } from "../utils.js";
import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js"; import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js";
import {
maybeMigrateLegacyConfigFile,
normalizeLegacyConfigValues,
} from "./doctor-legacy-config.js";
import { import {
maybeMigrateLegacyGatewayService, maybeMigrateLegacyGatewayService,
maybeScanExtraGatewayServices, maybeScanExtraGatewayServices,
} from "./doctor-gateway-services.js"; } from "./doctor-gateway-services.js";
import { import {
createDoctorPrompter, maybeMigrateLegacyConfigFile,
type DoctorOptions, normalizeLegacyConfigValues,
} from "./doctor-prompter.js"; } from "./doctor-legacy-config.js";
import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js";
import { maybeRepairSandboxImages } from "./doctor-sandbox.js"; import { maybeRepairSandboxImages } from "./doctor-sandbox.js";
import { noteSecurityWarnings } from "./doctor-security.js"; import { noteSecurityWarnings } from "./doctor-security.js";
import {
detectLegacyStateMigrations,
runLegacyStateMigrations,
} from "./doctor-state-migrations.js";
import { import {
noteStateIntegrity, noteStateIntegrity,
noteWorkspaceBackupTip, noteWorkspaceBackupTip,
} from "./doctor-state-integrity.js"; } from "./doctor-state-integrity.js";
import {
detectLegacyStateMigrations,
runLegacyStateMigrations,
} from "./doctor-state-migrations.js";
import { import {
MEMORY_SYSTEM_PROMPT, MEMORY_SYSTEM_PROMPT,
shouldSuggestMemorySystem, shouldSuggestMemorySystem,

View File

@ -2,13 +2,38 @@ import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import type { DmPolicy } from "../config/types.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 { loginWeb } from "../provider-web.js";
import { import {
DEFAULT_ACCOUNT_ID, DEFAULT_ACCOUNT_ID,
normalizeAccountId, normalizeAccountId,
} from "../routing/session-key.js"; } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.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 { import {
listWhatsAppAccountIds, listWhatsAppAccountIds,
resolveDefaultWhatsAppAccountId, resolveDefaultWhatsAppAccountId,
@ -19,6 +44,53 @@ import { detectBinary } from "./onboard-helpers.js";
import type { ProviderChoice } from "./onboard-types.js"; import type { ProviderChoice } from "./onboard-types.js";
import { installSignalCli } from "./signal-install.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<string> {
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( function addWildcardAllowFrom(
allowFrom?: Array<string | number> | null, allowFrom?: Array<string | number> | null,
): Array<string | number> { ): Array<string | number> {
@ -51,13 +123,13 @@ async function noteProviderPrimer(prompter: WizardPrompter): Promise<void> {
"DM security: default is pairing; unknown DMs get a pairing code.", "DM security: default is pairing; unknown DMs get a pairing code.",
"Approve with: clawdbot pairing approve --provider <provider> <code>", "Approve with: clawdbot pairing approve --provider <provider> <code>",
'Public DMs require dmPolicy="open" + allowFrom=["*"].', '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.", "WhatsApp: works with your own number; recommend a separate phone + eSIM.",
"Discord: very well supported right now.", "Discord: very well supported right now.",
"Slack: supported (Socket Mode).", "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.", "iMessage: this is still a work in progress.",
].join("\n"), ].join("\n"),
"How providers work", "How providers work",
@ -71,7 +143,7 @@ async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise<void> {
"2) Run /newbot (or /mybots)", "2) Run /newbot (or /mybots)",
"3) Copy the token (looks like 123456:ABC...)", "3) Copy the token (looks like 123456:ABC...)",
"Tip: you can also set TELEGRAM_BOT_TOKEN in your env.", "Tip: you can also set TELEGRAM_BOT_TOKEN in your env.",
"Docs: https://docs.clawd.bot/telegram", `Docs: ${docsLink("/telegram", "telegram")}`,
].join("\n"), ].join("\n"),
"Telegram bot token", "Telegram bot token",
); );
@ -84,7 +156,7 @@ async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise<void> {
"2) Bot → Add Bot → Reset Token → copy token", "2) Bot → Add Bot → Reset Token → copy token",
"3) OAuth2 → URL Generator → scope 'bot' → invite to your server", "3) OAuth2 → URL Generator → scope 'bot' → invite to your server",
"Tip: enable Message Content Intent if you need message text.", "Tip: enable Message Content Intent if you need message text.",
"Docs: https://docs.clawd.bot/discord", `Docs: ${docsLink("/discord", "discord")}`,
].join("\n"), ].join("\n"),
"Discord bot token", "Discord bot token",
); );
@ -172,7 +244,7 @@ async function noteSlackTokenHelp(
"4) Enable Event Subscriptions (socket) for message events", "4) Enable Event Subscriptions (socket) for message events",
"5) App Home → enable the Messages tab for DMs", "5) App Home → enable the Messages tab for DMs",
"Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.",
"Docs: https://docs.clawd.bot/slack", `Docs: ${docsLink("/slack", "slack")}`,
"", "",
"Manifest (JSON):", "Manifest (JSON):",
manifest, manifest,
@ -345,7 +417,7 @@ async function maybeConfigureDmPolicies(params: {
"Default: pairing (unknown DMs get a pairing code).", "Default: pairing (unknown DMs get a pairing code).",
`Approve: clawdbot pairing approve --provider ${params.provider} <code>`, `Approve: clawdbot pairing approve --provider ${params.provider} <code>`,
`Public DMs: ${params.policyKey}="open" + ${params.allowFromKey} includes "*".`, `Public DMs: ${params.policyKey}="open" + ${params.allowFromKey} includes "*".`,
"Docs: https://docs.clawd.bot/start/pairing", `Docs: ${docsLink("/start/pairing", "start/pairing")}`,
].join("\n"), ].join("\n"),
`${params.label} DM access`, `${params.label} DM access`,
); );
@ -432,7 +504,7 @@ async function promptWhatsAppAllowFrom(
"- disabled: ignore WhatsApp DMs", "- disabled: ignore WhatsApp DMs",
"", "",
`Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`, `Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`,
"Docs: https://docs.clawd.bot/whatsapp", `Docs: ${docsLink("/whatsapp", "whatsapp")}`,
].join("\n"), ].join("\n"),
"WhatsApp DM access", "WhatsApp DM access",
); );
@ -567,6 +639,9 @@ type SetupProvidersOptions = {
allowDisable?: boolean; allowDisable?: boolean;
allowSignalInstall?: boolean; allowSignalInstall?: boolean;
onSelection?: (selection: ProviderChoice[]) => void; onSelection?: (selection: ProviderChoice[]) => void;
accountIds?: Partial<Record<ProviderChoice, string>>;
onAccountId?: (provider: ProviderChoice, accountId: string) => void;
promptAccountIds?: boolean;
whatsappAccountId?: string; whatsappAccountId?: string;
promptWhatsAppAccountId?: boolean; promptWhatsAppAccountId?: boolean;
onWhatsAppAccountId?: (accountId: string) => void; onWhatsAppAccountId?: (accountId: string) => void;
@ -585,22 +660,31 @@ export async function setupProviders(
const discordEnv = Boolean(process.env.DISCORD_BOT_TOKEN?.trim()); const discordEnv = Boolean(process.env.DISCORD_BOT_TOKEN?.trim());
const slackBotEnv = Boolean(process.env.SLACK_BOT_TOKEN?.trim()); const slackBotEnv = Boolean(process.env.SLACK_BOT_TOKEN?.trim());
const slackAppEnv = Boolean(process.env.SLACK_APP_TOKEN?.trim()); const slackAppEnv = Boolean(process.env.SLACK_APP_TOKEN?.trim());
const telegramConfigured = Boolean( const telegramConfigured = listTelegramAccountIds(cfg).some((accountId) =>
telegramEnv || cfg.telegram?.botToken || cfg.telegram?.tokenFile, Boolean(resolveTelegramAccount({ cfg, accountId }).token),
); );
const discordConfigured = Boolean(discordEnv || cfg.discord?.token); const discordConfigured = listDiscordAccountIds(cfg).some((accountId) =>
const slackConfigured = Boolean( Boolean(resolveDiscordAccount({ cfg, accountId }).token),
(slackBotEnv && slackAppEnv) ||
(cfg.slack?.botToken && cfg.slack?.appToken),
); );
const signalConfigured = Boolean( const slackConfigured = listSlackAccountIds(cfg).some((accountId) => {
cfg.signal?.account || cfg.signal?.httpUrl || cfg.signal?.httpPort, 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 signalCliPath = cfg.signal?.cliPath ?? "signal-cli";
const signalCliDetected = await detectBinary(signalCliPath); const signalCliDetected = await detectBinary(signalCliPath);
const imessageConfigured = Boolean( const imessageConfigured = listIMessageAccountIds(cfg).some((accountId) => {
cfg.imessage?.cliPath || cfg.imessage?.dbPath || cfg.imessage?.allowFrom, 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 imessageCliPath = cfg.imessage?.cliPath ?? "imsg";
const imessageCliDetected = await detectBinary(imessageCliPath); const imessageCliDetected = await detectBinary(imessageCliPath);
@ -635,8 +719,8 @@ export async function setupProviders(
value: "telegram", value: "telegram",
label: "Telegram (Bot API)", label: "Telegram (Bot API)",
hint: telegramConfigured hint: telegramConfigured
? "easy start · configured" ? "recommended · configured"
: "easy start · needs token", : "recommended · newcomer-friendly",
}, },
{ {
value: "whatsapp", value: "whatsapp",
@ -667,20 +751,26 @@ export async function setupProviders(
})) as ProviderChoice[]; })) as ProviderChoice[];
options?.onSelection?.(selection); options?.onSelection?.(selection);
const accountOverrides: Partial<Record<ProviderChoice, string>> = {
...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<ProviderChoice, string> = { const selectionNotes: Record<ProviderChoice, string> = {
telegram: telegram: `Telegram — simplest way to get started: register a bot with @BotFather and get going. Docs: ${docsLink("/telegram", "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: ${docsLink("/whatsapp", "whatsapp")}`,
whatsapp: discord: `Discord — very well supported right now. Docs: ${docsLink("/discord", "discord")}`,
"WhatsApp — works with your own number; recommend a separate phone + eSIM. Docs: https://docs.clawd.bot/whatsapp", slack: `Slack — supported (Socket Mode). Docs: ${docsLink("/slack", "slack")}`,
discord: signal: `Signal — signal-cli linked device; more setup (David Reagans: "Hop on Discord."). Docs: ${docsLink("/signal", "signal")}`,
"Discord — very well supported right now. Docs: https://docs.clawd.bot/discord", imessage: `iMessage — this is still a work in progress. Docs: ${docsLink("/imessage", "imessage")}`,
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",
}; };
const selectedLines = selection const selectedLines = selection
.map((provider) => selectionNotes[provider]) .map((provider) => selectionNotes[provider])
@ -689,38 +779,23 @@ export async function setupProviders(
await prompter.note(selectedLines.join("\n"), "Selected providers"); await prompter.note(selectedLines.join("\n"), "Selected providers");
} }
const shouldPromptAccountIds = options?.promptAccountIds === true;
let next = cfg; let next = cfg;
if (selection.includes("whatsapp")) { if (selection.includes("whatsapp")) {
if (options?.promptWhatsAppAccountId && !options.whatsappAccountId) { const overrideId = accountOverrides.whatsapp?.trim();
const existingIds = listWhatsAppAccountIds(next); if (overrideId) {
const choice = (await prompter.select({ whatsappAccountId = normalizeAccountId(overrideId);
message: "WhatsApp account", } else if (shouldPromptAccountIds || options?.promptWhatsAppAccountId) {
options: [ whatsappAccountId = await promptAccountId({
...existingIds.map((id) => ({ cfg: next,
value: id, prompter,
label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id, label: "WhatsApp",
})), currentId: whatsappAccountId,
{ value: "__new__", label: "Add a new account" }, listAccountIds: listWhatsAppAccountIds,
], defaultAccountId: resolveDefaultWhatsAppAccountId(next),
})) 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;
}
} }
if (whatsappAccountId !== DEFAULT_ACCOUNT_ID) { if (whatsappAccountId !== DEFAULT_ACCOUNT_ID) {
@ -740,7 +815,7 @@ export async function setupProviders(
}; };
} }
options?.onWhatsAppAccountId?.(whatsappAccountId); recordAccount("whatsapp", whatsappAccountId);
whatsappLinked = await detectWhatsAppLinked(next, whatsappAccountId); whatsappLinked = await detectWhatsAppLinked(next, whatsappAccountId);
const { authDir } = resolveWhatsAppAuthDir({ const { authDir } = resolveWhatsAppAuthDir({
cfg: next, cfg: next,
@ -752,7 +827,7 @@ export async function setupProviders(
[ [
"Scan the QR with WhatsApp on your phone.", "Scan the QR with WhatsApp on your phone.",
`Credentials are stored under ${authDir}/ for future runs.`, `Credentials are stored under ${authDir}/ for future runs.`,
"Docs: https://docs.clawd.bot/whatsapp", `Docs: ${docsLink("/whatsapp", "whatsapp")}`,
].join("\n"), ].join("\n"),
"WhatsApp linking", "WhatsApp linking",
); );
@ -769,7 +844,7 @@ export async function setupProviders(
} catch (err) { } catch (err) {
runtime.error(`WhatsApp login failed: ${String(err)}`); runtime.error(`WhatsApp login failed: ${String(err)}`);
await prompter.note( await prompter.note(
"Docs: https://docs.clawd.bot/whatsapp", `Docs: ${docsLink("/whatsapp", "whatsapp")}`,
"WhatsApp help", "WhatsApp help",
); );
} }
@ -784,11 +859,39 @@ export async function setupProviders(
} }
if (selection.includes("telegram")) { 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; let token: string | null = null;
if (!telegramConfigured) { if (!accountConfigured) {
await noteTelegramTokenHelp(prompter); await noteTelegramTokenHelp(prompter);
} }
if (telegramEnv && !cfg.telegram?.botToken) { if (canUseEnv && !resolvedAccount.config.botToken) {
const keepEnv = await prompter.confirm({ const keepEnv = await prompter.confirm({
message: "TELEGRAM_BOT_TOKEN detected. Use env var?", message: "TELEGRAM_BOT_TOKEN detected. Use env var?",
initialValue: true, initialValue: true,
@ -809,7 +912,7 @@ export async function setupProviders(
}), }),
).trim(); ).trim();
} }
} else if (cfg.telegram?.botToken) { } else if (hasConfigToken) {
const keep = await prompter.confirm({ const keep = await prompter.confirm({
message: "Telegram token already configured. Keep it?", message: "Telegram token already configured. Keep it?",
initialValue: true, initialValue: true,
@ -832,23 +935,68 @@ export async function setupProviders(
} }
if (token) { if (token) {
next = { if (telegramAccountId === DEFAULT_ACCOUNT_ID) {
...next, next = {
telegram: { ...next,
...next.telegram, telegram: {
enabled: true, ...next.telegram,
botToken: token, 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")) { 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; let token: string | null = null;
if (!discordConfigured) { if (!accountConfigured) {
await noteDiscordTokenHelp(prompter); await noteDiscordTokenHelp(prompter);
} }
if (discordEnv && !cfg.discord?.token) { if (canUseEnv && !resolvedAccount.config.token) {
const keepEnv = await prompter.confirm({ const keepEnv = await prompter.confirm({
message: "DISCORD_BOT_TOKEN detected. Use env var?", message: "DISCORD_BOT_TOKEN detected. Use env var?",
initialValue: true, initialValue: true,
@ -869,7 +1017,7 @@ export async function setupProviders(
}), }),
).trim(); ).trim();
} }
} else if (cfg.discord?.token) { } else if (hasConfigToken) {
const keep = await prompter.confirm({ const keep = await prompter.confirm({
message: "Discord token already configured. Keep it?", message: "Discord token already configured. Keep it?",
initialValue: true, initialValue: true,
@ -892,18 +1040,67 @@ export async function setupProviders(
} }
if (token) { if (token) {
next = { if (discordAccountId === DEFAULT_ACCOUNT_ID) {
...next, next = {
discord: { ...next,
...next.discord, discord: {
enabled: true, ...next.discord,
token, 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")) { 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 botToken: string | null = null;
let appToken: string | null = null; let appToken: string | null = null;
const slackBotName = String( const slackBotName = String(
@ -912,13 +1109,12 @@ export async function setupProviders(
initialValue: "Clawdbot", initialValue: "Clawdbot",
}), }),
).trim(); ).trim();
if (!slackConfigured) { if (!accountConfigured) {
await noteSlackTokenHelp(prompter, slackBotName); await noteSlackTokenHelp(prompter, slackBotName);
} }
if ( if (
slackBotEnv && canUseEnv &&
slackAppEnv && (!resolvedAccount.config.botToken || !resolvedAccount.config.appToken)
(!cfg.slack?.botToken || !cfg.slack?.appToken)
) { ) {
const keepEnv = await prompter.confirm({ const keepEnv = await prompter.confirm({
message: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?", message: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?",
@ -946,7 +1142,7 @@ export async function setupProviders(
}), }),
).trim(); ).trim();
} }
} else if (cfg.slack?.botToken && cfg.slack?.appToken) { } else if (hasConfigTokens) {
const keep = await prompter.confirm({ const keep = await prompter.confirm({
message: "Slack tokens already configured. Keep them?", message: "Slack tokens already configured. Keep them?",
initialValue: true, initialValue: true,
@ -981,21 +1177,63 @@ export async function setupProviders(
} }
if (botToken && appToken) { if (botToken && appToken) {
next = { if (slackAccountId === DEFAULT_ACCOUNT_ID) {
...next, next = {
slack: { ...next,
...next.slack, slack: {
enabled: true, ...next.slack,
botToken, enabled: true,
appToken, 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")) { if (selection.includes("signal")) {
let resolvedCliPath = signalCliPath; const signalOverride = accountOverrides.signal?.trim();
let cliDetected = signalCliDetected; 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) { if (options?.allowSignalInstall) {
const wantsInstall = await prompter.confirm({ const wantsInstall = await prompter.confirm({
message: cliDetected message: cliDetected
@ -1035,7 +1273,7 @@ export async function setupProviders(
); );
} }
let account = cfg.signal?.account ?? ""; let account = accountConfig.account ?? "";
if (account) { if (account) {
const keep = await prompter.confirm({ const keep = await prompter.confirm({
message: `Signal account set (${account}). Keep it?`, message: `Signal account set (${account}). Keep it?`,
@ -1054,15 +1292,35 @@ export async function setupProviders(
} }
if (account) { if (account) {
next = { if (signalAccountId === DEFAULT_ACCOUNT_ID) {
...next, next = {
signal: { ...next,
...next.signal, signal: {
enabled: true, ...next.signal,
account, enabled: true,
cliPath: resolvedCliPath ?? "signal-cli", 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( await prompter.note(
@ -1070,15 +1328,37 @@ export async function setupProviders(
'Link device with: signal-cli link -n "Clawdbot"', 'Link device with: signal-cli link -n "Clawdbot"',
"Scan QR in Signal → Linked Devices", "Scan QR in Signal → Linked Devices",
"Then run: clawdbot gateway call providers.status --params '{\"probe\":true}'", "Then run: clawdbot gateway call providers.status --params '{\"probe\":true}'",
"Docs: https://docs.clawd.bot/signal", `Docs: ${docsLink("/signal", "signal")}`,
].join("\n"), ].join("\n"),
"Signal next steps", "Signal next steps",
); );
} }
if (selection.includes("imessage")) { if (selection.includes("imessage")) {
let resolvedCliPath = imessageCliPath; const imessageOverride = accountOverrides.imessage?.trim();
if (!imessageCliDetected) { 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({ const entered = await prompter.text({
message: "imsg CLI path", message: "imsg CLI path",
initialValue: resolvedCliPath, initialValue: resolvedCliPath,
@ -1094,14 +1374,33 @@ export async function setupProviders(
} }
if (resolvedCliPath) { if (resolvedCliPath) {
next = { if (imessageAccountId === DEFAULT_ACCOUNT_ID) {
...next, next = {
imessage: { ...next,
...next.imessage, imessage: {
enabled: true, ...next.imessage,
cliPath: resolvedCliPath, 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( await prompter.note(
@ -1110,7 +1409,7 @@ export async function setupProviders(
"Ensure Clawdbot has Full Disk Access to Messages DB.", "Ensure Clawdbot has Full Disk Access to Messages DB.",
"Grant Automation permission for Messages when prompted.", "Grant Automation permission for Messages when prompted.",
"List chats with: imsg chats --limit 20", "List chats with: imsg chats --limit 20",
"Docs: https://docs.clawd.bot/imessage", `Docs: ${docsLink("/imessage", "imessage")}`,
].join("\n"), ].join("\n"),
"iMessage next steps", "iMessage next steps",
); );

View File

@ -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<typeof import("../config/config.js")>();
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<string, { botToken?: string }>;
};
};
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<string, { token?: string }> };
};
expect(next.discord?.accounts?.work).toBeUndefined();
expect(next.discord?.accounts?.default?.token).toBe("d0");
});
});

1077
src/commands/providers.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -137,7 +137,7 @@ describe("sendCommand", () => {
expect(deps.sendMessageTelegram).toHaveBeenCalledWith( expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"123", "123",
"hi", "hi",
expect.objectContaining({ token: "token-abc", verbose: false }), expect.objectContaining({ accountId: "default", verbose: false }),
); );
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
}); });
@ -158,7 +158,7 @@ describe("sendCommand", () => {
expect(deps.sendMessageTelegram).toHaveBeenCalledWith( expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"123", "123",
"hi", "hi",
expect.objectContaining({ token: "cfg-token", verbose: false }), expect.objectContaining({ accountId: "default", verbose: false }),
); );
}); });
@ -209,7 +209,11 @@ describe("sendCommand", () => {
deps, deps,
runtime, runtime,
); );
expect(deps.sendMessageSlack).toHaveBeenCalledWith("channel:C123", "hi"); expect(deps.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"hi",
expect.objectContaining({ accountId: "default" }),
);
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
}); });

View File

@ -1,3 +1,4 @@
import { normalizeAccountId } from "../routing/session-key.js";
import type { ClawdbotConfig } from "./config.js"; import type { ClawdbotConfig } from "./config.js";
export type GroupPolicyProvider = "whatsapp" | "telegram" | "imessage"; export type GroupPolicyProvider = "whatsapp" | "telegram" | "imessage";
@ -18,10 +19,22 @@ type ProviderGroups = Record<string, ProviderGroupConfig>;
function resolveProviderGroups( function resolveProviderGroups(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
provider: GroupPolicyProvider, provider: GroupPolicyProvider,
accountId?: string | null,
): ProviderGroups | undefined { ): ProviderGroups | undefined {
if (provider === "whatsapp") return cfg.whatsapp?.groups; if (provider === "whatsapp") return cfg.whatsapp?.groups;
if (provider === "telegram") return cfg.telegram?.groups; const normalizedAccountId = normalizeAccountId(accountId);
if (provider === "imessage") return cfg.imessage?.groups; 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; return undefined;
} }
@ -29,9 +42,10 @@ export function resolveProviderGroupPolicy(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
provider: GroupPolicyProvider; provider: GroupPolicyProvider;
groupId?: string | null; groupId?: string | null;
accountId?: string | null;
}): ProviderGroupPolicy { }): ProviderGroupPolicy {
const { cfg, provider } = params; 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 allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0);
const normalizedId = params.groupId?.trim(); const normalizedId = params.groupId?.trim();
const groupConfig = normalizedId && groups ? groups[normalizedId] : undefined; const groupConfig = normalizedId && groups ? groups[normalizedId] : undefined;
@ -56,6 +70,7 @@ export function resolveProviderGroupRequireMention(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
provider: GroupPolicyProvider; provider: GroupPolicyProvider;
groupId?: string | null; groupId?: string | null;
accountId?: string | null;
requireMentionOverride?: boolean; requireMentionOverride?: boolean;
overrideOrder?: "before-config" | "after-config"; overrideOrder?: "before-config" | "after-config";
}): boolean { }): boolean {

View File

@ -129,6 +129,8 @@ export type WhatsAppConfig = {
}; };
export type WhatsAppAccountConfig = { 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. */ /** If false, do not start this WhatsApp account provider. Default: true. */
enabled?: boolean; enabled?: boolean;
/** Override auth directory (Baileys multi-file auth state). */ /** Override auth directory (Baileys multi-file auth state). */
@ -258,33 +260,9 @@ export type TelegramActionConfig = {
sendMessage?: boolean; sendMessage?: boolean;
}; };
export type TelegramTopicConfig = { export type TelegramAccountConfig = {
requireMention?: boolean; /** Optional display name for this account (used in CLI/UI lists). */
/** If specified, only load these skills for this topic. Omit = all skills; empty = no skills. */ name?: string;
skills?: string[];
/** If false, disable the bot for this topic. */
enabled?: boolean;
/** Optional allowlist for topic senders (ids or usernames). */
allowFrom?: Array<string | number>;
/** 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<string, TelegramTopicConfig>;
/** If false, disable the bot for this group (and its topics). */
enabled?: boolean;
/** Optional allowlist for group senders (ids or usernames). */
allowFrom?: Array<string | number>;
/** Optional system prompt snippet for this group. */
systemPrompt?: string;
};
export type TelegramConfig = {
/** /**
* Controls how Telegram direct chats (DMs) are handled: * Controls how Telegram direct chats (DMs) are handled:
* - "pairing" (default): unknown senders get a pairing code; owner must approve * - "pairing" (default): unknown senders get a pairing code; owner must approve
@ -293,10 +271,10 @@ export type TelegramConfig = {
* - "disabled": ignore all inbound DMs * - "disabled": ignore all inbound DMs
*/ */
dmPolicy?: DmPolicy; dmPolicy?: DmPolicy;
/** If false, do not start the Telegram provider. Default: true. */ /** If false, do not start this Telegram account. Default: true. */
enabled?: boolean; enabled?: boolean;
botToken?: string; 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; tokenFile?: string;
/** Control reply threading when reply tags are present (off|first|all). */ /** Control reply threading when reply tags are present (off|first|all). */
replyToMode?: ReplyToMode; replyToMode?: ReplyToMode;
@ -326,6 +304,37 @@ export type TelegramConfig = {
actions?: TelegramActionConfig; 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<string | number>;
/** 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<string, TelegramTopicConfig>;
/** If false, disable the bot for this group (and its topics). */
enabled?: boolean;
/** Optional allowlist for group senders (ids or usernames). */
allowFrom?: Array<string | number>;
/** Optional system prompt snippet for this group. */
systemPrompt?: string;
};
export type TelegramConfig = {
/** Optional per-account Telegram configuration (multi-account). */
accounts?: Record<string, TelegramAccountConfig>;
} & TelegramAccountConfig;
export type DiscordDmConfig = { export type DiscordDmConfig = {
/** If false, ignore all incoming Discord DMs. Default: true. */ /** If false, ignore all incoming Discord DMs. Default: true. */
enabled?: boolean; enabled?: boolean;
@ -387,8 +396,10 @@ export type DiscordActionConfig = {
stickerUploads?: boolean; stickerUploads?: boolean;
}; };
export type DiscordConfig = { export type DiscordAccountConfig = {
/** If false, do not start the Discord provider. Default: true. */ /** 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; enabled?: boolean;
token?: string; token?: string;
/** /**
@ -413,6 +424,11 @@ export type DiscordConfig = {
guilds?: Record<string, DiscordGuildEntry>; guilds?: Record<string, DiscordGuildEntry>;
}; };
export type DiscordConfig = {
/** Optional per-account Discord configuration (multi-account). */
accounts?: Record<string, DiscordAccountConfig>;
} & DiscordAccountConfig;
export type SlackDmConfig = { export type SlackDmConfig = {
/** If false, ignore all incoming Slack DMs. Default: true. */ /** If false, ignore all incoming Slack DMs. Default: true. */
enabled?: boolean; enabled?: boolean;
@ -465,8 +481,10 @@ export type SlackSlashCommandConfig = {
ephemeral?: boolean; ephemeral?: boolean;
}; };
export type SlackConfig = { export type SlackAccountConfig = {
/** If false, do not start the Slack provider. Default: true. */ /** 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; enabled?: boolean;
botToken?: string; botToken?: string;
appToken?: string; appToken?: string;
@ -491,8 +509,15 @@ export type SlackConfig = {
channels?: Record<string, SlackChannelConfig>; channels?: Record<string, SlackChannelConfig>;
}; };
export type SignalConfig = { export type SlackConfig = {
/** If false, do not start the Signal provider. Default: true. */ /** Optional per-account Slack configuration (multi-account). */
accounts?: Record<string, SlackAccountConfig>;
} & 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; enabled?: boolean;
/** Optional explicit E.164 account for signal-cli. */ /** Optional explicit E.164 account for signal-cli. */
account?: string; account?: string;
@ -527,8 +552,15 @@ export type SignalConfig = {
mediaMaxMb?: number; mediaMaxMb?: number;
}; };
export type IMessageConfig = { export type SignalConfig = {
/** If false, do not start the iMessage provider. Default: true. */ /** Optional per-account Signal configuration (multi-account). */
accounts?: Record<string, SignalAccountConfig>;
} & 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; enabled?: boolean;
/** imsg CLI binary path (default: imsg). */ /** imsg CLI binary path (default: imsg). */
cliPath?: string; cliPath?: string;
@ -565,6 +597,11 @@ export type IMessageConfig = {
>; >;
}; };
export type IMessageConfig = {
/** Optional per-account iMessage configuration (multi-account). */
accounts?: Record<string, IMessageAccountConfig>;
} & IMessageAccountConfig;
export type QueueMode = export type QueueMode =
| "steer" | "steer"
| "followup" | "followup"

View File

@ -89,6 +89,26 @@ const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]);
const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]); const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]);
const normalizeAllowFrom = (values?: Array<string | number>): string[] =>
(values ?? []).map((v) => String(v).trim()).filter(Boolean);
const requireOpenAllowFrom = (params: {
policy?: string;
allowFrom?: Array<string | number>;
ctx: z.RefinementCtx;
path: Array<string | number>;
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 const RetryConfigSchema = z
.object({ .object({
attempts: z.number().int().min(1).optional(), attempts: z.number().int().min(1).optional(),
@ -121,6 +141,316 @@ const HexColorSchema = z
.string() .string()
.regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)"); .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 const SessionSchema = z
.object({ .object({
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(), scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
@ -777,6 +1107,7 @@ export const ClawdbotSchema = z.object({
z.string(), z.string(),
z z
.object({ .object({
name: z.string().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */ /** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
authDir: z.string().optional(), authDir: z.string().optional(),
@ -849,311 +1180,12 @@ export const ClawdbotSchema = z.object({
}); });
}) })
.optional(), .optional(),
telegram: z
.object({ telegram: TelegramConfigSchema.optional(),
enabled: z.boolean().optional(), discord: DiscordConfigSchema.optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"), slack: SlackConfigSchema.optional(),
botToken: z.string().optional(), signal: SignalConfigSchema.optional(),
tokenFile: z.string().optional(), imessage: IMessageConfigSchema.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(),
bridge: z bridge: z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),

81
src/discord/accounts.ts Normal file
View File

@ -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);
}

View File

@ -46,6 +46,8 @@ describe("discord tool result dispatch", () => {
const runtimeError = vi.fn(); const runtimeError = vi.fn();
const handler = createDiscordMessageHandler({ const handler = createDiscordMessageHandler({
cfg, cfg,
discordConfig: cfg.discord,
accountId: "default",
token: "token", token: "token",
runtime: { runtime: {
log: vi.fn(), log: vi.fn(),
@ -115,6 +117,8 @@ describe("discord tool result dispatch", () => {
const handler = createDiscordMessageHandler({ const handler = createDiscordMessageHandler({
cfg, cfg,
discordConfig: cfg.discord,
accountId: "default",
token: "token", token: "token",
runtime: { runtime: {
log: vi.fn(), log: vi.fn(),
@ -197,6 +201,8 @@ describe("discord tool result dispatch", () => {
const handler = createDiscordMessageHandler({ const handler = createDiscordMessageHandler({
cfg, cfg,
discordConfig: cfg.discord,
accountId: "default",
token: "token", token: "token",
runtime: { runtime: {
log: vi.fn(), log: vi.fn(),
@ -306,6 +312,8 @@ describe("discord tool result dispatch", () => {
const handler = createDiscordMessageHandler({ const handler = createDiscordMessageHandler({
cfg, cfg,
discordConfig: cfg.discord,
accountId: "default",
token: "token", token: "token",
runtime: { runtime: {
log: vi.fn(), log: vi.fn(),

View File

@ -42,7 +42,7 @@ import {
} from "../auto-reply/reply/reply-dispatcher.js"; } from "../auto-reply/reply/reply-dispatcher.js";
import { getReplyFromConfig } from "../auto-reply/reply.js"; import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.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 { loadConfig } from "../config/config.js";
import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
@ -62,12 +62,15 @@ import {
import { resolveThreadSessionKeys } from "../routing/session-key.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { loadWebMedia } from "../web/media.js"; import { loadWebMedia } from "../web/media.js";
import { resolveDiscordAccount } from "./accounts.js";
import { fetchDiscordApplicationId } from "./probe.js"; import { fetchDiscordApplicationId } from "./probe.js";
import { reactMessageDiscord, sendMessageDiscord } from "./send.js"; import { reactMessageDiscord, sendMessageDiscord } from "./send.js";
import { normalizeDiscordToken } from "./token.js"; import { normalizeDiscordToken } from "./token.js";
export type MonitorDiscordOpts = { export type MonitorDiscordOpts = {
token?: string; token?: string;
accountId?: string;
config?: ClawdbotConfig;
runtime?: RuntimeEnv; runtime?: RuntimeEnv;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
mediaMaxMb?: number; mediaMaxMb?: number;
@ -244,16 +247,15 @@ function summarizeGuilds(entries?: Record<string, DiscordGuildEntryResolved>) {
} }
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const cfg = loadConfig(); const cfg = opts.config ?? loadConfig();
const token = normalizeDiscordToken( const account = resolveDiscordAccount({
opts.token ?? cfg,
process.env.DISCORD_BOT_TOKEN ?? accountId: opts.accountId,
cfg.discord?.token ?? });
undefined, const token = normalizeDiscordToken(opts.token ?? undefined) ?? account.token;
);
if (!token) { if (!token) {
throw new Error( 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 discordCfg = account.config;
const guildEntries = cfg.discord?.guilds; const dmConfig = discordCfg.dm;
const groupPolicy = cfg.discord?.groupPolicy ?? "open"; const guildEntries = discordCfg.guilds;
const groupPolicy = discordCfg.groupPolicy ?? "open";
const allowFrom = dmConfig?.allowFrom; const allowFrom = dmConfig?.allowFrom;
const mediaMaxBytes = const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; (opts.mediaMaxMb ?? discordCfg.mediaMaxMb ?? 8) * 1024 * 1024;
const textLimit = resolveTextChunkLimit(cfg, "discord"); const textLimit = resolveTextChunkLimit(cfg, "discord", account.accountId);
const historyLimit = Math.max( const historyLimit = Math.max(
0, 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 dmEnabled = dmConfig?.enabled ?? true;
const dmPolicy = dmConfig?.policy ?? "pairing"; const dmPolicy = dmConfig?.policy ?? "pairing";
const groupDmEnabled = dmConfig?.groupEnabled ?? false; const groupDmEnabled = dmConfig?.groupEnabled ?? false;
@ -303,6 +306,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
createDiscordNativeCommand({ createDiscordNativeCommand({
command: spec, command: spec,
cfg, cfg,
discordConfig: discordCfg,
accountId: account.accountId,
sessionPrefix, sessionPrefix,
ephemeralDefault, ephemeralDefault,
}), }),
@ -359,6 +364,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const messageHandler = createDiscordMessageHandler({ const messageHandler = createDiscordMessageHandler({
cfg, cfg,
discordConfig: discordCfg,
accountId: account.accountId,
token, token,
runtime, runtime,
botUserId, botUserId,
@ -377,6 +384,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
client.listeners.push(new DiscordMessageListener(messageHandler, logger)); client.listeners.push(new DiscordMessageListener(messageHandler, logger));
client.listeners.push( client.listeners.push(
new DiscordReactionListener({ new DiscordReactionListener({
cfg,
accountId: account.accountId,
runtime, runtime,
botUserId, botUserId,
guildEntries, guildEntries,
@ -385,6 +394,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
); );
client.listeners.push( client.listeners.push(
new DiscordReactionRemoveListener({ new DiscordReactionRemoveListener({
cfg,
accountId: account.accountId,
runtime, runtime,
botUserId, botUserId,
guildEntries, guildEntries,
@ -431,6 +442,8 @@ async function clearDiscordNativeCommands(params: {
export function createDiscordMessageHandler(params: { export function createDiscordMessageHandler(params: {
cfg: ReturnType<typeof loadConfig>; cfg: ReturnType<typeof loadConfig>;
discordConfig: ClawdbotConfig["discord"];
accountId: string;
token: string; token: string;
runtime: RuntimeEnv; runtime: RuntimeEnv;
botUserId?: string; botUserId?: string;
@ -447,6 +460,8 @@ export function createDiscordMessageHandler(params: {
}): DiscordMessageHandler { }): DiscordMessageHandler {
const { const {
cfg, cfg,
discordConfig,
accountId,
token, token,
runtime, runtime,
botUserId, botUserId,
@ -465,7 +480,7 @@ export function createDiscordMessageHandler(params: {
const mentionRegexes = buildMentionRegexes(cfg); const mentionRegexes = buildMentionRegexes(cfg);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const groupPolicy = cfg.discord?.groupPolicy ?? "open"; const groupPolicy = discordConfig?.groupPolicy ?? "open";
return async (data, client) => { return async (data, client) => {
try { try {
@ -490,7 +505,7 @@ export function createDiscordMessageHandler(params: {
return; return;
} }
const dmPolicy = cfg.discord?.dm?.policy ?? "pairing"; const dmPolicy = discordConfig?.dm?.policy ?? "pairing";
let commandAuthorized = true; let commandAuthorized = true;
if (isDirectMessage) { if (isDirectMessage) {
if (dmPolicy === "disabled") { if (dmPolicy === "disabled") {
@ -539,7 +554,7 @@ export function createDiscordMessageHandler(params: {
"Ask the bot owner to approve with:", "Ask the bot owner to approve with:",
"clawdbot pairing approve --provider discord <code>", "clawdbot pairing approve --provider discord <code>",
].join("\n"), ].join("\n"),
{ token, rest: client.rest }, { token, rest: client.rest, accountId },
); );
} catch (err) { } catch (err) {
logVerbose( logVerbose(
@ -633,6 +648,7 @@ export function createDiscordMessageHandler(params: {
const route = resolveAgentRoute({ const route = resolveAgentRoute({
cfg, cfg,
provider: "discord", provider: "discord",
accountId,
guildId: data.guild_id ?? undefined, guildId: data.guild_id ?? undefined,
peer: { peer: {
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel", kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
@ -988,6 +1004,7 @@ export function createDiscordMessageHandler(params: {
replies: [payload], replies: [payload],
target: replyTarget, target: replyTarget,
token, token,
accountId,
rest: client.rest, rest: client.rest,
runtime, runtime,
replyToMode, replyToMode,
@ -1068,6 +1085,8 @@ class DiscordMessageListener extends MessageCreateListener {
class DiscordReactionListener extends MessageReactionAddListener { class DiscordReactionListener extends MessageReactionAddListener {
constructor( constructor(
private params: { private params: {
cfg: ReturnType<typeof loadConfig>;
accountId: string;
runtime: RuntimeEnv; runtime: RuntimeEnv;
botUserId?: string; botUserId?: string;
guildEntries?: Record<string, DiscordGuildEntryResolved>; guildEntries?: Record<string, DiscordGuildEntryResolved>;
@ -1084,6 +1103,8 @@ class DiscordReactionListener extends MessageReactionAddListener {
data, data,
client, client,
action: "added", action: "added",
cfg: this.params.cfg,
accountId: this.params.accountId,
botUserId: this.params.botUserId, botUserId: this.params.botUserId,
guildEntries: this.params.guildEntries, guildEntries: this.params.guildEntries,
logger: this.params.logger, logger: this.params.logger,
@ -1102,6 +1123,8 @@ class DiscordReactionListener extends MessageReactionAddListener {
class DiscordReactionRemoveListener extends MessageReactionRemoveListener { class DiscordReactionRemoveListener extends MessageReactionRemoveListener {
constructor( constructor(
private params: { private params: {
cfg: ReturnType<typeof loadConfig>;
accountId: string;
runtime: RuntimeEnv; runtime: RuntimeEnv;
botUserId?: string; botUserId?: string;
guildEntries?: Record<string, DiscordGuildEntryResolved>; guildEntries?: Record<string, DiscordGuildEntryResolved>;
@ -1118,6 +1141,8 @@ class DiscordReactionRemoveListener extends MessageReactionRemoveListener {
data, data,
client, client,
action: "removed", action: "removed",
cfg: this.params.cfg,
accountId: this.params.accountId,
botUserId: this.params.botUserId, botUserId: this.params.botUserId,
guildEntries: this.params.guildEntries, guildEntries: this.params.guildEntries,
logger: this.params.logger, logger: this.params.logger,
@ -1137,6 +1162,8 @@ async function handleDiscordReactionEvent(params: {
data: DiscordReactionEvent; data: DiscordReactionEvent;
client: Client; client: Client;
action: "added" | "removed"; action: "added" | "removed";
cfg: ReturnType<typeof loadConfig>;
accountId: string;
botUserId?: string; botUserId?: string;
guildEntries?: Record<string, DiscordGuildEntryResolved>; guildEntries?: Record<string, DiscordGuildEntryResolved>;
logger: ReturnType<typeof getChildLogger>; logger: ReturnType<typeof getChildLogger>;
@ -1202,10 +1229,10 @@ async function handleDiscordReactionEvent(params: {
: undefined; : undefined;
const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${data.message_id}`; const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${data.message_id}`;
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
const cfg = loadConfig();
const route = resolveAgentRoute({ const route = resolveAgentRoute({
cfg, cfg: params.cfg,
provider: "discord", provider: "discord",
accountId: params.accountId,
guildId: data.guild_id ?? undefined, guildId: data.guild_id ?? undefined,
peer: { kind: "channel", id: data.channel_id }, peer: { kind: "channel", id: data.channel_id },
}); });
@ -1227,10 +1254,19 @@ function createDiscordNativeCommand(params: {
acceptsArgs: boolean; acceptsArgs: boolean;
}; };
cfg: ReturnType<typeof loadConfig>; cfg: ReturnType<typeof loadConfig>;
discordConfig: ClawdbotConfig["discord"];
accountId: string;
sessionPrefix: string; sessionPrefix: string;
ephemeralDefault: boolean; ephemeralDefault: boolean;
}) { }) {
const { command, cfg, sessionPrefix, ephemeralDefault } = params; const {
command,
cfg,
discordConfig,
accountId,
sessionPrefix,
ephemeralDefault,
} = params;
return new (class extends Command { return new (class extends Command {
name = command.name; name = command.name;
description = command.description; description = command.description;
@ -1266,7 +1302,7 @@ function createDiscordNativeCommand(params: {
); );
const guildInfo = resolveDiscordGuildEntry({ const guildInfo = resolveDiscordGuildEntry({
guild: interaction.guild ?? undefined, guild: interaction.guild ?? undefined,
guildEntries: cfg.discord?.guilds, guildEntries: discordConfig?.guilds,
}); });
const channelConfig = interaction.guild const channelConfig = interaction.guild
? resolveDiscordChannelConfig({ ? resolveDiscordChannelConfig({
@ -1294,7 +1330,7 @@ function createDiscordNativeCommand(params: {
Object.keys(guildInfo?.channels ?? {}).length > 0; Object.keys(guildInfo?.channels ?? {}).length > 0;
const channelAllowed = channelConfig?.allowed !== false; const channelAllowed = channelConfig?.allowed !== false;
const allowByPolicy = isDiscordGroupAllowedByPolicy({ const allowByPolicy = isDiscordGroupAllowedByPolicy({
groupPolicy: cfg.discord?.groupPolicy ?? "open", groupPolicy: discordConfig?.groupPolicy ?? "open",
channelAllowlistConfigured, channelAllowlistConfigured,
channelAllowed, channelAllowed,
}); });
@ -1305,8 +1341,8 @@ function createDiscordNativeCommand(params: {
return; return;
} }
} }
const dmEnabled = cfg.discord?.dm?.enabled ?? true; const dmEnabled = discordConfig?.dm?.enabled ?? true;
const dmPolicy = cfg.discord?.dm?.policy ?? "pairing"; const dmPolicy = discordConfig?.dm?.policy ?? "pairing";
let commandAuthorized = true; let commandAuthorized = true;
if (isDirectMessage) { if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") { if (!dmEnabled || dmPolicy === "disabled") {
@ -1318,7 +1354,7 @@ function createDiscordNativeCommand(params: {
"discord", "discord",
).catch(() => []); ).catch(() => []);
const effectiveAllowFrom = [ const effectiveAllowFrom = [
...(cfg.discord?.dm?.allowFrom ?? []), ...(discordConfig?.dm?.allowFrom ?? []),
...storeAllowFrom, ...storeAllowFrom,
]; ];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [ 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." }); await interaction.reply({ content: "Discord group DMs are disabled." });
return; return;
} }
@ -1395,6 +1431,7 @@ function createDiscordNativeCommand(params: {
const route = resolveAgentRoute({ const route = resolveAgentRoute({
cfg, cfg,
provider: "discord", provider: "discord",
accountId,
guildId: interaction.guild?.id ?? undefined, guildId: interaction.guild?.id ?? undefined,
peer: { peer: {
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel", kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
@ -1544,6 +1581,7 @@ async function deliverDiscordReply(params: {
replies: ReplyPayload[]; replies: ReplyPayload[];
target: string; target: string;
token: string; token: string;
accountId?: string;
rest?: RequestClient; rest?: RequestClient;
runtime: RuntimeEnv; runtime: RuntimeEnv;
textLimit: number; textLimit: number;
@ -1563,6 +1601,7 @@ async function deliverDiscordReply(params: {
await sendMessageDiscord(params.target, trimmed, { await sendMessageDiscord(params.target, trimmed, {
token: params.token, token: params.token,
rest: params.rest, rest: params.rest,
accountId: params.accountId,
}); });
} }
continue; continue;
@ -1574,12 +1613,14 @@ async function deliverDiscordReply(params: {
token: params.token, token: params.token,
rest: params.rest, rest: params.rest,
mediaUrl: firstMedia, mediaUrl: firstMedia,
accountId: params.accountId,
}); });
for (const extra of mediaList.slice(1)) { for (const extra of mediaList.slice(1)) {
await sendMessageDiscord(params.target, "", { await sendMessageDiscord(params.target, "", {
token: params.token, token: params.token,
rest: params.rest, rest: params.rest,
mediaUrl: extra, mediaUrl: extra,
accountId: params.accountId,
}); });
} }
} }

View File

@ -30,6 +30,7 @@ import {
type PollInput, type PollInput,
} from "../polls.js"; } from "../polls.js";
import { loadWebMedia, loadWebMediaRaw } from "../web/media.js"; import { loadWebMedia, loadWebMediaRaw } from "../web/media.js";
import { resolveDiscordAccount } from "./accounts.js";
import { normalizeDiscordToken } from "./token.js"; import { normalizeDiscordToken } from "./token.js";
const DISCORD_TEXT_LIMIT = 2000; const DISCORD_TEXT_LIMIT = 2000;
@ -74,6 +75,7 @@ type DiscordRecipient =
type DiscordSendOpts = { type DiscordSendOpts = {
token?: string; token?: string;
accountId?: string;
mediaUrl?: string; mediaUrl?: string;
verbose?: boolean; verbose?: boolean;
rest?: RequestClient; rest?: RequestClient;
@ -88,6 +90,7 @@ export type DiscordSendResult = {
export type DiscordReactOpts = { export type DiscordReactOpts = {
token?: string; token?: string;
accountId?: string;
rest?: RequestClient; rest?: RequestClient;
verbose?: boolean; verbose?: boolean;
retry?: RetryConfig; retry?: RetryConfig;
@ -179,17 +182,20 @@ export type DiscordStickerUpload = {
mediaUrl: string; mediaUrl: string;
}; };
function resolveToken(explicit?: string) { function resolveToken(params: {
const cfgToken = loadConfig().discord?.token; explicit?: string;
const token = normalizeDiscordToken( accountId: string;
explicit ?? process.env.DISCORD_BOT_TOKEN ?? cfgToken ?? undefined, fallbackToken?: string;
); }) {
if (!token) { const explicit = normalizeDiscordToken(params.explicit);
if (explicit) return explicit;
const fallback = normalizeDiscordToken(params.fallbackToken);
if (!fallback) {
throw new Error( 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) { function resolveRest(token: string, rest?: RequestClient) {
@ -198,22 +204,32 @@ function resolveRest(token: string, rest?: RequestClient) {
type DiscordClientOpts = { type DiscordClientOpts = {
token?: string; token?: string;
accountId?: string;
rest?: RequestClient; rest?: RequestClient;
retry?: RetryConfig; retry?: RetryConfig;
verbose?: boolean; verbose?: boolean;
}; };
function createDiscordClient(opts: DiscordClientOpts, cfg = loadConfig()) { 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 rest = resolveRest(token, opts.rest);
const request = createDiscordRetryRunner({ const request = createDiscordRetryRunner({
retry: opts.retry, retry: opts.retry,
configRetry: cfg.discord?.retry, configRetry: account.config.retry,
verbose: opts.verbose, verbose: opts.verbose,
}); });
return { token, rest, request }; return { token, rest, request };
} }
function resolveDiscordRest(opts: DiscordClientOpts) {
return createDiscordClient(opts).rest;
}
function normalizeReactionEmoji(raw: string) { function normalizeReactionEmoji(raw: string) {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) { if (!trimmed) {
@ -635,8 +651,7 @@ export async function removeReactionDiscord(
emoji: string, emoji: string,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
) { ) {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
const encoded = normalizeReactionEmoji(emoji); const encoded = normalizeReactionEmoji(emoji);
await rest.delete( await rest.delete(
Routes.channelMessageOwnReaction(channelId, messageId, encoded), Routes.channelMessageOwnReaction(channelId, messageId, encoded),
@ -649,8 +664,7 @@ export async function removeOwnReactionsDiscord(
messageId: string, messageId: string,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
): Promise<{ ok: true; removed: string[] }> { ): Promise<{ ok: true; removed: string[] }> {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
const message = (await rest.get( const message = (await rest.get(
Routes.channelMessage(channelId, messageId), Routes.channelMessage(channelId, messageId),
)) as { )) as {
@ -683,8 +697,7 @@ export async function fetchReactionsDiscord(
messageId: string, messageId: string,
opts: DiscordReactOpts & { limit?: number } = {}, opts: DiscordReactOpts & { limit?: number } = {},
): Promise<DiscordReactionSummary[]> { ): Promise<DiscordReactionSummary[]> {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
const message = (await rest.get( const message = (await rest.get(
Routes.channelMessage(channelId, messageId), Routes.channelMessage(channelId, messageId),
)) as { )) as {
@ -733,8 +746,7 @@ export async function fetchChannelPermissionsDiscord(
channelId: string, channelId: string,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
): Promise<DiscordPermissionsSummary> { ): Promise<DiscordPermissionsSummary> {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
const channel = (await rest.get(Routes.channel(channelId))) as APIChannel; const channel = (await rest.get(Routes.channel(channelId))) as APIChannel;
const channelType = "type" in channel ? channel.type : undefined; const channelType = "type" in channel ? channel.type : undefined;
const guildId = "guild_id" in channel ? channel.guild_id : undefined; const guildId = "guild_id" in channel ? channel.guild_id : undefined;
@ -808,8 +820,7 @@ export async function readMessagesDiscord(
query: DiscordMessageQuery = {}, query: DiscordMessageQuery = {},
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
): Promise<APIMessage[]> { ): Promise<APIMessage[]> {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
const limit = const limit =
typeof query.limit === "number" && Number.isFinite(query.limit) typeof query.limit === "number" && Number.isFinite(query.limit)
? Math.min(Math.max(Math.floor(query.limit), 1), 100) ? Math.min(Math.max(Math.floor(query.limit), 1), 100)
@ -831,8 +842,7 @@ export async function editMessageDiscord(
payload: DiscordMessageEdit, payload: DiscordMessageEdit,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
): Promise<APIMessage> { ): Promise<APIMessage> {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
return (await rest.patch(Routes.channelMessage(channelId, messageId), { return (await rest.patch(Routes.channelMessage(channelId, messageId), {
body: { content: payload.content }, body: { content: payload.content },
})) as APIMessage; })) as APIMessage;
@ -843,8 +853,7 @@ export async function deleteMessageDiscord(
messageId: string, messageId: string,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
) { ) {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
await rest.delete(Routes.channelMessage(channelId, messageId)); await rest.delete(Routes.channelMessage(channelId, messageId));
return { ok: true }; return { ok: true };
} }
@ -854,8 +863,7 @@ export async function pinMessageDiscord(
messageId: string, messageId: string,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
) { ) {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
await rest.put(Routes.channelPin(channelId, messageId)); await rest.put(Routes.channelPin(channelId, messageId));
return { ok: true }; return { ok: true };
} }
@ -865,8 +873,7 @@ export async function unpinMessageDiscord(
messageId: string, messageId: string,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
) { ) {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
await rest.delete(Routes.channelPin(channelId, messageId)); await rest.delete(Routes.channelPin(channelId, messageId));
return { ok: true }; return { ok: true };
} }
@ -875,8 +882,7 @@ export async function listPinsDiscord(
channelId: string, channelId: string,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
): Promise<APIMessage[]> { ): Promise<APIMessage[]> {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
return (await rest.get(Routes.channelPins(channelId))) as APIMessage[]; return (await rest.get(Routes.channelPins(channelId))) as APIMessage[];
} }
@ -885,8 +891,7 @@ export async function createThreadDiscord(
payload: DiscordThreadCreate, payload: DiscordThreadCreate,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
) { ) {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
const body: Record<string, unknown> = { name: payload.name }; const body: Record<string, unknown> = { name: payload.name };
if (payload.autoArchiveMinutes) { if (payload.autoArchiveMinutes) {
body.auto_archive_duration = payload.autoArchiveMinutes; body.auto_archive_duration = payload.autoArchiveMinutes;
@ -899,8 +904,7 @@ export async function listThreadsDiscord(
payload: DiscordThreadList, payload: DiscordThreadList,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
) { ) {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
if (payload.includeArchived) { if (payload.includeArchived) {
if (!payload.channelId) { if (!payload.channelId) {
throw new Error("channelId required to list archived threads"); throw new Error("channelId required to list archived threads");
@ -920,8 +924,7 @@ export async function searchMessagesDiscord(
query: DiscordSearchQuery, query: DiscordSearchQuery,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
) { ) {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("content", query.content); params.set("content", query.content);
if (query.channelIds?.length) { if (query.channelIds?.length) {
@ -947,8 +950,7 @@ export async function listGuildEmojisDiscord(
guildId: string, guildId: string,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
) { ) {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
return await rest.get(Routes.guildEmojis(guildId)); return await rest.get(Routes.guildEmojis(guildId));
} }
@ -956,8 +958,7 @@ export async function uploadEmojiDiscord(
payload: DiscordEmojiUpload, payload: DiscordEmojiUpload,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
) { ) {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
const media = await loadWebMediaRaw( const media = await loadWebMediaRaw(
payload.mediaUrl, payload.mediaUrl,
DISCORD_MAX_EMOJI_BYTES, DISCORD_MAX_EMOJI_BYTES,
@ -986,8 +987,7 @@ export async function uploadStickerDiscord(
payload: DiscordStickerUpload, payload: DiscordStickerUpload,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
) { ) {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
const media = await loadWebMediaRaw( const media = await loadWebMediaRaw(
payload.mediaUrl, payload.mediaUrl,
DISCORD_MAX_STICKER_BYTES, DISCORD_MAX_STICKER_BYTES,
@ -1025,8 +1025,7 @@ export async function fetchMemberInfoDiscord(
userId: string, userId: string,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
): Promise<APIGuildMember> { ): Promise<APIGuildMember> {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
return (await rest.get( return (await rest.get(
Routes.guildMember(guildId, userId), Routes.guildMember(guildId, userId),
)) as APIGuildMember; )) as APIGuildMember;
@ -1036,8 +1035,7 @@ export async function fetchRoleInfoDiscord(
guildId: string, guildId: string,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
): Promise<APIRole[]> { ): Promise<APIRole[]> {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
return (await rest.get(Routes.guildRoles(guildId))) as APIRole[]; return (await rest.get(Routes.guildRoles(guildId))) as APIRole[];
} }
@ -1045,8 +1043,7 @@ export async function addRoleDiscord(
payload: DiscordRoleChange, payload: DiscordRoleChange,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
) { ) {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
await rest.put( await rest.put(
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId), Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
); );
@ -1057,8 +1054,7 @@ export async function removeRoleDiscord(
payload: DiscordRoleChange, payload: DiscordRoleChange,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
) { ) {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
await rest.delete( await rest.delete(
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId), Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
); );
@ -1069,8 +1065,7 @@ export async function fetchChannelInfoDiscord(
channelId: string, channelId: string,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
): Promise<APIChannel> { ): Promise<APIChannel> {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
return (await rest.get(Routes.channel(channelId))) as APIChannel; return (await rest.get(Routes.channel(channelId))) as APIChannel;
} }
@ -1078,8 +1073,7 @@ export async function listGuildChannelsDiscord(
guildId: string, guildId: string,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
): Promise<APIChannel[]> { ): Promise<APIChannel[]> {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
return (await rest.get(Routes.guildChannels(guildId))) as APIChannel[]; return (await rest.get(Routes.guildChannels(guildId))) as APIChannel[];
} }
@ -1088,8 +1082,7 @@ export async function fetchVoiceStatusDiscord(
userId: string, userId: string,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
): Promise<APIVoiceState> { ): Promise<APIVoiceState> {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
return (await rest.get( return (await rest.get(
Routes.guildVoiceState(guildId, userId), Routes.guildVoiceState(guildId, userId),
)) as APIVoiceState; )) as APIVoiceState;
@ -1099,8 +1092,7 @@ export async function listScheduledEventsDiscord(
guildId: string, guildId: string,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
): Promise<APIGuildScheduledEvent[]> { ): Promise<APIGuildScheduledEvent[]> {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
return (await rest.get( return (await rest.get(
Routes.guildScheduledEvents(guildId), Routes.guildScheduledEvents(guildId),
)) as APIGuildScheduledEvent[]; )) as APIGuildScheduledEvent[];
@ -1111,8 +1103,7 @@ export async function createScheduledEventDiscord(
payload: RESTPostAPIGuildScheduledEventJSONBody, payload: RESTPostAPIGuildScheduledEventJSONBody,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
): Promise<APIGuildScheduledEvent> { ): Promise<APIGuildScheduledEvent> {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
return (await rest.post(Routes.guildScheduledEvents(guildId), { return (await rest.post(Routes.guildScheduledEvents(guildId), {
body: payload, body: payload,
})) as APIGuildScheduledEvent; })) as APIGuildScheduledEvent;
@ -1122,8 +1113,7 @@ export async function timeoutMemberDiscord(
payload: DiscordTimeoutTarget, payload: DiscordTimeoutTarget,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
): Promise<APIGuildMember> { ): Promise<APIGuildMember> {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
let until = payload.until; let until = payload.until;
if (!until && payload.durationMinutes) { if (!until && payload.durationMinutes) {
const ms = payload.durationMinutes * 60 * 1000; const ms = payload.durationMinutes * 60 * 1000;
@ -1144,8 +1134,7 @@ export async function kickMemberDiscord(
payload: DiscordModerationTarget, payload: DiscordModerationTarget,
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
) { ) {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
await rest.delete(Routes.guildMember(payload.guildId, payload.userId), { await rest.delete(Routes.guildMember(payload.guildId, payload.userId), {
headers: payload.reason headers: payload.reason
? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) } ? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) }
@ -1158,8 +1147,7 @@ export async function banMemberDiscord(
payload: DiscordModerationTarget & { deleteMessageDays?: number }, payload: DiscordModerationTarget & { deleteMessageDays?: number },
opts: DiscordReactOpts = {}, opts: DiscordReactOpts = {},
) { ) {
const token = resolveToken(opts.token); const rest = resolveDiscordRest(opts);
const rest = resolveRest(token, opts.rest);
const deleteMessageDays = const deleteMessageDays =
typeof payload.deleteMessageDays === "number" && typeof payload.deleteMessageDays === "number" &&
Number.isFinite(payload.deleteMessageDays) Number.isFinite(payload.deleteMessageDays)

View File

@ -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 { export function normalizeDiscordToken(raw?: string | null): string | undefined {
if (!raw) return undefined; if (!raw) return undefined;
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return undefined; if (!trimmed) return undefined;
return trimmed.replace(/^Bot\s+/i, ""); 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" };
}

View File

@ -4,16 +4,36 @@ import {
readConfigFileSnapshot, readConfigFileSnapshot,
writeConfigFile, writeConfigFile,
} from "../../config/config.js"; } from "../../config/config.js";
import {
listDiscordAccountIds,
resolveDefaultDiscordAccountId,
resolveDiscordAccount,
} from "../../discord/accounts.js";
import { type DiscordProbe, probeDiscord } from "../../discord/probe.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 { type IMessageProbe, probeIMessage } from "../../imessage/probe.js";
import {
listSignalAccountIds,
resolveDefaultSignalAccountId,
resolveSignalAccount,
} from "../../signal/accounts.js";
import { probeSignal, type SignalProbe } from "../../signal/probe.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 { probeSlack, type SlackProbe } from "../../slack/probe.js";
import { import {
resolveSlackAppToken, listTelegramAccountIds,
resolveSlackBotToken, resolveDefaultTelegramAccountId,
} from "../../slack/token.js"; resolveTelegramAccount,
} from "../../telegram/accounts.js";
import { probeTelegram, type TelegramProbe } from "../../telegram/probe.js"; import { probeTelegram, type TelegramProbe } from "../../telegram/probe.js";
import { resolveTelegramToken } from "../../telegram/token.js";
import { import {
listEnabledWhatsAppAccounts, listEnabledWhatsAppAccounts,
resolveDefaultWhatsAppAccountId, resolveDefaultWhatsAppAccountId,
@ -50,112 +70,193 @@ export const providersHandlers: GatewayRequestHandlers = {
const timeoutMs = const timeoutMs =
typeof timeoutMsRaw === "number" ? Math.max(1000, timeoutMsRaw) : 10_000; typeof timeoutMsRaw === "number" ? Math.max(1000, timeoutMsRaw) : 10_000;
const cfg = loadConfig(); const cfg = loadConfig();
const telegramCfg = cfg.telegram; const runtime = context.getRuntimeSnapshot();
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 discordCfg = cfg.discord; const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg);
const discordEnabled = Boolean(discordCfg) && discordCfg?.enabled !== false; const defaultDiscordAccountId = resolveDefaultDiscordAccountId(cfg);
const discordEnvToken = discordEnabled const defaultSlackAccountId = resolveDefaultSlackAccountId(cfg);
? process.env.DISCORD_BOT_TOKEN?.trim() const defaultSignalAccountId = resolveDefaultSignalAccountId(cfg);
: ""; const defaultIMessageAccountId = resolveDefaultIMessageAccountId(cfg);
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 slackCfg = cfg.slack; const telegramAccounts = await Promise.all(
const slackEnabled = slackCfg?.enabled !== false; listTelegramAccountIds(cfg).map(async (accountId) => {
const slackBotEnvToken = slackEnabled const account = resolveTelegramAccount({ cfg, accountId });
? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) const rt =
: undefined; runtime.telegramAccounts?.[account.accountId] ??
const slackBotConfigToken = slackEnabled (account.accountId === defaultTelegramAccountId
? resolveSlackBotToken(slackCfg?.botToken) ? runtime.telegram
: undefined; : undefined);
const slackBotToken = slackBotEnvToken ?? slackBotConfigToken ?? ""; const configured = Boolean(account.token);
const slackBotTokenSource = slackBotEnvToken let telegramProbe: TelegramProbe | undefined;
? "env" let lastProbeAt: number | null = null;
: slackBotConfigToken if (probe && configured && account.enabled) {
? "config" telegramProbe = await probeTelegram(
: "none"; account.token,
const slackAppEnvToken = slackEnabled timeoutMs,
? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) account.config.proxy,
: undefined; );
const slackAppConfigToken = slackEnabled lastProbeAt = Date.now();
? resolveSlackAppToken(slackCfg?.appToken) }
: undefined; return {
const slackAppToken = slackAppEnvToken ?? slackAppConfigToken ?? ""; accountId: account.accountId,
const slackAppTokenSource = slackAppEnvToken name: account.name,
? "env" enabled: account.enabled,
: slackAppConfigToken configured,
? "config" tokenSource: account.tokenSource,
: "none"; running: rt?.running ?? false,
const slackConfigured = mode: rt?.mode ?? (account.config.webhookUrl ? "webhook" : "polling"),
slackEnabled && Boolean(slackBotToken) && Boolean(slackAppToken); lastStartAt: rt?.lastStartAt ?? null,
let slackProbe: SlackProbe | undefined; lastStopAt: rt?.lastStopAt ?? null,
let slackLastProbeAt: number | null = null; lastError: rt?.lastError ?? null,
if (probe && slackConfigured) { probe: telegramProbe,
slackProbe = await probeSlack(slackBotToken, timeoutMs); lastProbeAt,
slackLastProbeAt = Date.now(); };
} }),
);
const defaultTelegramAccount =
telegramAccounts.find(
(account) => account.accountId === defaultTelegramAccountId,
) ?? telegramAccounts[0];
const signalCfg = cfg.signal; const discordAccounts = await Promise.all(
const signalEnabled = signalCfg?.enabled !== false; listDiscordAccountIds(cfg).map(async (accountId) => {
const signalHost = signalCfg?.httpHost?.trim() || "127.0.0.1"; const account = resolveDiscordAccount({ cfg, accountId });
const signalPort = signalCfg?.httpPort ?? 8080; const rt =
const signalBaseUrl = runtime.discordAccounts?.[account.accountId] ??
signalCfg?.httpUrl?.trim() || `http://${signalHost}:${signalPort}`; (account.accountId === defaultDiscordAccountId
const signalConfigured = ? runtime.discord
Boolean(signalCfg) && : undefined);
signalEnabled && const configured = Boolean(account.token);
Boolean( let discordProbe: DiscordProbe | undefined;
signalCfg?.account?.trim() || let lastProbeAt: number | null = null;
signalCfg?.httpUrl?.trim() || if (probe && configured && account.enabled) {
signalCfg?.cliPath?.trim() || discordProbe = await probeDiscord(account.token, timeoutMs);
signalCfg?.httpHost?.trim() || lastProbeAt = Date.now();
typeof signalCfg?.httpPort === "number" || }
typeof signalCfg?.autoStart === "boolean", return {
); accountId: account.accountId,
let signalProbe: SignalProbe | undefined; name: account.name,
let signalLastProbeAt: number | null = null; enabled: account.enabled,
if (probe && signalConfigured) { configured,
signalProbe = await probeSignal(signalBaseUrl, timeoutMs); tokenSource: account.tokenSource,
signalLastProbeAt = Date.now(); 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 slackAccounts = await Promise.all(
const imessageEnabled = imessageCfg?.enabled !== false; listSlackAccountIds(cfg).map(async (accountId) => {
const imessageConfigured = Boolean(imessageCfg) && imessageEnabled; 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 imessageProbe: IMessageProbe | undefined;
let imessageLastProbeAt: number | null = null; let imessageLastProbeAt: number | null = null;
if (probe && imessageConfigured) { if (probe && imessageBaseConfigured) {
imessageProbe = await probeIMessage(timeoutMs); imessageProbe = await probeIMessage(timeoutMs);
imessageLastProbeAt = Date.now(); imessageLastProbeAt = Date.now();
} }
const imessageAccounts = listIMessageAccountIds(cfg).map((accountId) => {
const runtime = context.getRuntimeSnapshot(); 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 defaultWhatsAppAccountId = resolveDefaultWhatsAppAccountId(cfg);
const enabledWhatsAppAccounts = listEnabledWhatsAppAccounts(cfg); const enabledWhatsAppAccounts = listEnabledWhatsAppAccounts(cfg);
const defaultWhatsAppAccount = const defaultWhatsAppAccount =
@ -226,58 +327,68 @@ export const providersHandlers: GatewayRequestHandlers = {
whatsappAccounts, whatsappAccounts,
whatsappDefaultAccountId: defaultWhatsAppAccountId, whatsappDefaultAccountId: defaultWhatsAppAccountId,
telegram: { telegram: {
configured: telegramEnabled && Boolean(telegramToken), configured: defaultTelegramAccount?.configured ?? false,
tokenSource, tokenSource: defaultTelegramAccount?.tokenSource ?? "none",
running: runtime.telegram.running, running: defaultTelegramAccount?.running ?? false,
mode: runtime.telegram.mode ?? null, mode: defaultTelegramAccount?.mode ?? null,
lastStartAt: runtime.telegram.lastStartAt ?? null, lastStartAt: defaultTelegramAccount?.lastStartAt ?? null,
lastStopAt: runtime.telegram.lastStopAt ?? null, lastStopAt: defaultTelegramAccount?.lastStopAt ?? null,
lastError: runtime.telegram.lastError ?? null, lastError: defaultTelegramAccount?.lastError ?? null,
probe: telegramProbe, probe: defaultTelegramAccount?.probe,
lastProbeAt, lastProbeAt: defaultTelegramAccount?.lastProbeAt ?? null,
}, },
telegramAccounts,
telegramDefaultAccountId: defaultTelegramAccountId,
discord: { discord: {
configured: discordEnabled && Boolean(discordToken), configured: defaultDiscordAccount?.configured ?? false,
tokenSource: discordTokenSource, tokenSource: defaultDiscordAccount?.tokenSource ?? "none",
running: runtime.discord.running, running: defaultDiscordAccount?.running ?? false,
lastStartAt: runtime.discord.lastStartAt ?? null, lastStartAt: defaultDiscordAccount?.lastStartAt ?? null,
lastStopAt: runtime.discord.lastStopAt ?? null, lastStopAt: defaultDiscordAccount?.lastStopAt ?? null,
lastError: runtime.discord.lastError ?? null, lastError: defaultDiscordAccount?.lastError ?? null,
probe: discordProbe, probe: defaultDiscordAccount?.probe,
lastProbeAt: discordLastProbeAt, lastProbeAt: defaultDiscordAccount?.lastProbeAt ?? null,
}, },
discordAccounts,
discordDefaultAccountId: defaultDiscordAccountId,
slack: { slack: {
configured: slackConfigured, configured: defaultSlackAccount?.configured ?? false,
botTokenSource: slackBotTokenSource, botTokenSource: defaultSlackAccount?.botTokenSource ?? "none",
appTokenSource: slackAppTokenSource, appTokenSource: defaultSlackAccount?.appTokenSource ?? "none",
running: runtime.slack.running, running: defaultSlackAccount?.running ?? false,
lastStartAt: runtime.slack.lastStartAt ?? null, lastStartAt: defaultSlackAccount?.lastStartAt ?? null,
lastStopAt: runtime.slack.lastStopAt ?? null, lastStopAt: defaultSlackAccount?.lastStopAt ?? null,
lastError: runtime.slack.lastError ?? null, lastError: defaultSlackAccount?.lastError ?? null,
probe: slackProbe, probe: defaultSlackAccount?.probe,
lastProbeAt: slackLastProbeAt, lastProbeAt: defaultSlackAccount?.lastProbeAt ?? null,
}, },
slackAccounts,
slackDefaultAccountId: defaultSlackAccountId,
signal: { signal: {
configured: signalConfigured, configured: defaultSignalAccount?.configured ?? false,
baseUrl: signalBaseUrl, baseUrl: defaultSignalAccount?.baseUrl ?? null,
running: runtime.signal.running, running: defaultSignalAccount?.running ?? false,
lastStartAt: runtime.signal.lastStartAt ?? null, lastStartAt: defaultSignalAccount?.lastStartAt ?? null,
lastStopAt: runtime.signal.lastStopAt ?? null, lastStopAt: defaultSignalAccount?.lastStopAt ?? null,
lastError: runtime.signal.lastError ?? null, lastError: defaultSignalAccount?.lastError ?? null,
probe: signalProbe, probe: defaultSignalAccount?.probe,
lastProbeAt: signalLastProbeAt, lastProbeAt: defaultSignalAccount?.lastProbeAt ?? null,
}, },
signalAccounts,
signalDefaultAccountId: defaultSignalAccountId,
imessage: { imessage: {
configured: imessageConfigured, configured: defaultIMessageAccount?.configured ?? false,
running: runtime.imessage.running, running: defaultIMessageAccount?.running ?? false,
lastStartAt: runtime.imessage.lastStartAt ?? null, lastStartAt: defaultIMessageAccount?.lastStartAt ?? null,
lastStopAt: runtime.imessage.lastStopAt ?? null, lastStopAt: defaultIMessageAccount?.lastStopAt ?? null,
lastError: runtime.imessage.lastError ?? null, lastError: defaultIMessageAccount?.lastError ?? null,
cliPath: runtime.imessage.cliPath ?? null, cliPath: defaultIMessageAccount?.cliPath ?? null,
dbPath: runtime.imessage.dbPath ?? null, dbPath: defaultIMessageAccount?.dbPath ?? null,
probe: imessageProbe, probe: defaultIMessageAccount?.probe,
lastProbeAt: imessageLastProbeAt, lastProbeAt: defaultIMessageAccount?.lastProbeAt ?? null,
}, },
imessageAccounts,
imessageDefaultAccountId: defaultIMessageAccountId,
}, },
undefined, undefined,
); );

View File

@ -5,7 +5,6 @@ import { sendMessageIMessage } from "../../imessage/index.js";
import { sendMessageSignal } from "../../signal/index.js"; import { sendMessageSignal } from "../../signal/index.js";
import { sendMessageSlack } from "../../slack/send.js"; import { sendMessageSlack } from "../../slack/send.js";
import { sendMessageTelegram } from "../../telegram/send.js"; import { sendMessageTelegram } from "../../telegram/send.js";
import { resolveTelegramToken } from "../../telegram/token.js";
import { normalizeMessageProvider } from "../../utils/message-provider.js"; import { normalizeMessageProvider } from "../../utils/message-provider.js";
import { resolveDefaultWhatsAppAccountId } from "../../web/accounts.js"; import { resolveDefaultWhatsAppAccountId } from "../../web/accounts.js";
import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
@ -53,14 +52,16 @@ export const sendHandlers: GatewayRequestHandlers = {
const to = request.to.trim(); const to = request.to.trim();
const message = request.message.trim(); const message = request.message.trim();
const provider = normalizeMessageProvider(request.provider) ?? "whatsapp"; const provider = normalizeMessageProvider(request.provider) ?? "whatsapp";
const accountId =
typeof request.accountId === "string" && request.accountId.trim().length
? request.accountId.trim()
: undefined;
try { try {
if (provider === "telegram") { if (provider === "telegram") {
const cfg = loadConfig();
const { token } = resolveTelegramToken(cfg);
const result = await sendMessageTelegram(to, message, { const result = await sendMessageTelegram(to, message, {
mediaUrl: request.mediaUrl, mediaUrl: request.mediaUrl,
verbose: shouldLogVerbose(), verbose: shouldLogVerbose(),
token: token || undefined, accountId,
}); });
const payload = { const payload = {
runId: idem, runId: idem,
@ -77,7 +78,7 @@ export const sendHandlers: GatewayRequestHandlers = {
} else if (provider === "discord") { } else if (provider === "discord") {
const result = await sendMessageDiscord(to, message, { const result = await sendMessageDiscord(to, message, {
mediaUrl: request.mediaUrl, mediaUrl: request.mediaUrl,
token: process.env.DISCORD_BOT_TOKEN, accountId,
}); });
const payload = { const payload = {
runId: idem, runId: idem,
@ -94,6 +95,7 @@ export const sendHandlers: GatewayRequestHandlers = {
} else if (provider === "slack") { } else if (provider === "slack") {
const result = await sendMessageSlack(to, message, { const result = await sendMessageSlack(to, message, {
mediaUrl: request.mediaUrl, mediaUrl: request.mediaUrl,
accountId,
}); });
const payload = { const payload = {
runId: idem, runId: idem,
@ -108,14 +110,9 @@ export const sendHandlers: GatewayRequestHandlers = {
}); });
respond(true, payload, undefined, { provider }); respond(true, payload, undefined, { provider });
} else if (provider === "signal") { } 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, { const result = await sendMessageSignal(to, message, {
mediaUrl: request.mediaUrl, mediaUrl: request.mediaUrl,
baseUrl, accountId,
account: cfg.signal?.account,
}); });
const payload = { const payload = {
runId: idem, runId: idem,
@ -129,14 +126,9 @@ export const sendHandlers: GatewayRequestHandlers = {
}); });
respond(true, payload, undefined, { provider }); respond(true, payload, undefined, { provider });
} else if (provider === "imessage") { } else if (provider === "imessage") {
const cfg = loadConfig();
const result = await sendMessageIMessage(to, message, { const result = await sendMessageIMessage(to, message, {
mediaUrl: request.mediaUrl, mediaUrl: request.mediaUrl,
cliPath: cfg.imessage?.cliPath, accountId,
dbPath: cfg.imessage?.dbPath,
maxBytes: cfg.imessage?.mediaMaxMb
? cfg.imessage.mediaMaxMb * 1024 * 1024
: undefined,
}); });
const payload = { const payload = {
runId: idem, runId: idem,
@ -151,16 +143,13 @@ export const sendHandlers: GatewayRequestHandlers = {
respond(true, payload, undefined, { provider }); respond(true, payload, undefined, { provider });
} else { } else {
const cfg = loadConfig(); const cfg = loadConfig();
const accountId = const targetAccountId =
typeof request.accountId === "string" && accountId ?? resolveDefaultWhatsAppAccountId(cfg);
request.accountId.trim().length > 0
? request.accountId.trim()
: resolveDefaultWhatsAppAccountId(cfg);
const result = await sendMessageWhatsApp(to, message, { const result = await sendMessageWhatsApp(to, message, {
mediaUrl: request.mediaUrl, mediaUrl: request.mediaUrl,
verbose: shouldLogVerbose(), verbose: shouldLogVerbose(),
gifPlayback: request.gifPlayback, gifPlayback: request.gifPlayback,
accountId, accountId: targetAccountId,
}); });
const payload = { const payload = {
runId: idem, runId: idem,
@ -238,9 +227,13 @@ export const sendHandlers: GatewayRequestHandlers = {
maxSelections: request.maxSelections, maxSelections: request.maxSelections,
durationHours: request.durationHours, durationHours: request.durationHours,
}; };
const accountId =
typeof request.accountId === "string" && request.accountId.trim().length
? request.accountId.trim()
: undefined;
try { try {
if (provider === "discord") { if (provider === "discord") {
const result = await sendPollDiscord(to, poll); const result = await sendPollDiscord(to, poll, { accountId });
const payload = { const payload = {
runId: idem, runId: idem,
messageId: result.messageId, messageId: result.messageId,

View File

@ -71,7 +71,16 @@ export type GatewayRequestContext = {
getRuntimeSnapshot: () => ProviderRuntimeSnapshot; getRuntimeSnapshot: () => ProviderRuntimeSnapshot;
startWhatsAppProvider: (accountId?: string) => Promise<void>; startWhatsAppProvider: (accountId?: string) => Promise<void>;
stopWhatsAppProvider: (accountId?: string) => Promise<void>; stopWhatsAppProvider: (accountId?: string) => Promise<void>;
stopTelegramProvider: () => Promise<void>; startTelegramProvider: (accountId?: string) => Promise<void>;
stopTelegramProvider: (accountId?: string) => Promise<void>;
startDiscordProvider: (accountId?: string) => Promise<void>;
stopDiscordProvider: (accountId?: string) => Promise<void>;
startSlackProvider: (accountId?: string) => Promise<void>;
stopSlackProvider: (accountId?: string) => Promise<void>;
startSignalProvider: (accountId?: string) => Promise<void>;
stopSignalProvider: (accountId?: string) => Promise<void>;
startIMessageProvider: (accountId?: string) => Promise<void>;
stopIMessageProvider: (accountId?: string) => Promise<void>;
markWhatsAppLoggedOut: (cleared: boolean, accountId?: string) => void; markWhatsAppLoggedOut: (cleared: boolean, accountId?: string) => void;
wizardRunner: ( wizardRunner: (
opts: import("../../commands/onboard-types.js").OnboardOptions, opts: import("../../commands/onboard-types.js").OnboardOptions,

File diff suppressed because it is too large Load Diff

View File

@ -42,6 +42,7 @@ const hoisted = vi.hoisted(() => {
lastEventAt: null, lastEventAt: null,
lastError: null, lastError: null,
}, },
whatsappAccounts: {},
telegram: { telegram: {
running: false, running: false,
lastStartAt: null, lastStartAt: null,
@ -49,18 +50,21 @@ const hoisted = vi.hoisted(() => {
lastError: null, lastError: null,
mode: null, mode: null,
}, },
telegramAccounts: {},
discord: { discord: {
running: false, running: false,
lastStartAt: null, lastStartAt: null,
lastStopAt: null, lastStopAt: null,
lastError: null, lastError: null,
}, },
discordAccounts: {},
slack: { slack: {
running: false, running: false,
lastStartAt: null, lastStartAt: null,
lastStopAt: null, lastStopAt: null,
lastError: null, lastError: null,
}, },
slackAccounts: {},
signal: { signal: {
running: false, running: false,
lastStartAt: null, lastStartAt: null,
@ -68,6 +72,7 @@ const hoisted = vi.hoisted(() => {
lastError: null, lastError: null,
baseUrl: null, baseUrl: null,
}, },
signalAccounts: {},
imessage: { imessage: {
running: false, running: false,
lastStartAt: null, lastStartAt: null,
@ -76,6 +81,7 @@ const hoisted = vi.hoisted(() => {
cliPath: null, cliPath: null,
dbPath: null, dbPath: null,
}, },
imessageAccounts: {},
})), })),
startProviders: vi.fn(async () => {}), startProviders: vi.fn(async () => {}),
startWhatsAppProvider: vi.fn(async () => {}), startWhatsAppProvider: vi.fn(async () => {}),

View File

@ -1542,7 +1542,16 @@ export async function startGatewayServer(
getRuntimeSnapshot, getRuntimeSnapshot,
startWhatsAppProvider, startWhatsAppProvider,
stopWhatsAppProvider, stopWhatsAppProvider,
startTelegramProvider,
stopTelegramProvider, stopTelegramProvider,
startDiscordProvider,
stopDiscordProvider,
startSlackProvider,
stopSlackProvider,
startSignalProvider,
stopSignalProvider,
startIMessageProvider,
stopIMessageProvider,
markWhatsAppLoggedOut, markWhatsAppLoggedOut,
wizardRunner, wizardRunner,
broadcastVoiceWakeChanged, broadcastVoiceWakeChanged,

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

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

View File

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

View File

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

View File

@ -411,7 +411,7 @@ describe("runHeartbeatOnce", () => {
expect(sendTelegram).toHaveBeenCalledWith( expect(sendTelegram).toHaveBeenCalledWith(
"123456", "123456",
"Hello from heartbeat", "Hello from heartbeat",
expect.objectContaining({ token: "test-bot-token-123" }), expect.objectContaining({ accountId: "default", verbose: false }),
); );
} finally { } finally {
replySpy.mockRestore(); replySpy.mockRestore();

View File

@ -7,7 +7,7 @@ import {
} from "./deliver.js"; } from "./deliver.js";
describe("deliverOutboundPayloads", () => { describe("deliverOutboundPayloads", () => {
it("chunks telegram markdown and passes config token", async () => { it("chunks telegram markdown and passes account id", async () => {
const sendTelegram = vi const sendTelegram = vi
.fn() .fn()
.mockResolvedValue({ messageId: "m1", chatId: "c1" }); .mockResolvedValue({ messageId: "m1", chatId: "c1" });
@ -28,7 +28,7 @@ describe("deliverOutboundPayloads", () => {
expect(sendTelegram).toHaveBeenCalledTimes(2); expect(sendTelegram).toHaveBeenCalledTimes(2);
for (const call of sendTelegram.mock.calls) { for (const call of sendTelegram.mock.calls) {
expect(call[2]).toEqual( expect(call[2]).toEqual(
expect.objectContaining({ token: "tok-1", verbose: false }), expect.objectContaining({ accountId: "default", verbose: false }),
); );
} }
expect(results).toHaveLength(2); expect(results).toHaveLength(2);

View File

@ -7,10 +7,10 @@ import type { ReplyPayload } from "../../auto-reply/types.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { sendMessageDiscord } from "../../discord/send.js"; import { sendMessageDiscord } from "../../discord/send.js";
import { sendMessageIMessage } from "../../imessage/send.js"; import { sendMessageIMessage } from "../../imessage/send.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { sendMessageSignal } from "../../signal/send.js"; import { sendMessageSignal } from "../../signal/send.js";
import { sendMessageSlack } from "../../slack/send.js"; import { sendMessageSlack } from "../../slack/send.js";
import { sendMessageTelegram } from "../../telegram/send.js"; import { sendMessageTelegram } from "../../telegram/send.js";
import { resolveTelegramToken } from "../../telegram/token.js";
import { sendMessageWhatsApp } from "../../web/outbound.js"; import { sendMessageWhatsApp } from "../../web/outbound.js";
import type { NormalizedOutboundPayload } from "./payloads.js"; import type { NormalizedOutboundPayload } from "./payloads.js";
import { normalizeOutboundPayloads } from "./payloads.js"; import { normalizeOutboundPayloads } from "./payloads.js";
@ -64,9 +64,15 @@ type ProviderHandler = {
function resolveMediaMaxBytes( function resolveMediaMaxBytes(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
provider: "signal" | "imessage", provider: "signal" | "imessage",
accountId?: string | null,
): number | undefined { ): number | undefined {
const normalizedAccountId = normalizeAccountId(accountId);
const providerLimit = 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 (providerLimit) return providerLimit * MB;
if (cfg.agent?.mediaMaxMb) return cfg.agent.mediaMaxMb * MB; if (cfg.agent?.mediaMaxMb) return cfg.agent.mediaMaxMb * MB;
return undefined; return undefined;
@ -76,20 +82,18 @@ function createProviderHandler(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
provider: Exclude<OutboundProvider, "none">; provider: Exclude<OutboundProvider, "none">;
to: string; to: string;
accountId?: string;
deps: Required<OutboundSendDeps>; deps: Required<OutboundSendDeps>;
}): ProviderHandler { }): ProviderHandler {
const { cfg, to, deps } = params; const { cfg, to, deps } = params;
const telegramToken = const accountId = normalizeAccountId(params.accountId);
params.provider === "telegram"
? resolveTelegramToken(cfg).token || undefined
: undefined;
const signalMaxBytes = const signalMaxBytes =
params.provider === "signal" params.provider === "signal"
? resolveMediaMaxBytes(cfg, "signal") ? resolveMediaMaxBytes(cfg, "signal", accountId)
: undefined; : undefined;
const imessageMaxBytes = const imessageMaxBytes =
params.provider === "imessage" params.provider === "imessage"
? resolveMediaMaxBytes(cfg, "imessage") ? resolveMediaMaxBytes(cfg, "imessage", accountId)
: undefined; : undefined;
const handlers: Record<Exclude<OutboundProvider, "none">, ProviderHandler> = { const handlers: Record<Exclude<OutboundProvider, "none">, ProviderHandler> = {
@ -97,13 +101,17 @@ function createProviderHandler(params: {
chunker: providerCaps.whatsapp.chunker, chunker: providerCaps.whatsapp.chunker,
sendText: async (text) => ({ sendText: async (text) => ({
provider: "whatsapp", provider: "whatsapp",
...(await deps.sendWhatsApp(to, text, { verbose: false })), ...(await deps.sendWhatsApp(to, text, {
verbose: false,
accountId,
})),
}), }),
sendMedia: async (caption, mediaUrl) => ({ sendMedia: async (caption, mediaUrl) => ({
provider: "whatsapp", provider: "whatsapp",
...(await deps.sendWhatsApp(to, caption, { ...(await deps.sendWhatsApp(to, caption, {
verbose: false, verbose: false,
mediaUrl, mediaUrl,
accountId,
})), })),
}), }),
}, },
@ -113,7 +121,7 @@ function createProviderHandler(params: {
provider: "telegram", provider: "telegram",
...(await deps.sendTelegram(to, text, { ...(await deps.sendTelegram(to, text, {
verbose: false, verbose: false,
token: telegramToken, accountId,
})), })),
}), }),
sendMedia: async (caption, mediaUrl) => ({ sendMedia: async (caption, mediaUrl) => ({
@ -121,7 +129,7 @@ function createProviderHandler(params: {
...(await deps.sendTelegram(to, caption, { ...(await deps.sendTelegram(to, caption, {
verbose: false, verbose: false,
mediaUrl, mediaUrl,
token: telegramToken, accountId,
})), })),
}), }),
}, },
@ -129,13 +137,17 @@ function createProviderHandler(params: {
chunker: providerCaps.discord.chunker, chunker: providerCaps.discord.chunker,
sendText: async (text) => ({ sendText: async (text) => ({
provider: "discord", provider: "discord",
...(await deps.sendDiscord(to, text, { verbose: false })), ...(await deps.sendDiscord(to, text, {
verbose: false,
accountId,
})),
}), }),
sendMedia: async (caption, mediaUrl) => ({ sendMedia: async (caption, mediaUrl) => ({
provider: "discord", provider: "discord",
...(await deps.sendDiscord(to, caption, { ...(await deps.sendDiscord(to, caption, {
verbose: false, verbose: false,
mediaUrl, mediaUrl,
accountId,
})), })),
}), }),
}, },
@ -143,24 +155,33 @@ function createProviderHandler(params: {
chunker: providerCaps.slack.chunker, chunker: providerCaps.slack.chunker,
sendText: async (text) => ({ sendText: async (text) => ({
provider: "slack", provider: "slack",
...(await deps.sendSlack(to, text)), ...(await deps.sendSlack(to, text, {
accountId,
})),
}), }),
sendMedia: async (caption, mediaUrl) => ({ sendMedia: async (caption, mediaUrl) => ({
provider: "slack", provider: "slack",
...(await deps.sendSlack(to, caption, { mediaUrl })), ...(await deps.sendSlack(to, caption, {
mediaUrl,
accountId,
})),
}), }),
}, },
signal: { signal: {
chunker: providerCaps.signal.chunker, chunker: providerCaps.signal.chunker,
sendText: async (text) => ({ sendText: async (text) => ({
provider: "signal", provider: "signal",
...(await deps.sendSignal(to, text, { maxBytes: signalMaxBytes })), ...(await deps.sendSignal(to, text, {
maxBytes: signalMaxBytes,
accountId,
})),
}), }),
sendMedia: async (caption, mediaUrl) => ({ sendMedia: async (caption, mediaUrl) => ({
provider: "signal", provider: "signal",
...(await deps.sendSignal(to, caption, { ...(await deps.sendSignal(to, caption, {
mediaUrl, mediaUrl,
maxBytes: signalMaxBytes, maxBytes: signalMaxBytes,
accountId,
})), })),
}), }),
}, },
@ -168,13 +189,17 @@ function createProviderHandler(params: {
chunker: providerCaps.imessage.chunker, chunker: providerCaps.imessage.chunker,
sendText: async (text) => ({ sendText: async (text) => ({
provider: "imessage", provider: "imessage",
...(await deps.sendIMessage(to, text, { maxBytes: imessageMaxBytes })), ...(await deps.sendIMessage(to, text, {
maxBytes: imessageMaxBytes,
accountId,
})),
}), }),
sendMedia: async (caption, mediaUrl) => ({ sendMedia: async (caption, mediaUrl) => ({
provider: "imessage", provider: "imessage",
...(await deps.sendIMessage(to, caption, { ...(await deps.sendIMessage(to, caption, {
mediaUrl, mediaUrl,
maxBytes: imessageMaxBytes, maxBytes: imessageMaxBytes,
accountId,
})), })),
}), }),
}, },
@ -187,6 +212,7 @@ export async function deliverOutboundPayloads(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
provider: Exclude<OutboundProvider, "none">; provider: Exclude<OutboundProvider, "none">;
to: string; to: string;
accountId?: string;
payloads: ReplyPayload[]; payloads: ReplyPayload[];
deps?: OutboundSendDeps; deps?: OutboundSendDeps;
bestEffort?: boolean; bestEffort?: boolean;
@ -194,6 +220,7 @@ export async function deliverOutboundPayloads(params: {
onPayload?: (payload: NormalizedOutboundPayload) => void; onPayload?: (payload: NormalizedOutboundPayload) => void;
}): Promise<OutboundDeliveryResult[]> { }): Promise<OutboundDeliveryResult[]> {
const { cfg, provider, to, payloads } = params; const { cfg, provider, to, payloads } = params;
const accountId = normalizeAccountId(params.accountId);
const deps = { const deps = {
sendWhatsApp: params.deps?.sendWhatsApp ?? sendMessageWhatsApp, sendWhatsApp: params.deps?.sendWhatsApp ?? sendMessageWhatsApp,
sendTelegram: params.deps?.sendTelegram ?? sendMessageTelegram, sendTelegram: params.deps?.sendTelegram ?? sendMessageTelegram,
@ -208,9 +235,10 @@ export async function deliverOutboundPayloads(params: {
provider, provider,
to, to,
deps, deps,
accountId,
}); });
const textLimit = handler.chunker const textLimit = handler.chunker
? resolveTextChunkLimit(cfg, provider) ? resolveTextChunkLimit(cfg, provider, accountId)
: undefined; : undefined;
const sendTextChunks = async (text: string) => { const sendTextChunks = async (text: string) => {

90
src/signal/accounts.ts Normal file
View File

@ -0,0 +1,90 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { SignalAccountConfig } from "../config/types.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../routing/session-key.js";
export type ResolvedSignalAccount = {
accountId: string;
enabled: boolean;
name?: string;
baseUrl: string;
configured: boolean;
config: SignalAccountConfig;
};
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = cfg.signal?.accounts;
if (!accounts || typeof accounts !== "object") return [];
return Object.keys(accounts).filter(Boolean);
}
export function listSignalAccountIds(cfg: ClawdbotConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
return ids.sort((a, b) => a.localeCompare(b));
}
export function resolveDefaultSignalAccountId(cfg: ClawdbotConfig): string {
const ids = listSignalAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): SignalAccountConfig | undefined {
const accounts = cfg.signal?.accounts;
if (!accounts || typeof accounts !== "object") return undefined;
return accounts[accountId] as SignalAccountConfig | undefined;
}
function mergeSignalAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): SignalAccountConfig {
const { accounts: _ignored, ...base } = (cfg.signal ??
{}) as SignalAccountConfig & { accounts?: unknown };
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account };
}
export function resolveSignalAccount(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}): ResolvedSignalAccount {
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = params.cfg.signal?.enabled !== false;
const merged = mergeSignalAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const host = merged.httpHost?.trim() || "127.0.0.1";
const port = merged.httpPort ?? 8080;
const baseUrl = merged.httpUrl?.trim() || `http://${host}:${port}`;
const configured = Boolean(
merged.account?.trim() ||
merged.httpUrl?.trim() ||
merged.cliPath?.trim() ||
merged.httpHost?.trim() ||
typeof merged.httpPort === "number" ||
typeof merged.autoStart === "boolean",
);
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
baseUrl,
configured,
config: merged,
};
}
export function listEnabledSignalAccounts(
cfg: ClawdbotConfig,
): ResolvedSignalAccount[] {
return listSignalAccountIds(cfg)
.map((accountId) => resolveSignalAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View File

@ -3,6 +3,7 @@ import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
import type { ReplyPayload } from "../auto-reply/types.js"; import type { ReplyPayload } from "../auto-reply/types.js";
import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
@ -15,6 +16,7 @@ import {
import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveAgentRoute } from "../routing/resolve-route.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { normalizeE164 } from "../utils.js"; import { normalizeE164 } from "../utils.js";
import { resolveSignalAccount } from "./accounts.js";
import { signalCheck, signalRpcRequest } from "./client.js"; import { signalCheck, signalRpcRequest } from "./client.js";
import { spawnSignalDaemon } from "./daemon.js"; import { spawnSignalDaemon } from "./daemon.js";
import { sendMessageSignal } from "./send.js"; import { sendMessageSignal } from "./send.js";
@ -51,6 +53,8 @@ export type MonitorSignalOpts = {
runtime?: RuntimeEnv; runtime?: RuntimeEnv;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
account?: string; account?: string;
accountId?: string;
config?: ClawdbotConfig;
baseUrl?: string; baseUrl?: string;
autoStart?: boolean; autoStart?: boolean;
cliPath?: string; cliPath?: string;
@ -83,36 +87,8 @@ function resolveRuntime(opts: MonitorSignalOpts): RuntimeEnv {
); );
} }
function resolveBaseUrl(opts: MonitorSignalOpts): string { function normalizeAllowList(raw?: Array<string | number>): string[] {
const cfg = loadConfig(); return (raw ?? []).map((entry) => String(entry).trim()).filter(Boolean);
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 isAllowedSender(sender: string, allowFrom: string[]): boolean { function isAllowedSender(sender: string, allowFrom: string[]): boolean {
@ -207,12 +183,21 @@ async function deliverReplies(params: {
target: string; target: string;
baseUrl: string; baseUrl: string;
account?: string; account?: string;
accountId?: string;
runtime: RuntimeEnv; runtime: RuntimeEnv;
maxBytes: number; maxBytes: number;
textLimit: number; textLimit: number;
}) { }) {
const { replies, target, baseUrl, account, runtime, maxBytes, textLimit } = const {
params; replies,
target,
baseUrl,
account,
accountId,
runtime,
maxBytes,
textLimit,
} = params;
for (const payload of replies) { for (const payload of replies) {
const mediaList = const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
@ -224,6 +209,7 @@ async function deliverReplies(params: {
baseUrl, baseUrl,
account, account,
maxBytes, maxBytes,
accountId,
}); });
} }
} else { } else {
@ -236,6 +222,7 @@ async function deliverReplies(params: {
account, account,
mediaUrl: url, mediaUrl: url,
maxBytes, maxBytes,
accountId,
}); });
} }
} }
@ -247,37 +234,53 @@ export async function monitorSignalProvider(
opts: MonitorSignalOpts = {}, opts: MonitorSignalOpts = {},
): Promise<void> { ): Promise<void> {
const runtime = resolveRuntime(opts); const runtime = resolveRuntime(opts);
const cfg = loadConfig(); const cfg = opts.config ?? loadConfig();
const textLimit = resolveTextChunkLimit(cfg, "signal"); const accountInfo = resolveSignalAccount({
const baseUrl = resolveBaseUrl(opts); cfg,
const account = resolveAccount(opts); accountId: opts.accountId,
const dmPolicy = cfg.signal?.dmPolicy ?? "pairing"; });
const allowFrom = resolveAllowFrom(opts); const textLimit = resolveTextChunkLimit(cfg, "signal", accountInfo.accountId);
const groupAllowFrom = resolveGroupAllowFrom(opts); const baseUrl = opts.baseUrl?.trim() || accountInfo.baseUrl;
const groupPolicy = cfg.signal?.groupPolicy ?? "open"; 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 = const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.signal?.mediaMaxMb ?? 8) * 1024 * 1024; (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;
const ignoreAttachments = const ignoreAttachments =
opts.ignoreAttachments ?? cfg.signal?.ignoreAttachments ?? false; opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false;
const autoStart = const autoStart =
opts.autoStart ?? cfg.signal?.autoStart ?? !cfg.signal?.httpUrl; opts.autoStart ??
accountInfo.config.autoStart ??
!accountInfo.config.httpUrl;
let daemonHandle: ReturnType<typeof spawnSignalDaemon> | null = null; let daemonHandle: ReturnType<typeof spawnSignalDaemon> | null = null;
if (autoStart) { if (autoStart) {
const cliPath = opts.cliPath ?? cfg.signal?.cliPath ?? "signal-cli"; const cliPath = opts.cliPath ?? accountInfo.config.cliPath ?? "signal-cli";
const httpHost = opts.httpHost ?? cfg.signal?.httpHost ?? "127.0.0.1"; const httpHost =
const httpPort = opts.httpPort ?? cfg.signal?.httpPort ?? 8080; opts.httpHost ?? accountInfo.config.httpHost ?? "127.0.0.1";
const httpPort = opts.httpPort ?? accountInfo.config.httpPort ?? 8080;
daemonHandle = spawnSignalDaemon({ daemonHandle = spawnSignalDaemon({
cliPath, cliPath,
account, account,
httpHost, httpHost,
httpPort, httpPort,
receiveMode: opts.receiveMode ?? cfg.signal?.receiveMode, receiveMode: opts.receiveMode ?? accountInfo.config.receiveMode,
ignoreAttachments: ignoreAttachments:
opts.ignoreAttachments ?? cfg.signal?.ignoreAttachments, opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments,
ignoreStories: opts.ignoreStories ?? cfg.signal?.ignoreStories, ignoreStories: opts.ignoreStories ?? accountInfo.config.ignoreStories,
sendReadReceipts: opts.sendReadReceipts ?? cfg.signal?.sendReadReceipts, sendReadReceipts:
opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts,
runtime, runtime,
}); });
} }
@ -357,7 +360,12 @@ export async function monitorSignalProvider(
"Ask the bot owner to approve with:", "Ask the bot owner to approve with:",
"clawdbot pairing approve --provider signal <code>", "clawdbot pairing approve --provider signal <code>",
].join("\n"), ].join("\n"),
{ baseUrl, account, maxBytes: mediaMaxBytes }, {
baseUrl,
account,
maxBytes: mediaMaxBytes,
accountId: accountInfo.accountId,
},
); );
} catch (err) { } catch (err) {
logVerbose( logVerbose(
@ -447,6 +455,7 @@ export async function monitorSignalProvider(
const route = resolveAgentRoute({ const route = resolveAgentRoute({
cfg, cfg,
provider: "signal", provider: "signal",
accountId: accountInfo.accountId,
peer: { peer: {
kind: isGroup ? "group" : "dm", kind: isGroup ? "group" : "dm",
id: isGroup ? (groupId ?? "unknown") : normalizeE164(sender), id: isGroup ? (groupId ?? "unknown") : normalizeE164(sender),
@ -505,6 +514,7 @@ export async function monitorSignalProvider(
target: ctxPayload.To, target: ctxPayload.To,
baseUrl, baseUrl,
account, account,
accountId: accountInfo.accountId,
runtime, runtime,
maxBytes: mediaMaxBytes, maxBytes: mediaMaxBytes,
textLimit, textLimit,

View File

@ -2,11 +2,13 @@ import { loadConfig } from "../config/config.js";
import { mediaKindFromMime } from "../media/constants.js"; import { mediaKindFromMime } from "../media/constants.js";
import { saveMediaBuffer } from "../media/store.js"; import { saveMediaBuffer } from "../media/store.js";
import { loadWebMedia } from "../web/media.js"; import { loadWebMedia } from "../web/media.js";
import { resolveSignalAccount } from "./accounts.js";
import { signalRpcRequest } from "./client.js"; import { signalRpcRequest } from "./client.js";
export type SignalSendOpts = { export type SignalSendOpts = {
baseUrl?: string; baseUrl?: string;
account?: string; account?: string;
accountId?: string;
mediaUrl?: string; mediaUrl?: string;
maxBytes?: number; maxBytes?: number;
timeoutMs?: number; timeoutMs?: number;
@ -22,23 +24,6 @@ type SignalTarget =
| { type: "group"; groupId: string } | { type: "group"; groupId: string }
| { type: "username"; username: 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 { function parseTarget(raw: string): SignalTarget {
let value = raw.trim(); let value = raw.trim();
if (!value) throw new Error("Signal recipient is required"); if (!value) throw new Error("Signal recipient is required");
@ -81,11 +66,25 @@ export async function sendMessageSignal(
text: string, text: string,
opts: SignalSendOpts = {}, opts: SignalSendOpts = {},
): Promise<SignalSendResult> { ): Promise<SignalSendResult> {
const baseUrl = resolveBaseUrl(opts.baseUrl); const cfg = loadConfig();
const account = resolveAccount(opts.account); 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); const target = parseTarget(to);
let message = text ?? ""; 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; let attachments: string[] | undefined;
if (opts.mediaUrl?.trim()) { if (opts.mediaUrl?.trim()) {

113
src/slack/accounts.ts Normal file
View File

@ -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);
}

View File

@ -28,6 +28,7 @@ import { getReplyFromConfig } from "../auto-reply/reply.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import type { ReplyPayload } from "../auto-reply/types.js"; import type { ReplyPayload } from "../auto-reply/types.js";
import type { import type {
ClawdbotConfig,
SlackReactionNotificationMode, SlackReactionNotificationMode,
SlackSlashCommandConfig, SlackSlashCommandConfig,
} from "../config/config.js"; } from "../config/config.js";
@ -49,6 +50,7 @@ import {
import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../routing/session-key.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { resolveSlackAccount } from "./accounts.js";
import { reactSlackMessage } from "./actions.js"; import { reactSlackMessage } from "./actions.js";
import { sendMessageSlack } from "./send.js"; import { sendMessageSlack } from "./send.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
@ -56,6 +58,8 @@ import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
export type MonitorSlackOpts = { export type MonitorSlackOpts = {
botToken?: string; botToken?: string;
appToken?: string; appToken?: string;
accountId?: string;
config?: ClawdbotConfig;
runtime?: RuntimeEnv; runtime?: RuntimeEnv;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
mediaMaxMb?: number; mediaMaxMb?: number;
@ -436,7 +440,11 @@ async function resolveSlackThreadStarter(params: {
} }
export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { 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 sessionCfg = cfg.session;
const sessionScope = sessionCfg?.scope ?? "per-sender"; const sessionScope = sessionCfg?.scope ?? "per-sender";
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
@ -462,21 +470,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
mainKey, mainKey,
); );
}; };
const botToken = resolveSlackBotToken( const botToken = resolveSlackBotToken(opts.botToken ?? account.botToken);
opts.botToken ?? const appToken = resolveSlackAppToken(opts.appToken ?? account.appToken);
process.env.SLACK_BOT_TOKEN ??
cfg.slack?.botToken ??
undefined,
);
const appToken = resolveSlackAppToken(
opts.appToken ??
process.env.SLACK_APP_TOKEN ??
cfg.slack?.appToken ??
undefined,
);
if (!botToken || !appToken) { if (!botToken || !appToken) {
throw new Error( 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 dmPolicy = dmConfig?.policy ?? "pairing";
const allowFrom = normalizeAllowList(dmConfig?.allowFrom); const allowFrom = normalizeAllowList(dmConfig?.allowFrom);
const groupDmEnabled = dmConfig?.groupEnabled ?? false; const groupDmEnabled = dmConfig?.groupEnabled ?? false;
const groupDmChannels = normalizeAllowList(dmConfig?.groupChannels); const groupDmChannels = normalizeAllowList(dmConfig?.groupChannels);
const channelsConfig = cfg.slack?.channels; const channelsConfig = slackCfg.channels;
const dmEnabled = dmConfig?.enabled ?? true; const dmEnabled = dmConfig?.enabled ?? true;
const groupPolicy = cfg.slack?.groupPolicy ?? "open"; const groupPolicy = slackCfg.groupPolicy ?? "open";
const useAccessGroups = cfg.commands?.useAccessGroups !== false; const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const reactionMode = cfg.slack?.reactionNotifications ?? "own"; const reactionMode = slackCfg.reactionNotifications ?? "own";
const reactionAllowlist = cfg.slack?.reactionAllowlist ?? []; const reactionAllowlist = slackCfg.reactionAllowlist ?? [];
const slashCommand = resolveSlackSlashCommandConfig( 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 mentionRegexes = buildMentionRegexes(cfg);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const mediaMaxBytes = 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 logger = getChildLogger({ module: "slack-auto-reply" });
const channelCache = new Map< const channelCache = new Map<
@ -790,7 +789,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
"Ask the bot owner to approve with:", "Ask the bot owner to approve with:",
"clawdbot pairing approve --provider slack <code>", "clawdbot pairing approve --provider slack <code>",
].join("\n"), ].join("\n"),
{ token: botToken, client: app.client }, {
token: botToken,
client: app.client,
accountId: account.accountId,
},
); );
} catch (err) { } catch (err) {
logVerbose( logVerbose(
@ -922,6 +925,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const route = resolveAgentRoute({ const route = resolveAgentRoute({
cfg, cfg,
provider: "slack", provider: "slack",
accountId: account.accountId,
teamId: teamId || undefined, teamId: teamId || undefined,
peer: { peer: {
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group", kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
@ -1071,6 +1075,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
replies: [payload], replies: [payload],
target: replyTarget, target: replyTarget,
token: botToken, token: botToken,
accountId: account.accountId,
runtime, runtime,
textLimit, textLimit,
threadTs: incomingThreadTs, threadTs: incomingThreadTs,
@ -1749,6 +1754,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const route = resolveAgentRoute({ const route = resolveAgentRoute({
cfg, cfg,
provider: "slack", provider: "slack",
accountId: account.accountId,
teamId: teamId || undefined, teamId: teamId || undefined,
peer: { peer: {
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group", kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
@ -1875,6 +1881,7 @@ async function deliverReplies(params: {
replies: ReplyPayload[]; replies: ReplyPayload[];
target: string; target: string;
token: string; token: string;
accountId?: string;
runtime: RuntimeEnv; runtime: RuntimeEnv;
textLimit: number; textLimit: number;
threadTs?: string; threadTs?: string;
@ -1893,6 +1900,7 @@ async function deliverReplies(params: {
await sendMessageSlack(params.target, trimmed, { await sendMessageSlack(params.target, trimmed, {
token: params.token, token: params.token,
threadTs: params.threadTs, threadTs: params.threadTs,
accountId: params.accountId,
}); });
} }
} else { } else {
@ -1904,6 +1912,7 @@ async function deliverReplies(params: {
token: params.token, token: params.token,
mediaUrl, mediaUrl,
threadTs: params.threadTs, threadTs: params.threadTs,
accountId: params.accountId,
}); });
} }
} }

View File

@ -6,6 +6,7 @@ import {
} from "../auto-reply/chunk.js"; } from "../auto-reply/chunk.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { loadWebMedia } from "../web/media.js"; import { loadWebMedia } from "../web/media.js";
import { resolveSlackAccount } from "./accounts.js";
import { resolveSlackBotToken } from "./token.js"; import { resolveSlackBotToken } from "./token.js";
const SLACK_TEXT_LIMIT = 4000; const SLACK_TEXT_LIMIT = 4000;
@ -22,6 +23,7 @@ type SlackRecipient =
type SlackSendOpts = { type SlackSendOpts = {
token?: string; token?: string;
accountId?: string;
mediaUrl?: string; mediaUrl?: string;
client?: WebClient; client?: WebClient;
threadTs?: string; threadTs?: string;
@ -32,17 +34,20 @@ export type SlackSendResult = {
channelId: string; channelId: string;
}; };
function resolveToken(explicit?: string) { function resolveToken(params: {
const cfgToken = loadConfig().slack?.botToken; explicit?: string;
const token = resolveSlackBotToken( accountId: string;
explicit ?? process.env.SLACK_BOT_TOKEN ?? cfgToken ?? undefined, fallbackToken?: string;
); }) {
if (!token) { const explicit = resolveSlackBotToken(params.explicit);
if (explicit) return explicit;
const fallback = resolveSlackBotToken(params.fallbackToken);
if (!fallback) {
throw new Error( 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 { function parseRecipient(raw: string): SlackRecipient {
@ -140,17 +145,25 @@ export async function sendMessageSlack(
if (!trimmedMessage && !opts.mediaUrl) { if (!trimmedMessage && !opts.mediaUrl) {
throw new Error("Slack send requires text or media"); 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 client = opts.client ?? new WebClient(token);
const recipient = parseRecipient(to); const recipient = parseRecipient(to);
const { channelId } = await resolveChannelId(client, recipient); const { channelId } = await resolveChannelId(client, recipient);
const cfg = loadConfig(); const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
const textLimit = resolveTextChunkLimit(cfg, "slack");
const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT); const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT);
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit); const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
const mediaMaxBytes = const mediaMaxBytes =
typeof cfg.slack?.mediaMaxMb === "number" typeof account.config.mediaMaxMb === "number"
? cfg.slack.mediaMaxMb * 1024 * 1024 ? account.config.mediaMaxMb * 1024 * 1024
: undefined; : undefined;
let lastMessageId = ""; let lastMessageId = "";

81
src/telegram/accounts.ts Normal file
View File

@ -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);
}

View File

@ -50,6 +50,7 @@ import {
import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveAgentRoute } from "../routing/resolve-route.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { loadWebMedia } from "../web/media.js"; import { loadWebMedia } from "../web/media.js";
import { resolveTelegramAccount } from "./accounts.js";
import { createTelegramDraftStream } from "./draft-stream.js"; import { createTelegramDraftStream } from "./draft-stream.js";
import { import {
readTelegramAllowFromStore, readTelegramAllowFromStore,
@ -105,6 +106,7 @@ type TelegramContext = {
export type TelegramBotOptions = { export type TelegramBotOptions = {
token: string; token: string;
accountId?: string;
runtime?: RuntimeEnv; runtime?: RuntimeEnv;
requireMention?: boolean; requireMention?: boolean;
allowFrom?: Array<string | number>; allowFrom?: Array<string | number>;
@ -158,14 +160,19 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const mediaGroupBuffer = new Map<string, MediaGroupEntry>(); const mediaGroupBuffer = new Map<string, MediaGroupEntry>();
const cfg = opts.config ?? loadConfig(); const cfg = opts.config ?? loadConfig();
const textLimit = resolveTextChunkLimit(cfg, "telegram"); const account = resolveTelegramAccount({
const dmPolicy = cfg.telegram?.dmPolicy ?? "pairing"; cfg,
const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom; 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 = const groupAllowFrom =
opts.groupAllowFrom ?? opts.groupAllowFrom ??
cfg.telegram?.groupAllowFrom ?? telegramCfg.groupAllowFrom ??
(cfg.telegram?.allowFrom && cfg.telegram.allowFrom.length > 0 (telegramCfg.allowFrom && telegramCfg.allowFrom.length > 0
? cfg.telegram.allowFrom ? telegramCfg.allowFrom
: undefined) ?? : undefined) ??
(opts.allowFrom && opts.allowFrom.length > 0 ? opts.allowFrom : undefined); (opts.allowFrom && opts.allowFrom.length > 0 ? opts.allowFrom : undefined);
const normalizeAllowFrom = (list?: Array<string | number>) => { const normalizeAllowFrom = (list?: Array<string | number>) => {
@ -205,15 +212,15 @@ export function createTelegramBot(opts: TelegramBotOptions) {
(entry) => entry === username || entry === `@${username}`, (entry) => entry === username || entry === `@${username}`,
); );
}; };
const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "first"; const replyToMode = opts.replyToMode ?? telegramCfg.replyToMode ?? "first";
const streamMode = resolveTelegramStreamMode(cfg); const streamMode = resolveTelegramStreamMode(telegramCfg);
const nativeEnabled = cfg.commands?.native === true; const nativeEnabled = cfg.commands?.native === true;
const nativeDisabledExplicit = cfg.commands?.native === false; const nativeDisabledExplicit = cfg.commands?.native === false;
const useAccessGroups = cfg.commands?.useAccessGroups !== false; const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const mediaMaxBytes = 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 logger = getChildLogger({ module: "telegram-auto-reply" });
const mentionRegexes = buildMentionRegexes(cfg); const mentionRegexes = buildMentionRegexes(cfg);
let botHasTopicsEnabled: boolean | undefined; let botHasTopicsEnabled: boolean | undefined;
@ -237,6 +244,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
resolveProviderGroupPolicy({ resolveProviderGroupPolicy({
cfg, cfg,
provider: "telegram", provider: "telegram",
accountId: account.accountId,
groupId: String(chatId), groupId: String(chatId),
}); });
const resolveGroupActivation = (params: { const resolveGroupActivation = (params: {
@ -264,6 +272,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
resolveProviderGroupRequireMention({ resolveProviderGroupRequireMention({
cfg, cfg,
provider: "telegram", provider: "telegram",
accountId: account.accountId,
groupId: String(chatId), groupId: String(chatId),
requireMentionOverride: opts.requireMention, requireMentionOverride: opts.requireMention,
overrideOrder: "after-config", overrideOrder: "after-config",
@ -272,7 +281,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
chatId: string | number, chatId: string | number,
messageThreadId?: number, messageThreadId?: number,
) => { ) => {
const groups = cfg.telegram?.groups; const groups = telegramCfg.groups;
if (!groups) return { groupConfig: undefined, topicConfig: undefined }; if (!groups) return { groupConfig: undefined, topicConfig: undefined };
const groupKey = String(chatId); const groupKey = String(chatId);
const groupConfig = groups[groupKey] ?? groups["*"]; const groupConfig = groups[groupKey] ?? groups["*"];
@ -304,6 +313,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const route = resolveAgentRoute({ const route = resolveAgentRoute({
cfg, cfg,
provider: "telegram", provider: "telegram",
accountId: account.accountId,
peer: { peer: {
kind: isGroup ? "group" : "dm", kind: isGroup ? "group" : "dm",
id: peerId, id: peerId,
@ -814,7 +824,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
} }
if (isGroup && useAccessGroups) { if (isGroup && useAccessGroups) {
const groupPolicy = cfg.telegram?.groupPolicy ?? "open"; const groupPolicy = telegramCfg.groupPolicy ?? "open";
if (groupPolicy === "disabled") { if (groupPolicy === "disabled") {
await bot.api.sendMessage( await bot.api.sendMessage(
chatId, chatId,
@ -881,6 +891,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const route = resolveAgentRoute({ const route = resolveAgentRoute({
cfg, cfg,
provider: "telegram", provider: "telegram",
accountId: account.accountId,
peer: { peer: {
kind: isGroup ? "group" : "dm", kind: isGroup ? "group" : "dm",
id: isGroup id: isGroup
@ -1009,7 +1020,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
// - "open" (default): groups bypass allowFrom, only mention-gating applies // - "open" (default): groups bypass allowFrom, only mention-gating applies
// - "disabled": block all group messages entirely // - "disabled": block all group messages entirely
// - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom // - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
const groupPolicy = cfg.telegram?.groupPolicy ?? "open"; const groupPolicy = telegramCfg.groupPolicy ?? "open";
if (groupPolicy === "disabled") { if (groupPolicy === "disabled") {
logVerbose(`Blocked telegram group message (groupPolicy: disabled)`); logVerbose(`Blocked telegram group message (groupPolicy: disabled)`);
return; return;
@ -1260,9 +1271,9 @@ function buildTelegramThreadParams(messageThreadId?: number) {
} }
function resolveTelegramStreamMode( function resolveTelegramStreamMode(
cfg: ReturnType<typeof loadConfig>, telegramCfg: ClawdbotConfig["telegram"],
): TelegramStreamMode { ): TelegramStreamMode {
const raw = cfg.telegram?.streamMode?.trim().toLowerCase(); const raw = telegramCfg?.streamMode?.trim().toLowerCase();
if (raw === "off" || raw === "partial" || raw === "block") return raw; if (raw === "off" || raw === "partial" || raw === "block") return raw;
return "partial"; return "partial";
} }

View File

@ -2,13 +2,15 @@ import { type RunOptions, run } from "@grammyjs/runner";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { resolveTelegramAccount } from "./accounts.js";
import { createTelegramBot } from "./bot.js"; import { createTelegramBot } from "./bot.js";
import { makeProxyFetch } from "./proxy.js"; import { makeProxyFetch } from "./proxy.js";
import { resolveTelegramToken } from "./token.js";
import { startTelegramWebhook } from "./webhook.js"; import { startTelegramWebhook } from "./webhook.js";
export type MonitorTelegramOpts = { export type MonitorTelegramOpts = {
token?: string; token?: string;
accountId?: string;
config?: ClawdbotConfig;
runtime?: RuntimeEnv; runtime?: RuntimeEnv;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
useWebhook?: boolean; useWebhook?: boolean;
@ -36,20 +38,22 @@ export function createTelegramRunnerOptions(
} }
export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
const cfg = loadConfig(); const cfg = opts.config ?? loadConfig();
const { token } = resolveTelegramToken(cfg, { const account = resolveTelegramAccount({
envToken: opts.token, cfg,
accountId: opts.accountId,
}); });
const token = opts.token?.trim() || account.token;
if (!token) { if (!token) {
throw new Error( 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 = const proxyFetch =
opts.proxyFetch ?? opts.proxyFetch ??
(cfg.telegram?.proxy (account.config.proxy
? makeProxyFetch(cfg.telegram?.proxy as string) ? makeProxyFetch(account.config.proxy as string)
: undefined); : undefined);
const bot = createTelegramBot({ const bot = createTelegramBot({
@ -57,6 +61,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
runtime: opts.runtime, runtime: opts.runtime,
proxyFetch, proxyFetch,
config: cfg, config: cfg,
accountId: account.accountId,
}); });
if (opts.useWebhook) { if (opts.useWebhook) {

View File

@ -1,17 +1,17 @@
import type { ReactionType, ReactionTypeEmoji } from "@grammyjs/types"; import type { ReactionType, ReactionTypeEmoji } from "@grammyjs/types";
import { Bot, InputFile } from "grammy"; import { Bot, InputFile } from "grammy";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import type { ClawdbotConfig } from "../config/types.js";
import { formatErrorMessage } from "../infra/errors.js"; import { formatErrorMessage } from "../infra/errors.js";
import type { RetryConfig } from "../infra/retry.js"; import type { RetryConfig } from "../infra/retry.js";
import { createTelegramRetryRunner } from "../infra/retry-policy.js"; import { createTelegramRetryRunner } from "../infra/retry-policy.js";
import { mediaKindFromMime } from "../media/constants.js"; import { mediaKindFromMime } from "../media/constants.js";
import { isGifMedia } from "../media/mime.js"; import { isGifMedia } from "../media/mime.js";
import { loadWebMedia } from "../web/media.js"; import { loadWebMedia } from "../web/media.js";
import { resolveTelegramToken } from "./token.js"; import { resolveTelegramAccount } from "./accounts.js";
type TelegramSendOpts = { type TelegramSendOpts = {
token?: string; token?: string;
accountId?: string;
verbose?: boolean; verbose?: boolean;
mediaUrl?: string; mediaUrl?: string;
maxBytes?: number; maxBytes?: number;
@ -30,6 +30,7 @@ type TelegramSendResult = {
type TelegramReactionOpts = { type TelegramReactionOpts = {
token?: string; token?: string;
accountId?: string;
api?: Bot["api"]; api?: Bot["api"];
remove?: boolean; remove?: boolean;
verbose?: boolean; verbose?: boolean;
@ -39,15 +40,17 @@ type TelegramReactionOpts = {
const PARSE_ERR_RE = const PARSE_ERR_RE =
/can't parse entities|parse entities|find end of the entity/i; /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(); if (explicit?.trim()) return explicit.trim();
const { token } = resolveTelegramToken(cfg); if (!params.token) {
if (!token) {
throw new Error( 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 { function normalizeChatId(to: string): string {
@ -97,7 +100,11 @@ export async function sendMessageTelegram(
opts: TelegramSendOpts = {}, opts: TelegramSendOpts = {},
): Promise<TelegramSendResult> { ): Promise<TelegramSendResult> {
const cfg = loadConfig(); 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); const chatId = normalizeChatId(to);
// Use provided api or create a new Bot instance. The nullish coalescing // Use provided api or create a new Bot instance. The nullish coalescing
// operator ensures api is always defined (Bot.api is always non-null). // 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 hasThreadParams = Object.keys(threadParams).length > 0;
const request = createTelegramRetryRunner({ const request = createTelegramRetryRunner({
retry: opts.retry, retry: opts.retry,
configRetry: cfg.telegram?.retry, configRetry: account.config.retry,
verbose: opts.verbose, verbose: opts.verbose,
}); });
@ -236,13 +243,17 @@ export async function reactMessageTelegram(
opts: TelegramReactionOpts = {}, opts: TelegramReactionOpts = {},
): Promise<{ ok: true }> { ): Promise<{ ok: true }> {
const cfg = loadConfig(); 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 chatId = normalizeChatId(String(chatIdInput));
const messageId = normalizeMessageId(messageIdInput); const messageId = normalizeMessageId(messageIdInput);
const api = opts.api ?? new Bot(token).api; const api = opts.api ?? new Bot(token).api;
const request = createTelegramRetryRunner({ const request = createTelegramRetryRunner({
retry: opts.retry, retry: opts.retry,
configRetry: cfg.telegram?.retry, configRetry: account.config.retry,
verbose: opts.verbose, verbose: opts.verbose,
}); });
const remove = opts.remove === true; const remove = opts.remove === true;

View File

@ -1,6 +1,10 @@
import fs from "node:fs"; import fs from "node:fs";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../routing/session-key.js";
export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none"; export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none";
@ -11,6 +15,7 @@ export type TelegramTokenResolution = {
type ResolveTelegramTokenOpts = { type ResolveTelegramTokenOpts = {
envToken?: string | null; envToken?: string | null;
accountId?: string | null;
logMissingFile?: (message: string) => void; logMissingFile?: (message: string) => void;
}; };
@ -18,13 +23,48 @@ export function resolveTelegramToken(
cfg?: ClawdbotConfig, cfg?: ClawdbotConfig,
opts: ResolveTelegramTokenOpts = {}, opts: ResolveTelegramTokenOpts = {},
): TelegramTokenResolution { ): 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) { if (envToken) {
return { token: envToken, source: "env" }; return { token: envToken, source: "env" };
} }
const tokenFile = cfg?.telegram?.tokenFile?.trim(); const tokenFile = cfg?.telegram?.tokenFile?.trim();
if (tokenFile) { if (tokenFile && allowEnv) {
if (!fs.existsSync(tokenFile)) { if (!fs.existsSync(tokenFile)) {
opts.logMissingFile?.(`telegram.tokenFile not found: ${tokenFile}`); opts.logMissingFile?.(`telegram.tokenFile not found: ${tokenFile}`);
return { token: "", source: "none" }; return { token: "", source: "none" };
@ -38,11 +78,10 @@ export function resolveTelegramToken(
opts.logMissingFile?.(`telegram.tokenFile read failed: ${String(err)}`); opts.logMissingFile?.(`telegram.tokenFile read failed: ${String(err)}`);
return { token: "", source: "none" }; return { token: "", source: "none" };
} }
return { token: "", source: "none" };
} }
const configToken = cfg?.telegram?.botToken?.trim(); const configToken = cfg?.telegram?.botToken?.trim();
if (configToken) { if (configToken && allowEnv) {
return { token: configToken, source: "config" }; return { token: configToken, source: "config" };
} }

View File

@ -142,5 +142,25 @@ export function shortenHomeInString(input: string): string {
return input.split(home).join("~"); 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. // Configuration root; can be overridden via CLAWDBOT_STATE_DIR.
export const CONFIG_DIR = resolveConfigDir(); export const CONFIG_DIR = resolveConfigDir();

View File

@ -9,6 +9,7 @@ import { resolveUserPath } from "../utils.js";
export type ResolvedWhatsAppAccount = { export type ResolvedWhatsAppAccount = {
accountId: string; accountId: string;
name?: string;
enabled: boolean; enabled: boolean;
authDir: string; authDir: string;
isLegacyAuthDir: boolean; isLegacyAuthDir: boolean;
@ -101,6 +102,7 @@ export function resolveWhatsAppAccount(params: {
}); });
return { return {
accountId, accountId,
name: accountCfg?.name?.trim() || undefined,
enabled, enabled,
authDir, authDir,
isLegacyAuthDir: isLegacy, isLegacyAuthDir: isLegacy,