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).
- The legacy single-account Baileys auth dir is migrated by `clawdbot doctor` into `whatsapp/default`.
### `telegram.accounts` / `discord.accounts` / `slack.accounts` / `signal.accounts` / `imessage.accounts`
Run multiple accounts per provider (each account has its own `accountId` and optional `name`):
```json5
{
telegram: {
accounts: {
default: {
name: "Primary bot",
botToken: "123456:ABC..."
},
alerts: {
name: "Alerts bot",
botToken: "987654:XYZ..."
}
}
}
}
```
Notes:
- `default` is used when `accountId` is omitted (CLI + routing).
- Env tokens only apply to the **default** account.
- Base provider settings (group policy, mention gating, etc.) apply to all accounts unless overridden per account.
- Use `routing.bindings[].match.accountId` to route each account to a different agent.
### `routing.groupChat`
Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats.
@@ -560,6 +587,7 @@ Set `web.enabled: false` to keep it off by default.
Clawdbot starts Telegram only when a `telegram` config section exists. The bot token is resolved from `TELEGRAM_BOT_TOKEN` or `telegram.botToken`.
Set `telegram.enabled: false` to disable automatic startup.
Multi-account support lives under `telegram.accounts` (see the multi-account section above). Env tokens only apply to the default account.
```json5
{
@@ -609,6 +637,7 @@ Retry policy defaults and behavior are documented in [Retry policy](/concepts/re
### `discord` (bot transport)
Configure the Discord bot by setting the bot token and optional gating:
Multi-account support lives under `discord.accounts` (see the multi-account section above). Env tokens only apply to the default account.
```json5
{
@@ -728,6 +757,8 @@ Slack runs in Socket Mode and requires both a bot token and app token:
}
```
Multi-account support lives under `slack.accounts` (see the multi-account section above). Env tokens only apply to the default account.
Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands.
Reaction notification modes:
@@ -764,10 +795,19 @@ Clawdbot spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
}
```
Multi-account support lives under `imessage.accounts` (see the multi-account section above).
Notes:
- Requires Full Disk Access to the Messages DB.
- The first send will prompt for Messages automation permission.
- Prefer `chat_id:<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`

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
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.
- Full Disk Access for Clawdbot + `imsg` (Messages DB access).
- Automation permission when sending.
- `imessage.cliPath` can point to a wrapper script (for example, an SSH hop to another Mac that runs `imsg rpc`).
## Setup (fast path)
1) Ensure Messages is signed in on this Mac.
2) Configure iMessage and start the gateway.
### Remote/SSH variant (optional)
If you want iMessage on another Mac, set `imessage.cliPath` to a wrapper that
execs `ssh` and runs `imsg rpc` on the remote host. Clawdbot only needs a
stdio stream; `imsg` still runs on the remote macOS host.
Example wrapper (save somewhere in your PATH and `chmod +x`):
```bash
#!/usr/bin/env bash
exec ssh -T mac-mini "imsg rpc"
```
Notes:
- Remote Mac must have Messages signed in and `imsg` installed.
- Full Disk Access + Automation prompts happen on the remote Mac.
- Use SSH keys (no password prompt) so the gateway can launch `imsg rpc` unattended.
Example:
```json5
{
@@ -36,6 +53,8 @@ Example:
}
```
Multi-account support: use `imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
## Access control (DMs + groups)
DMs:
- Default: `imessage.dmPolicy = "pairing"`.

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)
DMs:
- 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.
Multi-account support: use `slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
## Manifest (optional)
Use this Slack app manifest to create the app quickly (adjust the name/command if you want).

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.
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.

View File

@@ -85,6 +85,20 @@ describe("resolveTextChunkLimit", () => {
expect(resolveTextChunkLimit(cfg, "telegram")).toBe(1234);
});
it("prefers account overrides when provided", () => {
const cfg = {
telegram: {
textChunkLimit: 2000,
accounts: {
default: { textChunkLimit: 1234 },
primary: { textChunkLimit: 777 },
},
},
};
expect(resolveTextChunkLimit(cfg, "telegram", "primary")).toBe(777);
expect(resolveTextChunkLimit(cfg, "telegram", "default")).toBe(1234);
});
it("uses the matching provider override", () => {
const cfg = {
discord: { textChunkLimit: 111 },

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -44,13 +44,17 @@ function normalizeDefaultWorkspacePath(
return next === resolved ? value : next;
}
export function replaceLegacyName(value: string | undefined): string | undefined {
export function replaceLegacyName(
value: string | undefined,
): string | undefined {
if (!value) return value;
const replacedClawdis = value.replace(/clawdis/g, "clawdbot");
return replacedClawdis.replace(/clawd(?!bot)/g, "clawdbot");
}
export function replaceModernName(value: string | undefined): string | undefined {
export function replaceModernName(
value: string | undefined,
): string | undefined {
if (!value) return value;
if (!value.includes("clawdbot")) return value;
return value.replace(/clawdbot/g, "clawdis");

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -89,6 +89,26 @@ const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]);
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
.object({
attempts: z.number().int().min(1).optional(),
@@ -121,6 +141,316 @@ const HexColorSchema = z
.string()
.regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)");
const TelegramTopicSchema = z.object({
requireMention: z.boolean().optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
});
const TelegramGroupSchema = z.object({
requireMention: z.boolean().optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(),
});
const TelegramAccountSchemaBase = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
botToken: z.string().optional(),
tokenFile: z.string().optional(),
replyToMode: ReplyToModeSchema.optional(),
groups: z.record(z.string(), TelegramGroupSchema.optional()).optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"),
mediaMaxMb: z.number().positive().optional(),
retry: RetryConfigSchema,
proxy: z.string().optional(),
webhookUrl: z.string().optional(),
webhookSecret: z.string().optional(),
webhookPath: z.string().optional(),
actions: z
.object({
reactions: z.boolean().optional(),
})
.optional(),
});
const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine(
(value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'telegram.dmPolicy="open" requires telegram.allowFrom to include "*"',
});
},
);
const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'telegram.dmPolicy="open" requires telegram.allowFrom to include "*"',
});
});
const DiscordDmSchema = z
.object({
enabled: z.boolean().optional(),
policy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupEnabled: z.boolean().optional(),
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
})
.superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.policy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'discord.dm.policy="open" requires discord.dm.allowFrom to include "*"',
});
});
const DiscordGuildChannelSchema = z.object({
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
});
const DiscordGuildSchema = z.object({
slug: z.string().optional(),
requireMention: z.boolean().optional(),
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
channels: z
.record(z.string(), DiscordGuildChannelSchema.optional())
.optional(),
});
const DiscordAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
token: z.string().optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
historyLimit: z.number().int().min(0).optional(),
retry: RetryConfigSchema,
actions: z
.object({
reactions: z.boolean().optional(),
stickers: z.boolean().optional(),
polls: z.boolean().optional(),
permissions: z.boolean().optional(),
messages: z.boolean().optional(),
threads: z.boolean().optional(),
pins: z.boolean().optional(),
search: z.boolean().optional(),
memberInfo: z.boolean().optional(),
roleInfo: z.boolean().optional(),
roles: z.boolean().optional(),
channelInfo: z.boolean().optional(),
voiceStatus: z.boolean().optional(),
events: z.boolean().optional(),
moderation: z.boolean().optional(),
})
.optional(),
replyToMode: ReplyToModeSchema.optional(),
dm: DiscordDmSchema.optional(),
guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(),
});
const DiscordConfigSchema = DiscordAccountSchema.extend({
accounts: z.record(z.string(), DiscordAccountSchema.optional()).optional(),
});
const SlackDmSchema = z
.object({
enabled: z.boolean().optional(),
policy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupEnabled: z.boolean().optional(),
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
})
.superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.policy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'slack.dm.policy="open" requires slack.dm.allowFrom to include "*"',
});
});
const SlackChannelSchema = z.object({
enabled: z.boolean().optional(),
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
skills: z.array(z.string()).optional(),
systemPrompt: z.string().optional(),
});
const SlackAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
botToken: z.string().optional(),
appToken: z.string().optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
actions: z
.object({
reactions: z.boolean().optional(),
messages: z.boolean().optional(),
pins: z.boolean().optional(),
search: z.boolean().optional(),
permissions: z.boolean().optional(),
memberInfo: z.boolean().optional(),
channelInfo: z.boolean().optional(),
emojiList: z.boolean().optional(),
})
.optional(),
slashCommand: z
.object({
enabled: z.boolean().optional(),
name: z.string().optional(),
sessionPrefix: z.string().optional(),
ephemeral: z.boolean().optional(),
})
.optional(),
dm: SlackDmSchema.optional(),
channels: z.record(z.string(), SlackChannelSchema.optional()).optional(),
});
const SlackConfigSchema = SlackAccountSchema.extend({
accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(),
});
const SignalAccountSchemaBase = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
account: z.string().optional(),
httpUrl: z.string().optional(),
httpHost: z.string().optional(),
httpPort: z.number().int().positive().optional(),
cliPath: z.string().optional(),
autoStart: z.boolean().optional(),
receiveMode: z.union([z.literal("on-start"), z.literal("manual")]).optional(),
ignoreAttachments: z.boolean().optional(),
ignoreStories: z.boolean().optional(),
sendReadReceipts: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().int().positive().optional(),
});
const SignalAccountSchema = SignalAccountSchemaBase.superRefine(
(value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'signal.dmPolicy="open" requires signal.allowFrom to include "*"',
});
},
);
const SignalConfigSchema = SignalAccountSchemaBase.extend({
accounts: z.record(z.string(), SignalAccountSchema.optional()).optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message: 'signal.dmPolicy="open" requires signal.allowFrom to include "*"',
});
});
const IMessageAccountSchemaBase = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
cliPath: z.string().optional(),
dbPath: z.string().optional(),
service: z
.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")])
.optional(),
region: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
includeAttachments: z.boolean().optional(),
mediaMaxMb: z.number().int().positive().optional(),
textChunkLimit: z.number().int().positive().optional(),
groups: z
.record(
z.string(),
z
.object({
requireMention: z.boolean().optional(),
})
.optional(),
)
.optional(),
});
const IMessageAccountSchema = IMessageAccountSchemaBase.superRefine(
(value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'imessage.dmPolicy="open" requires imessage.allowFrom to include "*"',
});
},
);
const IMessageConfigSchema = IMessageAccountSchemaBase.extend({
accounts: z.record(z.string(), IMessageAccountSchema.optional()).optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'imessage.dmPolicy="open" requires imessage.allowFrom to include "*"',
});
});
const SessionSchema = z
.object({
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
@@ -777,6 +1107,7 @@ export const ClawdbotSchema = z.object({
z.string(),
z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
authDir: z.string().optional(),
@@ -849,311 +1180,12 @@ export const ClawdbotSchema = z.object({
});
})
.optional(),
telegram: z
.object({
enabled: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
botToken: z.string().optional(),
tokenFile: z.string().optional(),
replyToMode: ReplyToModeSchema.optional(),
groups: z
.record(
z.string(),
z
.object({
requireMention: z.boolean().optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
topics: z
.record(
z.string(),
z
.object({
requireMention: z.boolean().optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z
.array(z.union([z.string(), z.number()]))
.optional(),
systemPrompt: z.string().optional(),
})
.optional(),
)
.optional(),
})
.optional(),
)
.optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
streamMode: z
.enum(["off", "partial", "block"])
.optional()
.default("partial"),
mediaMaxMb: z.number().positive().optional(),
retry: RetryConfigSchema,
proxy: z.string().optional(),
webhookUrl: z.string().optional(),
webhookSecret: z.string().optional(),
webhookPath: z.string().optional(),
actions: z
.object({
reactions: z.boolean().optional(),
})
.optional(),
})
.superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'telegram.dmPolicy="open" requires telegram.allowFrom to include "*"',
});
})
.optional(),
discord: z
.object({
enabled: z.boolean().optional(),
token: z.string().optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
historyLimit: z.number().int().min(0).optional(),
retry: RetryConfigSchema,
actions: z
.object({
reactions: z.boolean().optional(),
stickers: z.boolean().optional(),
polls: z.boolean().optional(),
permissions: z.boolean().optional(),
messages: z.boolean().optional(),
threads: z.boolean().optional(),
pins: z.boolean().optional(),
search: z.boolean().optional(),
memberInfo: z.boolean().optional(),
roleInfo: z.boolean().optional(),
roles: z.boolean().optional(),
channelInfo: z.boolean().optional(),
voiceStatus: z.boolean().optional(),
events: z.boolean().optional(),
moderation: z.boolean().optional(),
})
.optional(),
replyToMode: ReplyToModeSchema.optional(),
dm: z
.object({
enabled: z.boolean().optional(),
policy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupEnabled: z.boolean().optional(),
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
})
.superRefine((value, ctx) => {
if (value.policy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'discord.dm.policy="open" requires discord.dm.allowFrom to include "*"',
});
})
.optional(),
guilds: z
.record(
z.string(),
z
.object({
slug: z.string().optional(),
requireMention: z.boolean().optional(),
reactionNotifications: z
.enum(["off", "own", "all", "allowlist"])
.optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
channels: z
.record(
z.string(),
z
.object({
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
users: z
.array(z.union([z.string(), z.number()]))
.optional(),
systemPrompt: z.string().optional(),
})
.optional(),
)
.optional(),
})
.optional(),
)
.optional(),
})
.optional(),
slack: z
.object({
enabled: z.boolean().optional(),
botToken: z.string().optional(),
appToken: z.string().optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
reactionNotifications: z
.enum(["off", "own", "all", "allowlist"])
.optional(),
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
replyToMode: ReplyToModeSchema.optional(),
actions: z
.object({
reactions: z.boolean().optional(),
messages: z.boolean().optional(),
pins: z.boolean().optional(),
search: z.boolean().optional(),
permissions: z.boolean().optional(),
memberInfo: z.boolean().optional(),
channelInfo: z.boolean().optional(),
emojiList: z.boolean().optional(),
})
.optional(),
slashCommand: z
.object({
enabled: z.boolean().optional(),
name: z.string().optional(),
sessionPrefix: z.string().optional(),
ephemeral: z.boolean().optional(),
})
.optional(),
dm: z
.object({
enabled: z.boolean().optional(),
policy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupEnabled: z.boolean().optional(),
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
})
.superRefine((value, ctx) => {
if (value.policy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'slack.dm.policy="open" requires slack.dm.allowFrom to include "*"',
});
})
.optional(),
channels: z
.record(
z.string(),
z
.object({
enabled: z.boolean().optional(),
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
skills: z.array(z.string()).optional(),
systemPrompt: z.string().optional(),
})
.optional(),
)
.optional(),
})
.optional(),
signal: z
.object({
enabled: z.boolean().optional(),
account: z.string().optional(),
httpUrl: z.string().optional(),
httpHost: z.string().optional(),
httpPort: z.number().int().positive().optional(),
cliPath: z.string().optional(),
autoStart: z.boolean().optional(),
receiveMode: z
.union([z.literal("on-start"), z.literal("manual")])
.optional(),
ignoreAttachments: z.boolean().optional(),
ignoreStories: z.boolean().optional(),
sendReadReceipts: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().int().positive().optional(),
})
.superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'signal.dmPolicy="open" requires signal.allowFrom to include "*"',
});
})
.optional(),
imessage: z
.object({
enabled: z.boolean().optional(),
cliPath: z.string().optional(),
dbPath: z.string().optional(),
service: z
.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")])
.optional(),
region: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
includeAttachments: z.boolean().optional(),
mediaMaxMb: z.number().int().positive().optional(),
textChunkLimit: z.number().int().positive().optional(),
groups: z
.record(
z.string(),
z
.object({
requireMention: z.boolean().optional(),
})
.optional(),
)
.optional(),
})
.superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'imessage.dmPolicy="open" requires imessage.allowFrom to include "*"',
});
})
.optional(),
telegram: TelegramConfigSchema.optional(),
discord: DiscordConfigSchema.optional(),
slack: SlackConfigSchema.optional(),
signal: SignalConfigSchema.optional(),
imessage: IMessageConfigSchema.optional(),
bridge: z
.object({
enabled: z.boolean().optional(),

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

View File

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

View File

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

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 {
if (!raw) return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
return trimmed.replace(/^Bot\s+/i, "");
}
export function resolveDiscordToken(
cfg?: ClawdbotConfig,
opts: { accountId?: string | null; envToken?: string | null } = {},
): DiscordTokenResolution {
const accountId = normalizeAccountId(opts.accountId);
const accountCfg =
accountId !== DEFAULT_ACCOUNT_ID
? cfg?.discord?.accounts?.[accountId]
: cfg?.discord?.accounts?.[DEFAULT_ACCOUNT_ID];
const accountToken = normalizeDiscordToken(accountCfg?.token ?? undefined);
if (accountToken) return { token: accountToken, source: "config" };
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const envToken = allowEnv
? normalizeDiscordToken(opts.envToken ?? process.env.DISCORD_BOT_TOKEN)
: undefined;
if (envToken) return { token: envToken, source: "env" };
const configToken = allowEnv
? normalizeDiscordToken(cfg?.discord?.token ?? undefined)
: undefined;
if (configToken) return { token: configToken, source: "config" };
return { token: "", source: "none" };
}

View File

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

View File

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

View File

@@ -71,7 +71,16 @@ export type GatewayRequestContext = {
getRuntimeSnapshot: () => ProviderRuntimeSnapshot;
startWhatsAppProvider: (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;
wizardRunner: (
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,
lastError: null,
},
whatsappAccounts: {},
telegram: {
running: false,
lastStartAt: null,
@@ -49,18 +50,21 @@ const hoisted = vi.hoisted(() => {
lastError: null,
mode: null,
},
telegramAccounts: {},
discord: {
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
discordAccounts: {},
slack: {
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
slackAccounts: {},
signal: {
running: false,
lastStartAt: null,
@@ -68,6 +72,7 @@ const hoisted = vi.hoisted(() => {
lastError: null,
baseUrl: null,
},
signalAccounts: {},
imessage: {
running: false,
lastStartAt: null,
@@ -76,6 +81,7 @@ const hoisted = vi.hoisted(() => {
cliPath: null,
dbPath: null,
},
imessageAccounts: {},
})),
startProviders: vi.fn(async () => {}),
startWhatsAppProvider: vi.fn(async () => {}),

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -2,11 +2,13 @@ import { loadConfig } from "../config/config.js";
import { mediaKindFromMime } from "../media/constants.js";
import { saveMediaBuffer } from "../media/store.js";
import { loadWebMedia } from "../web/media.js";
import { resolveSignalAccount } from "./accounts.js";
import { signalRpcRequest } from "./client.js";
export type SignalSendOpts = {
baseUrl?: string;
account?: string;
accountId?: string;
mediaUrl?: string;
maxBytes?: number;
timeoutMs?: number;
@@ -22,23 +24,6 @@ type SignalTarget =
| { type: "group"; groupId: string }
| { type: "username"; username: string };
function resolveBaseUrl(explicit?: string): string {
const cfg = loadConfig();
const signalCfg = cfg.signal;
if (explicit?.trim()) return explicit.trim();
if (signalCfg?.httpUrl?.trim()) return signalCfg.httpUrl.trim();
const host = signalCfg?.httpHost?.trim() || "127.0.0.1";
const port = signalCfg?.httpPort ?? 8080;
return `http://${host}:${port}`;
}
function resolveAccount(explicit?: string): string | undefined {
const cfg = loadConfig();
const signalCfg = cfg.signal;
const account = explicit?.trim() || signalCfg?.account?.trim();
return account || undefined;
}
function parseTarget(raw: string): SignalTarget {
let value = raw.trim();
if (!value) throw new Error("Signal recipient is required");
@@ -81,11 +66,25 @@ export async function sendMessageSignal(
text: string,
opts: SignalSendOpts = {},
): Promise<SignalSendResult> {
const baseUrl = resolveBaseUrl(opts.baseUrl);
const account = resolveAccount(opts.account);
const cfg = loadConfig();
const accountInfo = resolveSignalAccount({
cfg,
accountId: opts.accountId,
});
const baseUrl = opts.baseUrl?.trim() || accountInfo.baseUrl;
const account = opts.account?.trim() || accountInfo.config.account?.trim();
const target = parseTarget(to);
let message = text ?? "";
const maxBytes = opts.maxBytes ?? 8 * 1024 * 1024;
const maxBytes = (() => {
if (typeof opts.maxBytes === "number") return opts.maxBytes;
if (typeof accountInfo.config.mediaMaxMb === "number") {
return accountInfo.config.mediaMaxMb * 1024 * 1024;
}
if (typeof cfg.agent?.mediaMaxMb === "number") {
return cfg.agent.mediaMaxMb * 1024 * 1024;
}
return 8 * 1024 * 1024;
})();
let attachments: string[] | undefined;
if (opts.mediaUrl?.trim()) {

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -142,5 +142,25 @@ export function shortenHomeInString(input: string): string {
return input.split(home).join("~");
}
export function formatTerminalLink(
label: string,
url: string,
opts?: { fallback?: string; force?: boolean },
): string {
const esc = "\u001b";
const safeLabel = label.replaceAll(esc, "");
const safeUrl = url.replaceAll(esc, "");
const allow =
opts?.force === true
? true
: opts?.force === false
? false
: Boolean(process.stdout.isTTY);
if (!allow) {
return opts?.fallback ?? `${safeLabel} (${safeUrl})`;
}
return `\u001b]8;;${safeUrl}\u0007${safeLabel}\u001b]8;;\u0007`;
}
// Configuration root; can be overridden via CLAWDBOT_STATE_DIR.
export const CONFIG_DIR = resolveConfigDir();

View File

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