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

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" };
}