import { chunkMarkdownText } from "../../auto-reply/chunk.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { writeConfigFile } from "../../config/config.js"; import { shouldLogVerbose } from "../../globals.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { listTelegramAccountIds, type ResolvedTelegramAccount, resolveDefaultTelegramAccountId, resolveTelegramAccount, } from "../../telegram/accounts.js"; import { auditTelegramGroupMembership, collectTelegramUnmentionedGroupIds, } from "../../telegram/audit.js"; import { probeTelegram } from "../../telegram/probe.js"; import { sendMessageTelegram } from "../../telegram/send.js"; import { resolveTelegramToken } from "../../telegram/token.js"; import { getChatChannelMeta } from "../registry.js"; import { TelegramConfigSchema } from "../../config/zod-schema.providers-core.js"; import { telegramMessageActions } from "./actions/telegram.js"; import { buildChannelConfigSchema } from "./config-schema.js"; import { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "./config-helpers.js"; import { resolveTelegramGroupRequireMention } from "./group-mentions.js"; import { formatPairingApproveHint } from "./helpers.js"; import { normalizeTelegramMessagingTarget } from "./normalize-target.js"; import { telegramOnboardingAdapter } from "./onboarding/telegram.js"; import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; import { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, } from "./setup-helpers.js"; import { collectTelegramStatusIssues } from "./status-issues/telegram.js"; import type { ChannelPlugin } from "./types.js"; const meta = getChatChannelMeta("telegram"); function parseReplyToMessageId(replyToId?: string | null) { if (!replyToId) return undefined; const parsed = Number.parseInt(replyToId, 10); return Number.isFinite(parsed) ? parsed : undefined; } function parseThreadId(threadId?: string | number | null) { if (threadId == null) return undefined; if (typeof threadId === "number") { return Number.isFinite(threadId) ? Math.trunc(threadId) : undefined; } const trimmed = threadId.trim(); if (!trimmed) return undefined; const parsed = Number.parseInt(trimmed, 10); return Number.isFinite(parsed) ? parsed : undefined; } export const telegramPlugin: ChannelPlugin = { id: "telegram", meta: { ...meta, quickstartAllowFrom: true, }, onboarding: telegramOnboardingAdapter, pairing: { idLabel: "telegramUserId", normalizeAllowEntry: (entry) => entry.replace(/^(telegram|tg):/i, ""), notifyApproval: async ({ cfg, id }) => { const { token } = resolveTelegramToken(cfg); if (!token) throw new Error("telegram token not configured"); await sendMessageTelegram(id, PAIRING_APPROVED_MESSAGE, { token }); }, }, capabilities: { chatTypes: ["direct", "group", "channel", "thread"], reactions: true, threads: true, media: true, nativeCommands: true, blockStreaming: true, }, reload: { configPrefixes: ["channels.telegram"] }, configSchema: buildChannelConfigSchema(TelegramConfigSchema), config: { listAccountIds: (cfg) => listTelegramAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), defaultAccountId: (cfg) => resolveDefaultTelegramAccountId(cfg), setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({ cfg, sectionKey: "telegram", accountId, enabled, allowTopLevel: true, }), deleteAccount: ({ cfg, accountId }) => deleteAccountFromConfigSection({ cfg, sectionKey: "telegram", accountId, clearBaseFields: ["botToken", "tokenFile", "name"], }), isConfigured: (account) => Boolean(account.token?.trim()), describeAccount: (account) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, configured: Boolean(account.token?.trim()), tokenSource: account.tokenSource, }), resolveAllowFrom: ({ cfg, accountId }) => (resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => String(entry), ), formatAllowFrom: ({ allowFrom }) => allowFrom .map((entry) => String(entry).trim()) .filter(Boolean) .map((entry) => entry.replace(/^(telegram|tg):/i, "")) .map((entry) => entry.toLowerCase()), }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; const useAccountPath = Boolean(cfg.channels?.telegram?.accounts?.[resolvedAccountId]); const basePath = useAccountPath ? `channels.telegram.accounts.${resolvedAccountId}.` : "channels.telegram."; return { policy: account.config.dmPolicy ?? "pairing", allowFrom: account.config.allowFrom ?? [], policyPath: `${basePath}dmPolicy`, allowFromPath: basePath, approveHint: formatPairingApproveHint("telegram"), normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""), }; }, collectWarnings: ({ account }) => { const groupPolicy = account.config.groupPolicy ?? "allowlist"; if (groupPolicy !== "open") return []; const groupAllowlistConfigured = account.config.groups && Object.keys(account.config.groups).length > 0; if (groupAllowlistConfigured) { return [ `- Telegram groups: groupPolicy="open" allows any member in allowed groups to trigger (mention-gated). Set channels.telegram.groupPolicy="allowlist" + channels.telegram.groupAllowFrom to restrict senders.`, ]; } return [ `- Telegram groups: groupPolicy="open" with no channels.telegram.groups allowlist; any group can add + ping (mention-gated). Set channels.telegram.groupPolicy="allowlist" + channels.telegram.groupAllowFrom or configure channels.telegram.groups.`, ]; }, }, groups: { resolveRequireMention: resolveTelegramGroupRequireMention, }, threading: { resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "first", }, messaging: { normalizeTarget: normalizeTelegramMessagingTarget, }, directory: { self: async () => null, listPeers: async ({ cfg, accountId, query, limit }) => { const account = resolveTelegramAccount({ cfg, accountId }); const q = query?.trim().toLowerCase() || ""; const raw = [ ...(account.config.allowFrom ?? []).map((entry) => String(entry)), ...Object.keys(account.config.dms ?? {}), ]; const peers = Array.from( new Set( raw .map((entry) => entry.trim()) .filter(Boolean) .map((entry) => entry.replace(/^(telegram|tg):/i, "")), ), ) .map((entry) => { const trimmed = entry.trim(); if (!trimmed) return null; if (/^-?\d+$/.test(trimmed)) return trimmed; const withAt = trimmed.startsWith("@") ? trimmed : `@${trimmed}`; return withAt; }) .filter((id): id is string => Boolean(id)) .filter((id) => (q ? id.toLowerCase().includes(q) : true)) .slice(0, limit && limit > 0 ? limit : undefined) .map((id) => ({ kind: "user", id }) as const); return peers; }, listGroups: async ({ cfg, accountId, query, limit }) => { const account = resolveTelegramAccount({ cfg, accountId }); const q = query?.trim().toLowerCase() || ""; const groups = Object.keys(account.config.groups ?? {}) .map((id) => id.trim()) .filter((id) => Boolean(id) && id !== "*") .filter((id) => (q ? id.toLowerCase().includes(q) : true)) .slice(0, limit && limit > 0 ? limit : undefined) .map((id) => ({ kind: "group", id }) as const); return groups; }, }, actions: telegramMessageActions, setup: { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => applyAccountNameToChannelSection({ cfg, channelKey: "telegram", accountId, name, }), validateInput: ({ accountId, input }) => { if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { return "TELEGRAM_BOT_TOKEN can only be used for the default account."; } if (!input.useEnv && !input.token && !input.tokenFile) { return "Telegram requires targetken or --token-file (or --use-env)."; } return null; }, applyAccountConfig: ({ cfg, accountId, input }) => { const namedConfig = applyAccountNameToChannelSection({ cfg, channelKey: "telegram", accountId, name: input.name, }); const next = accountId !== DEFAULT_ACCOUNT_ID ? migrateBaseNameToDefaultAccount({ cfg: namedConfig, channelKey: "telegram", }) : namedConfig; if (accountId === DEFAULT_ACCOUNT_ID) { return { ...next, channels: { ...next.channels, telegram: { ...next.channels?.telegram, enabled: true, ...(input.useEnv ? {} : input.tokenFile ? { tokenFile: input.tokenFile } : input.token ? { botToken: input.token } : {}), }, }, }; } return { ...next, channels: { ...next.channels, telegram: { ...next.channels?.telegram, enabled: true, accounts: { ...next.channels?.telegram?.accounts, [accountId]: { ...next.channels?.telegram?.accounts?.[accountId], enabled: true, ...(input.tokenFile ? { tokenFile: input.tokenFile } : input.token ? { botToken: input.token } : {}), }, }, }, }, }; }, }, outbound: { deliveryMode: "direct", chunker: chunkMarkdownText, textChunkLimit: 4000, resolveTarget: ({ to }) => { const trimmed = to?.trim(); if (!trimmed) { return { ok: false, error: new Error("Delivering to Telegram requires target "), }; } return { ok: true, to: trimmed }; }, sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => { const send = deps?.sendTelegram ?? sendMessageTelegram; const replyToMessageId = parseReplyToMessageId(replyToId); const messageThreadId = parseThreadId(threadId); const result = await send(to, text, { verbose: false, messageThreadId, replyToMessageId, accountId: accountId ?? undefined, }); return { channel: "telegram", ...result }; }, sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId }) => { const send = deps?.sendTelegram ?? sendMessageTelegram; const replyToMessageId = parseReplyToMessageId(replyToId); const messageThreadId = parseThreadId(threadId); const result = await send(to, text, { verbose: false, mediaUrl, messageThreadId, replyToMessageId, accountId: accountId ?? undefined, }); return { channel: "telegram", ...result }; }, }, status: { defaultRuntime: { accountId: DEFAULT_ACCOUNT_ID, running: false, lastStartAt: null, lastStopAt: null, lastError: null, }, collectStatusIssues: collectTelegramStatusIssues, buildChannelSummary: ({ snapshot }) => ({ configured: snapshot.configured ?? false, tokenSource: snapshot.tokenSource ?? "none", running: snapshot.running ?? false, mode: snapshot.mode ?? null, lastStartAt: snapshot.lastStartAt ?? null, lastStopAt: snapshot.lastStopAt ?? null, lastError: snapshot.lastError ?? null, probe: snapshot.probe, lastProbeAt: snapshot.lastProbeAt ?? null, }), probeAccount: async ({ account, timeoutMs }) => probeTelegram(account.token, timeoutMs, account.config.proxy), auditAccount: async ({ account, timeoutMs, probe, cfg }) => { const groups = cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ?? cfg.channels?.telegram?.groups; const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } = collectTelegramUnmentionedGroupIds(groups); if (!groupIds.length && unresolvedGroups === 0 && !hasWildcardUnmentionedGroups) { return undefined; } const botId = (probe as { ok?: boolean; bot?: { id?: number } })?.ok && (probe as { bot?: { id?: number } }).bot?.id != null ? (probe as { bot: { id: number } }).bot.id : null; if (!botId) { return { ok: unresolvedGroups === 0 && !hasWildcardUnmentionedGroups, checkedGroups: 0, unresolvedGroups, hasWildcardUnmentionedGroups, groups: [], elapsedMs: 0, }; } const audit = await auditTelegramGroupMembership({ token: account.token, botId, groupIds, proxyUrl: account.config.proxy, timeoutMs, }); return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups }; }, buildAccountSnapshot: ({ account, cfg, runtime, probe, audit }) => { const configured = Boolean(account.token?.trim()); const groups = cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ?? cfg.channels?.telegram?.groups; const allowUnmentionedGroups = Boolean( groups?.["*"] && (groups["*"] as { requireMention?: boolean }).requireMention === false, ) || Object.entries(groups ?? {}).some( ([key, value]) => key !== "*" && Boolean(value) && typeof value === "object" && (value as { requireMention?: boolean }).requireMention === false, ); return { accountId: account.accountId, name: account.name, enabled: account.enabled, configured, tokenSource: account.tokenSource, running: runtime?.running ?? false, lastStartAt: runtime?.lastStartAt ?? null, lastStopAt: runtime?.lastStopAt ?? null, lastError: runtime?.lastError ?? null, mode: runtime?.mode ?? (account.config.webhookUrl ? "webhook" : "polling"), probe, audit, allowUnmentionedGroups, lastInboundAt: runtime?.lastInboundAt ?? null, lastOutboundAt: runtime?.lastOutboundAt ?? null, }; }, }, gateway: { startAccount: async (ctx) => { const account = ctx.account; const token = account.token.trim(); let telegramBotLabel = ""; try { const probe = await probeTelegram(token, 2500, account.config.proxy); const username = probe.ok ? probe.bot?.username?.trim() : null; if (username) telegramBotLabel = ` (@${username})`; } catch (err) { if (shouldLogVerbose()) { ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`); } } ctx.log?.info(`[${account.accountId}] starting provider${telegramBotLabel}`); // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. const { monitorTelegramProvider } = await import("../../telegram/monitor.js"); return monitorTelegramProvider({ token, accountId: account.accountId, config: ctx.cfg, runtime: ctx.runtime, abortSignal: ctx.abortSignal, useWebhook: Boolean(account.config.webhookUrl), webhookUrl: account.config.webhookUrl, webhookSecret: account.config.webhookSecret, webhookPath: account.config.webhookPath, }); }, logoutAccount: async ({ accountId, cfg }) => { const envToken = process.env.TELEGRAM_BOT_TOKEN?.trim() ?? ""; const nextCfg = { ...cfg } as ClawdbotConfig; const nextTelegram = cfg.channels?.telegram ? { ...cfg.channels.telegram } : undefined; let cleared = false; let changed = false; if (nextTelegram) { if (accountId === DEFAULT_ACCOUNT_ID && nextTelegram.botToken) { delete nextTelegram.botToken; cleared = true; changed = true; } const accounts = nextTelegram.accounts && typeof nextTelegram.accounts === "object" ? { ...nextTelegram.accounts } : undefined; if (accounts && accountId in accounts) { const entry = accounts[accountId]; if (entry && typeof entry === "object") { const nextEntry = { ...entry } as Record; if ("botToken" in nextEntry) { const token = nextEntry.botToken; if (typeof token === "string" ? token.trim() : token) { cleared = true; } delete nextEntry.botToken; changed = true; } if (Object.keys(nextEntry).length === 0) { delete accounts[accountId]; changed = true; } else { accounts[accountId] = nextEntry as typeof entry; } } } if (accounts) { if (Object.keys(accounts).length === 0) { delete nextTelegram.accounts; changed = true; } else { nextTelegram.accounts = accounts; } } } if (changed) { if (nextTelegram && Object.keys(nextTelegram).length > 0) { nextCfg.channels = { ...nextCfg.channels, telegram: nextTelegram }; } else { const nextChannels = { ...nextCfg.channels }; delete nextChannels.telegram; if (Object.keys(nextChannels).length > 0) { nextCfg.channels = nextChannels; } else { delete nextCfg.channels; } } } const resolved = resolveTelegramAccount({ cfg: changed ? nextCfg : cfg, accountId, }); const loggedOut = resolved.tokenSource === "none"; if (changed) { await writeConfigFile(nextCfg); } return { cleared, envToken: Boolean(envToken), loggedOut }; }, }, };