diff --git a/extensions/telegram/src/account-inspect.test.ts b/extensions/telegram/src/account-inspect.test.ts new file mode 100644 index 00000000000..5e58626ba03 --- /dev/null +++ b/extensions/telegram/src/account-inspect.test.ts @@ -0,0 +1,107 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { withEnv } from "../../../src/test-utils/env.js"; +import { inspectTelegramAccount } from "./account-inspect.js"; + +describe("inspectTelegramAccount SecretRef resolution", () => { + it("resolves default env SecretRef templates in read-only status paths", () => { + withEnv({ TG_STATUS_TOKEN: "123:token" }, () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + botToken: "${TG_STATUS_TOKEN}", + }, + }, + }; + + const account = inspectTelegramAccount({ cfg, accountId: "default" }); + expect(account.tokenSource).toBe("env"); + expect(account.tokenStatus).toBe("available"); + expect(account.token).toBe("123:token"); + }); + }); + + it("respects env provider allowlists in read-only status paths", () => { + withEnv({ TG_NOT_ALLOWED: "123:token" }, () => { + const cfg: OpenClawConfig = { + secrets: { + defaults: { + env: "secure-env", + }, + providers: { + "secure-env": { + source: "env", + allowlist: ["TG_ALLOWED"], + }, + }, + }, + channels: { + telegram: { + botToken: "${TG_NOT_ALLOWED}", + }, + }, + }; + + const account = inspectTelegramAccount({ cfg, accountId: "default" }); + expect(account.tokenSource).toBe("env"); + expect(account.tokenStatus).toBe("configured_unavailable"); + expect(account.token).toBe(""); + }); + }); + + it("does not read env values for non-env providers", () => { + withEnv({ TG_EXEC_PROVIDER: "123:token" }, () => { + const cfg: OpenClawConfig = { + secrets: { + defaults: { + env: "exec-provider", + }, + providers: { + "exec-provider": { + source: "exec", + command: "/usr/bin/env", + }, + }, + }, + channels: { + telegram: { + botToken: "${TG_EXEC_PROVIDER}", + }, + }, + }; + + const account = inspectTelegramAccount({ cfg, accountId: "default" }); + expect(account.tokenSource).toBe("env"); + expect(account.tokenStatus).toBe("configured_unavailable"); + expect(account.token).toBe(""); + }); + }); + + it.runIf(process.platform !== "win32")( + "treats symlinked token files as configured_unavailable", + () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-inspect-")); + const tokenFile = path.join(dir, "token.txt"); + const tokenLink = path.join(dir, "token-link.txt"); + fs.writeFileSync(tokenFile, "123:token\n", "utf8"); + fs.symlinkSync(tokenFile, tokenLink); + + const cfg: OpenClawConfig = { + channels: { + telegram: { + tokenFile: tokenLink, + }, + }, + }; + + const account = inspectTelegramAccount({ cfg, accountId: "default" }); + expect(account.tokenSource).toBe("tokenFile"); + expect(account.tokenStatus).toBe("configured_unavailable"); + expect(account.token).toBe(""); + fs.rmSync(dir, { recursive: true, force: true }); + }, + ); +}); diff --git a/extensions/telegram/src/account-inspect.ts b/extensions/telegram/src/account-inspect.ts new file mode 100644 index 00000000000..8014df80080 --- /dev/null +++ b/extensions/telegram/src/account-inspect.ts @@ -0,0 +1,232 @@ +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { + coerceSecretRef, + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "../../../src/config/types.secrets.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.telegram.js"; +import { tryReadSecretFileSync } from "../../../src/infra/secret-file.js"; +import { resolveAccountWithDefaultFallback } from "../../../src/plugin-sdk/account-resolution.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { resolveDefaultSecretProviderAlias } from "../../../src/secrets/ref-contract.js"; +import { + mergeTelegramAccountConfig, + resolveDefaultTelegramAccountId, + resolveTelegramAccountConfig, +} from "./accounts.js"; + +export type TelegramCredentialStatus = "available" | "configured_unavailable" | "missing"; + +export type InspectedTelegramAccount = { + accountId: string; + enabled: boolean; + name?: string; + token: string; + tokenSource: "env" | "tokenFile" | "config" | "none"; + tokenStatus: TelegramCredentialStatus; + configured: boolean; + config: TelegramAccountConfig; +}; + +function inspectTokenFile(pathValue: unknown): { + token: string; + tokenSource: "tokenFile" | "none"; + tokenStatus: TelegramCredentialStatus; +} | null { + const tokenFile = typeof pathValue === "string" ? pathValue.trim() : ""; + if (!tokenFile) { + return null; + } + const token = tryReadSecretFileSync(tokenFile, "Telegram bot token", { + rejectSymlink: true, + }); + return { + token: token ?? "", + tokenSource: "tokenFile", + tokenStatus: token ? "available" : "configured_unavailable", + }; +} + +function canResolveEnvSecretRefInReadOnlyPath(params: { + cfg: OpenClawConfig; + provider: string; + id: string; +}): boolean { + const providerConfig = params.cfg.secrets?.providers?.[params.provider]; + if (!providerConfig) { + return params.provider === resolveDefaultSecretProviderAlias(params.cfg, "env"); + } + if (providerConfig.source !== "env") { + return false; + } + const allowlist = providerConfig.allowlist; + return !allowlist || allowlist.includes(params.id); +} + +function inspectTokenValue(params: { cfg: OpenClawConfig; value: unknown }): { + token: string; + tokenSource: "config" | "env" | "none"; + tokenStatus: TelegramCredentialStatus; +} | null { + // Try to resolve env-based SecretRefs from process.env for read-only inspection + const ref = coerceSecretRef(params.value, params.cfg.secrets?.defaults); + if (ref?.source === "env") { + if ( + !canResolveEnvSecretRefInReadOnlyPath({ + cfg: params.cfg, + provider: ref.provider, + id: ref.id, + }) + ) { + return { + token: "", + tokenSource: "env", + tokenStatus: "configured_unavailable", + }; + } + const envValue = process.env[ref.id]; + if (envValue && envValue.trim()) { + return { + token: envValue.trim(), + tokenSource: "env", + tokenStatus: "available", + }; + } + return { + token: "", + tokenSource: "env", + tokenStatus: "configured_unavailable", + }; + } + const token = normalizeSecretInputString(params.value); + if (token) { + return { + token, + tokenSource: "config", + tokenStatus: "available", + }; + } + if (hasConfiguredSecretInput(params.value, params.cfg.secrets?.defaults)) { + return { + token: "", + tokenSource: "config", + tokenStatus: "configured_unavailable", + }; + } + return null; +} + +function inspectTelegramAccountPrimary(params: { + cfg: OpenClawConfig; + accountId: string; + envToken?: string | null; +}): InspectedTelegramAccount { + const accountId = normalizeAccountId(params.accountId); + const merged = mergeTelegramAccountConfig(params.cfg, accountId); + const enabled = params.cfg.channels?.telegram?.enabled !== false && merged.enabled !== false; + + const accountConfig = resolveTelegramAccountConfig(params.cfg, accountId); + const accountTokenFile = inspectTokenFile(accountConfig?.tokenFile); + if (accountTokenFile) { + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: accountTokenFile.token, + tokenSource: accountTokenFile.tokenSource, + tokenStatus: accountTokenFile.tokenStatus, + configured: accountTokenFile.tokenStatus !== "missing", + config: merged, + }; + } + + const accountToken = inspectTokenValue({ cfg: params.cfg, value: accountConfig?.botToken }); + if (accountToken) { + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: accountToken.token, + tokenSource: accountToken.tokenSource, + tokenStatus: accountToken.tokenStatus, + configured: accountToken.tokenStatus !== "missing", + config: merged, + }; + } + + const channelTokenFile = inspectTokenFile(params.cfg.channels?.telegram?.tokenFile); + if (channelTokenFile) { + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: channelTokenFile.token, + tokenSource: channelTokenFile.tokenSource, + tokenStatus: channelTokenFile.tokenStatus, + configured: channelTokenFile.tokenStatus !== "missing", + config: merged, + }; + } + + const channelToken = inspectTokenValue({ + cfg: params.cfg, + value: params.cfg.channels?.telegram?.botToken, + }); + if (channelToken) { + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: channelToken.token, + tokenSource: channelToken.tokenSource, + tokenStatus: channelToken.tokenStatus, + configured: channelToken.tokenStatus !== "missing", + config: merged, + }; + } + + const allowEnv = accountId === DEFAULT_ACCOUNT_ID; + const envToken = allowEnv ? (params.envToken ?? process.env.TELEGRAM_BOT_TOKEN)?.trim() : ""; + if (envToken) { + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: envToken, + tokenSource: "env", + tokenStatus: "available", + configured: true, + config: merged, + }; + } + + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: "", + tokenSource: "none", + tokenStatus: "missing", + configured: false, + config: merged, + }; +} + +export function inspectTelegramAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; + envToken?: string | null; +}): InspectedTelegramAccount { + return resolveAccountWithDefaultFallback({ + accountId: params.accountId, + normalizeAccountId, + resolvePrimary: (accountId) => + inspectTelegramAccountPrimary({ + cfg: params.cfg, + accountId, + envToken: params.envToken, + }), + hasCredential: (account) => account.tokenSource !== "none", + resolveDefaultAccountId: () => resolveDefaultTelegramAccountId(params.cfg), + }); +} diff --git a/src/telegram/accounts.test.ts b/extensions/telegram/src/accounts.test.ts similarity index 98% rename from src/telegram/accounts.test.ts rename to extensions/telegram/src/accounts.test.ts index fad5e0a63a5..28af65a5d8a 100644 --- a/src/telegram/accounts.test.ts +++ b/extensions/telegram/src/accounts.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { withEnv } from "../test-utils/env.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { withEnv } from "../../../src/test-utils/env.js"; import { listTelegramAccountIds, resetMissingDefaultWarnFlag, @@ -29,7 +29,7 @@ function resolveAccountWithEnv( return withEnv(env, () => resolveTelegramAccount({ cfg, ...(accountId ? { accountId } : {}) })); } -vi.mock("../logging/subsystem.js", () => ({ +vi.mock("../../../src/logging/subsystem.js", () => ({ createSubsystemLogger: () => { const logger = { warn: warnMock, diff --git a/extensions/telegram/src/accounts.ts b/extensions/telegram/src/accounts.ts new file mode 100644 index 00000000000..71d78590488 --- /dev/null +++ b/extensions/telegram/src/accounts.ts @@ -0,0 +1,211 @@ +import util from "node:util"; +import { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramAccountConfig, TelegramActionConfig } from "../../../src/config/types.js"; +import { isTruthyEnvValue } from "../../../src/infra/env.js"; +import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; +import { + listConfiguredAccountIds as listConfiguredAccountIdsFromSection, + resolveAccountWithDefaultFallback, +} from "../../../src/plugin-sdk/account-resolution.js"; +import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; +import { + listBoundAccountIds, + resolveDefaultAgentBoundAccountId, +} from "../../../src/routing/bindings.js"; +import { formatSetExplicitDefaultInstruction } from "../../../src/routing/default-account-warnings.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + normalizeOptionalAccountId, +} from "../../../src/routing/session-key.js"; +import { resolveTelegramToken } from "./token.js"; + +const log = createSubsystemLogger("telegram/accounts"); + +function formatDebugArg(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (value instanceof Error) { + return value.stack ?? value.message; + } + return util.inspect(value, { colors: false, depth: null, compact: true, breakLength: Infinity }); +} + +const debugAccounts = (...args: unknown[]) => { + if (isTruthyEnvValue(process.env.OPENCLAW_DEBUG_TELEGRAM_ACCOUNTS)) { + const parts = args.map((arg) => formatDebugArg(arg)); + log.warn(parts.join(" ").trim()); + } +}; + +export type ResolvedTelegramAccount = { + accountId: string; + enabled: boolean; + name?: string; + token: string; + tokenSource: "env" | "tokenFile" | "config" | "none"; + config: TelegramAccountConfig; +}; + +function listConfiguredAccountIds(cfg: OpenClawConfig): string[] { + return listConfiguredAccountIdsFromSection({ + accounts: cfg.channels?.telegram?.accounts, + normalizeAccountId, + }); +} + +export function listTelegramAccountIds(cfg: OpenClawConfig): string[] { + const ids = Array.from( + new Set([...listConfiguredAccountIds(cfg), ...listBoundAccountIds(cfg, "telegram")]), + ); + debugAccounts("listTelegramAccountIds", ids); + if (ids.length === 0) { + return [DEFAULT_ACCOUNT_ID]; + } + return ids.toSorted((a, b) => a.localeCompare(b)); +} + +let emittedMissingDefaultWarn = false; + +/** @internal Reset the once-per-process warning flag. Exported for tests only. */ +export function resetMissingDefaultWarnFlag(): void { + emittedMissingDefaultWarn = false; +} + +export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string { + const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram"); + if (boundDefault) { + return boundDefault; + } + const preferred = normalizeOptionalAccountId(cfg.channels?.telegram?.defaultAccount); + if ( + preferred && + listTelegramAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred) + ) { + return preferred; + } + const ids = listTelegramAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) { + return DEFAULT_ACCOUNT_ID; + } + if (ids.length > 1 && !emittedMissingDefaultWarn) { + emittedMissingDefaultWarn = true; + log.warn( + `channels.telegram: accounts.default is missing; falling back to "${ids[0]}". ` + + `${formatSetExplicitDefaultInstruction("telegram")} to avoid routing surprises in multi-account setups.`, + ); + } + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +export function resolveTelegramAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): TelegramAccountConfig | undefined { + const normalized = normalizeAccountId(accountId); + return resolveAccountEntry(cfg.channels?.telegram?.accounts, normalized); +} + +export function mergeTelegramAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): TelegramAccountConfig { + const { + accounts: _ignored, + defaultAccount: _ignoredDefaultAccount, + groups: channelGroups, + ...base + } = (cfg.channels?.telegram ?? {}) as TelegramAccountConfig & { + accounts?: unknown; + defaultAccount?: unknown; + }; + const account = resolveTelegramAccountConfig(cfg, accountId) ?? {}; + + // In multi-account setups, channel-level `groups` must NOT be inherited by + // accounts that don't have their own `groups` config. A bot that is not a + // member of a configured group will fail when handling group messages, and + // this failure disrupts message delivery for *all* accounts. + // Single-account setups keep backward compat: channel-level groups still + // applies when the account has no override. + // See: https://github.com/openclaw/openclaw/issues/30673 + const configuredAccountIds = Object.keys(cfg.channels?.telegram?.accounts ?? {}); + const isMultiAccount = configuredAccountIds.length > 1; + const groups = account.groups ?? (isMultiAccount ? undefined : channelGroups); + + return { ...base, ...account, groups }; +} + +export function createTelegramActionGate(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): (key: keyof TelegramActionConfig, defaultValue?: boolean) => boolean { + const accountId = normalizeAccountId(params.accountId); + return createAccountActionGate({ + baseActions: params.cfg.channels?.telegram?.actions, + accountActions: resolveTelegramAccountConfig(params.cfg, accountId)?.actions, + }); +} + +export type TelegramPollActionGateState = { + sendMessageEnabled: boolean; + pollEnabled: boolean; + enabled: boolean; +}; + +export function resolveTelegramPollActionGateState( + isActionEnabled: (key: keyof TelegramActionConfig, defaultValue?: boolean) => boolean, +): TelegramPollActionGateState { + const sendMessageEnabled = isActionEnabled("sendMessage"); + const pollEnabled = isActionEnabled("poll"); + return { + sendMessageEnabled, + pollEnabled, + enabled: sendMessageEnabled && pollEnabled, + }; +} + +export function resolveTelegramAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): ResolvedTelegramAccount { + const baseEnabled = params.cfg.channels?.telegram?.enabled !== false; + + const resolve = (accountId: string) => { + const merged = mergeTelegramAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + const tokenResolution = resolveTelegramToken(params.cfg, { accountId }); + debugAccounts("resolve", { + accountId, + enabled, + tokenSource: tokenResolution.source, + }); + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: tokenResolution.token, + tokenSource: tokenResolution.source, + config: merged, + } satisfies ResolvedTelegramAccount; + }; + + // If accountId is omitted, prefer a configured account token over failing on + // the implicit "default" account. This keeps env-based setups working while + // making config-only tokens work for things like heartbeats. + return resolveAccountWithDefaultFallback({ + accountId: params.accountId, + normalizeAccountId, + resolvePrimary: resolve, + hasCredential: (account) => account.tokenSource !== "none", + resolveDefaultAccountId: () => resolveDefaultTelegramAccountId(params.cfg), + }); +} + +export function listEnabledTelegramAccounts(cfg: OpenClawConfig): ResolvedTelegramAccount[] { + return listTelegramAccountIds(cfg) + .map((accountId) => resolveTelegramAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/extensions/telegram/src/allowed-updates.ts b/extensions/telegram/src/allowed-updates.ts new file mode 100644 index 00000000000..a081373e810 --- /dev/null +++ b/extensions/telegram/src/allowed-updates.ts @@ -0,0 +1,14 @@ +import { API_CONSTANTS } from "grammy"; + +type TelegramUpdateType = (typeof API_CONSTANTS.ALL_UPDATE_TYPES)[number]; + +export function resolveTelegramAllowedUpdates(): ReadonlyArray { + const updates = [...API_CONSTANTS.DEFAULT_UPDATE_TYPES] as TelegramUpdateType[]; + if (!updates.includes("message_reaction")) { + updates.push("message_reaction"); + } + if (!updates.includes("channel_post")) { + updates.push("channel_post"); + } + return updates; +} diff --git a/extensions/telegram/src/api-logging.ts b/extensions/telegram/src/api-logging.ts new file mode 100644 index 00000000000..6af9d7ae5a3 --- /dev/null +++ b/extensions/telegram/src/api-logging.ts @@ -0,0 +1,45 @@ +import { danger } from "../../../src/globals.js"; +import { formatErrorMessage } from "../../../src/infra/errors.js"; +import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; + +export type TelegramApiLogger = (message: string) => void; + +type TelegramApiLoggingParams = { + operation: string; + fn: () => Promise; + runtime?: RuntimeEnv; + logger?: TelegramApiLogger; + shouldLog?: (err: unknown) => boolean; +}; + +const fallbackLogger = createSubsystemLogger("telegram/api"); + +function resolveTelegramApiLogger(runtime?: RuntimeEnv, logger?: TelegramApiLogger) { + if (logger) { + return logger; + } + if (runtime?.error) { + return runtime.error; + } + return (message: string) => fallbackLogger.error(message); +} + +export async function withTelegramApiErrorLogging({ + operation, + fn, + runtime, + logger, + shouldLog, +}: TelegramApiLoggingParams): Promise { + try { + return await fn(); + } catch (err) { + if (!shouldLog || shouldLog(err)) { + const errText = formatErrorMessage(err); + const log = resolveTelegramApiLogger(runtime, logger); + log(danger(`telegram ${operation} failed: ${errText}`)); + } + throw err; + } +} diff --git a/extensions/telegram/src/approval-buttons.test.ts b/extensions/telegram/src/approval-buttons.test.ts new file mode 100644 index 00000000000..bc6fac49e07 --- /dev/null +++ b/extensions/telegram/src/approval-buttons.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { buildTelegramExecApprovalButtons } from "./approval-buttons.js"; + +describe("telegram approval buttons", () => { + it("builds allow-once/allow-always/deny buttons", () => { + expect(buildTelegramExecApprovalButtons("fbd8daf7")).toEqual([ + [ + { text: "Allow Once", callback_data: "/approve fbd8daf7 allow-once" }, + { text: "Allow Always", callback_data: "/approve fbd8daf7 allow-always" }, + ], + [{ text: "Deny", callback_data: "/approve fbd8daf7 deny" }], + ]); + }); + + it("skips buttons when callback_data exceeds Telegram limit", () => { + expect(buildTelegramExecApprovalButtons(`a${"b".repeat(60)}`)).toBeUndefined(); + }); +}); diff --git a/extensions/telegram/src/approval-buttons.ts b/extensions/telegram/src/approval-buttons.ts new file mode 100644 index 00000000000..a996ed3adf3 --- /dev/null +++ b/extensions/telegram/src/approval-buttons.ts @@ -0,0 +1,42 @@ +import type { ExecApprovalReplyDecision } from "../../../src/infra/exec-approval-reply.js"; +import type { TelegramInlineButtons } from "./button-types.js"; + +const MAX_CALLBACK_DATA_BYTES = 64; + +function fitsCallbackData(value: string): boolean { + return Buffer.byteLength(value, "utf8") <= MAX_CALLBACK_DATA_BYTES; +} + +export function buildTelegramExecApprovalButtons( + approvalId: string, +): TelegramInlineButtons | undefined { + return buildTelegramExecApprovalButtonsForDecisions(approvalId, [ + "allow-once", + "allow-always", + "deny", + ]); +} + +function buildTelegramExecApprovalButtonsForDecisions( + approvalId: string, + allowedDecisions: readonly ExecApprovalReplyDecision[], +): TelegramInlineButtons | undefined { + const allowOnce = `/approve ${approvalId} allow-once`; + if (!allowedDecisions.includes("allow-once") || !fitsCallbackData(allowOnce)) { + return undefined; + } + + const primaryRow: Array<{ text: string; callback_data: string }> = [ + { text: "Allow Once", callback_data: allowOnce }, + ]; + const allowAlways = `/approve ${approvalId} allow-always`; + if (allowedDecisions.includes("allow-always") && fitsCallbackData(allowAlways)) { + primaryRow.push({ text: "Allow Always", callback_data: allowAlways }); + } + const rows: Array> = [primaryRow]; + const deny = `/approve ${approvalId} deny`; + if (allowedDecisions.includes("deny") && fitsCallbackData(deny)) { + rows.push([{ text: "Deny", callback_data: deny }]); + } + return rows; +} diff --git a/extensions/telegram/src/audit-membership-runtime.ts b/extensions/telegram/src/audit-membership-runtime.ts new file mode 100644 index 00000000000..694ad338c5b --- /dev/null +++ b/extensions/telegram/src/audit-membership-runtime.ts @@ -0,0 +1,76 @@ +import { isRecord } from "../../../src/utils.js"; +import { fetchWithTimeout } from "../../../src/utils/fetch-timeout.js"; +import type { + AuditTelegramGroupMembershipParams, + TelegramGroupMembershipAudit, + TelegramGroupMembershipAuditEntry, +} from "./audit.js"; +import { resolveTelegramFetch } from "./fetch.js"; +import { makeProxyFetch } from "./proxy.js"; + +const TELEGRAM_API_BASE = "https://api.telegram.org"; + +type TelegramApiOk = { ok: true; result: T }; +type TelegramApiErr = { ok: false; description?: string }; +type TelegramGroupMembershipAuditData = Omit; + +export async function auditTelegramGroupMembershipImpl( + params: AuditTelegramGroupMembershipParams, +): Promise { + const proxyFetch = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : undefined; + const fetcher = resolveTelegramFetch(proxyFetch, { network: params.network }); + const base = `${TELEGRAM_API_BASE}/bot${params.token}`; + const groups: TelegramGroupMembershipAuditEntry[] = []; + + for (const chatId of params.groupIds) { + try { + const url = `${base}/getChatMember?chat_id=${encodeURIComponent(chatId)}&user_id=${encodeURIComponent(String(params.botId))}`; + const res = await fetchWithTimeout(url, {}, params.timeoutMs, fetcher); + const json = (await res.json()) as TelegramApiOk<{ status?: string }> | TelegramApiErr; + if (!res.ok || !isRecord(json) || !json.ok) { + const desc = + isRecord(json) && !json.ok && typeof json.description === "string" + ? json.description + : `getChatMember failed (${res.status})`; + groups.push({ + chatId, + ok: false, + status: null, + error: desc, + matchKey: chatId, + matchSource: "id", + }); + continue; + } + const status = isRecord((json as TelegramApiOk).result) + ? ((json as TelegramApiOk<{ status?: string }>).result.status ?? null) + : null; + const ok = status === "creator" || status === "administrator" || status === "member"; + groups.push({ + chatId, + ok, + status, + error: ok ? null : "bot not in group", + matchKey: chatId, + matchSource: "id", + }); + } catch (err) { + groups.push({ + chatId, + ok: false, + status: null, + error: err instanceof Error ? err.message : String(err), + matchKey: chatId, + matchSource: "id", + }); + } + } + + return { + ok: groups.every((g) => g.ok), + checkedGroups: groups.length, + unresolvedGroups: 0, + hasWildcardUnmentionedGroups: false, + groups, + }; +} diff --git a/src/telegram/audit.test.ts b/extensions/telegram/src/audit.test.ts similarity index 100% rename from src/telegram/audit.test.ts rename to extensions/telegram/src/audit.test.ts diff --git a/extensions/telegram/src/audit.ts b/extensions/telegram/src/audit.ts new file mode 100644 index 00000000000..507f161edca --- /dev/null +++ b/extensions/telegram/src/audit.ts @@ -0,0 +1,107 @@ +import type { TelegramGroupConfig } from "../../../src/config/types.js"; +import type { TelegramNetworkConfig } from "../../../src/config/types.telegram.js"; + +export type TelegramGroupMembershipAuditEntry = { + chatId: string; + ok: boolean; + status?: string | null; + error?: string | null; + matchKey?: string; + matchSource?: "id"; +}; + +export type TelegramGroupMembershipAudit = { + ok: boolean; + checkedGroups: number; + unresolvedGroups: number; + hasWildcardUnmentionedGroups: boolean; + groups: TelegramGroupMembershipAuditEntry[]; + elapsedMs: number; +}; + +export function collectTelegramUnmentionedGroupIds( + groups: Record | undefined, +) { + if (!groups || typeof groups !== "object") { + return { + groupIds: [] as string[], + unresolvedGroups: 0, + hasWildcardUnmentionedGroups: false, + }; + } + const hasWildcardUnmentionedGroups = + Boolean(groups["*"]?.requireMention === false) && groups["*"]?.enabled !== false; + const groupIds: string[] = []; + let unresolvedGroups = 0; + for (const [key, value] of Object.entries(groups)) { + if (key === "*") { + continue; + } + if (!value || typeof value !== "object") { + continue; + } + if (value.enabled === false) { + continue; + } + if (value.requireMention !== false) { + continue; + } + const id = String(key).trim(); + if (!id) { + continue; + } + if (/^-?\d+$/.test(id)) { + groupIds.push(id); + } else { + unresolvedGroups += 1; + } + } + groupIds.sort((a, b) => a.localeCompare(b)); + return { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups }; +} + +export type AuditTelegramGroupMembershipParams = { + token: string; + botId: number; + groupIds: string[]; + proxyUrl?: string; + network?: TelegramNetworkConfig; + timeoutMs: number; +}; + +let auditMembershipRuntimePromise: Promise | null = + null; + +function loadAuditMembershipRuntime() { + auditMembershipRuntimePromise ??= import("./audit-membership-runtime.js"); + return auditMembershipRuntimePromise; +} + +export async function auditTelegramGroupMembership( + params: AuditTelegramGroupMembershipParams, +): Promise { + const started = Date.now(); + const token = params.token?.trim() ?? ""; + if (!token || params.groupIds.length === 0) { + return { + ok: true, + checkedGroups: 0, + unresolvedGroups: 0, + hasWildcardUnmentionedGroups: false, + groups: [], + elapsedMs: Date.now() - started, + }; + } + + // Lazy import to avoid pulling `undici` (ProxyAgent) into cold-path callers that only need + // `collectTelegramUnmentionedGroupIds` (e.g. config audits). + const { auditTelegramGroupMembershipImpl } = await loadAuditMembershipRuntime(); + const result = await auditTelegramGroupMembershipImpl({ + ...params, + token, + }); + return { + ...result, + elapsedMs: Date.now() - started, + }; +} diff --git a/extensions/telegram/src/bot-access.test.ts b/extensions/telegram/src/bot-access.test.ts new file mode 100644 index 00000000000..4d147a420b7 --- /dev/null +++ b/extensions/telegram/src/bot-access.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { normalizeAllowFrom } from "./bot-access.js"; + +describe("normalizeAllowFrom", () => { + it("accepts sender IDs and keeps negative chat IDs invalid", () => { + const result = normalizeAllowFrom(["-1001234567890", " tg:-100999 ", "745123456", "@someone"]); + + expect(result).toEqual({ + entries: ["745123456"], + hasWildcard: false, + hasEntries: true, + invalidEntries: ["-1001234567890", "-100999", "@someone"], + }); + }); +}); diff --git a/extensions/telegram/src/bot-access.ts b/extensions/telegram/src/bot-access.ts new file mode 100644 index 00000000000..57b242afc3d --- /dev/null +++ b/extensions/telegram/src/bot-access.ts @@ -0,0 +1,94 @@ +import { + firstDefined, + isSenderIdAllowed, + mergeDmAllowFromSources, +} from "../../../src/channels/allow-from.js"; +import type { AllowlistMatch } from "../../../src/channels/allowlist-match.js"; +import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; + +export type NormalizedAllowFrom = { + entries: string[]; + hasWildcard: boolean; + hasEntries: boolean; + invalidEntries: string[]; +}; + +export type AllowFromMatch = AllowlistMatch<"wildcard" | "id">; + +const warnedInvalidEntries = new Set(); +const log = createSubsystemLogger("telegram/bot-access"); + +function warnInvalidAllowFromEntries(entries: string[]) { + if (process.env.VITEST || process.env.NODE_ENV === "test") { + return; + } + for (const entry of entries) { + if (warnedInvalidEntries.has(entry)) { + continue; + } + warnedInvalidEntries.add(entry); + log.warn( + [ + "Invalid allowFrom entry:", + JSON.stringify(entry), + "- allowFrom/groupAllowFrom authorization expects numeric Telegram sender user IDs only.", + 'To allow a Telegram group or supergroup, add its negative chat ID under "channels.telegram.groups" instead.', + 'If you had "@username" entries, re-run onboarding (it resolves @username to IDs) or replace them manually.', + ].join(" "), + ); + } +} + +export const normalizeAllowFrom = (list?: Array): NormalizedAllowFrom => { + const entries = (list ?? []).map((value) => String(value).trim()).filter(Boolean); + const hasWildcard = entries.includes("*"); + const normalized = entries + .filter((value) => value !== "*") + .map((value) => value.replace(/^(telegram|tg):/i, "")); + const invalidEntries = normalized.filter((value) => !/^\d+$/.test(value)); + if (invalidEntries.length > 0) { + warnInvalidAllowFromEntries([...new Set(invalidEntries)]); + } + const ids = normalized.filter((value) => /^\d+$/.test(value)); + return { + entries: ids, + hasWildcard, + hasEntries: entries.length > 0, + invalidEntries, + }; +}; + +export const normalizeDmAllowFromWithStore = (params: { + allowFrom?: Array; + storeAllowFrom?: string[]; + dmPolicy?: string; +}): NormalizedAllowFrom => normalizeAllowFrom(mergeDmAllowFromSources(params)); + +export const isSenderAllowed = (params: { + allow: NormalizedAllowFrom; + senderId?: string; + senderUsername?: string; +}) => { + const { allow, senderId } = params; + return isSenderIdAllowed(allow, senderId, true); +}; + +export { firstDefined }; + +export const resolveSenderAllowMatch = (params: { + allow: NormalizedAllowFrom; + senderId?: string; + senderUsername?: string; +}): AllowFromMatch => { + const { allow, senderId } = params; + if (allow.hasWildcard) { + return { allowed: true, matchKey: "*", matchSource: "wildcard" }; + } + if (!allow.hasEntries) { + return { allowed: false }; + } + if (senderId && allow.entries.includes(senderId)) { + return { allowed: true, matchKey: senderId, matchSource: "id" }; + } + return { allowed: false }; +}; diff --git a/extensions/telegram/src/bot-handlers.ts b/extensions/telegram/src/bot-handlers.ts new file mode 100644 index 00000000000..295c4092ec6 --- /dev/null +++ b/extensions/telegram/src/bot-handlers.ts @@ -0,0 +1,1679 @@ +import type { Message, ReactionTypeEmoji } from "@grammyjs/types"; +import { resolveAgentDir, resolveDefaultAgentId } from "../../../src/agents/agent-scope.js"; +import { resolveDefaultModelForAgent } from "../../../src/agents/model-selection.js"; +import { + createInboundDebouncer, + resolveInboundDebounceMs, +} from "../../../src/auto-reply/inbound-debounce.js"; +import { buildCommandsPaginationKeyboard } from "../../../src/auto-reply/reply/commands-info.js"; +import { + buildModelsProviderData, + formatModelsAvailableHeader, +} from "../../../src/auto-reply/reply/commands-models.js"; +import { resolveStoredModelOverride } from "../../../src/auto-reply/reply/model-selection.js"; +import { listSkillCommandsForAgents } from "../../../src/auto-reply/skill-commands.js"; +import { buildCommandsMessagePaginated } from "../../../src/auto-reply/status.js"; +import { shouldDebounceTextInbound } from "../../../src/channels/inbound-debounce-policy.js"; +import { resolveChannelConfigWrites } from "../../../src/channels/plugins/config-writes.js"; +import { loadConfig } from "../../../src/config/config.js"; +import { writeConfigFile } from "../../../src/config/io.js"; +import { + loadSessionStore, + resolveSessionStoreEntry, + resolveStorePath, + updateSessionStore, +} from "../../../src/config/sessions.js"; +import type { DmPolicy } from "../../../src/config/types.base.js"; +import type { + TelegramDirectConfig, + TelegramGroupConfig, + TelegramTopicConfig, +} from "../../../src/config/types.js"; +import { danger, logVerbose, warn } from "../../../src/globals.js"; +import { enqueueSystemEvent } from "../../../src/infra/system-events.js"; +import { MediaFetchError } from "../../../src/media/fetch.js"; +import { readChannelAllowFromStore } from "../../../src/pairing/pairing-store.js"; +import { resolveAgentRoute } from "../../../src/routing/resolve-route.js"; +import { resolveThreadSessionKeys } from "../../../src/routing/session-key.js"; +import { applyModelOverrideToSessionEntry } from "../../../src/sessions/model-overrides.js"; +import { withTelegramApiErrorLogging } from "./api-logging.js"; +import { + isSenderAllowed, + normalizeDmAllowFromWithStore, + type NormalizedAllowFrom, +} from "./bot-access.js"; +import type { TelegramMediaRef } from "./bot-message-context.js"; +import { RegisterTelegramHandlerParams } from "./bot-native-commands.js"; +import { + MEDIA_GROUP_TIMEOUT_MS, + type MediaGroupEntry, + type TelegramUpdateKeyContext, +} from "./bot-updates.js"; +import { resolveMedia } from "./bot/delivery.js"; +import { + getTelegramTextParts, + buildTelegramGroupPeerId, + buildTelegramParentPeer, + resolveTelegramForumThreadId, + resolveTelegramGroupAllowFromContext, +} from "./bot/helpers.js"; +import type { TelegramContext } from "./bot/types.js"; +import { resolveTelegramConversationRoute } from "./conversation-route.js"; +import { enforceTelegramDmAccess } from "./dm-access.js"; +import { + isTelegramExecApprovalApprover, + isTelegramExecApprovalClientEnabled, + shouldEnableTelegramExecApprovalButtons, +} from "./exec-approvals.js"; +import { + evaluateTelegramGroupBaseAccess, + evaluateTelegramGroupPolicyAccess, +} from "./group-access.js"; +import { migrateTelegramGroupConfig } from "./group-migration.js"; +import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js"; +import { + buildModelsKeyboard, + buildProviderKeyboard, + calculateTotalPages, + getModelsPageSize, + parseModelCallbackData, + resolveModelSelection, + type ProviderInfo, +} from "./model-buttons.js"; +import { buildInlineKeyboard } from "./send.js"; +import { wasSentByBot } from "./sent-message-cache.js"; + +const APPROVE_CALLBACK_DATA_RE = + /^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(allow-once|allow-always|deny)\b/i; + +function isMediaSizeLimitError(err: unknown): boolean { + const errMsg = String(err); + return errMsg.includes("exceeds") && errMsg.includes("MB limit"); +} + +function isRecoverableMediaGroupError(err: unknown): boolean { + return err instanceof MediaFetchError || isMediaSizeLimitError(err); +} + +function hasInboundMedia(msg: Message): boolean { + return ( + Boolean(msg.media_group_id) || + (Array.isArray(msg.photo) && msg.photo.length > 0) || + Boolean(msg.video ?? msg.video_note ?? msg.document ?? msg.audio ?? msg.voice ?? msg.sticker) + ); +} + +function hasReplyTargetMedia(msg: Message): boolean { + const externalReply = (msg as Message & { external_reply?: Message }).external_reply; + const replyTarget = msg.reply_to_message ?? externalReply; + return Boolean(replyTarget && hasInboundMedia(replyTarget)); +} + +function resolveInboundMediaFileId(msg: Message): string | undefined { + return ( + msg.sticker?.file_id ?? + msg.photo?.[msg.photo.length - 1]?.file_id ?? + msg.video?.file_id ?? + msg.video_note?.file_id ?? + msg.document?.file_id ?? + msg.audio?.file_id ?? + msg.voice?.file_id + ); +} + +export const registerTelegramHandlers = ({ + cfg, + accountId, + bot, + opts, + telegramTransport, + runtime, + mediaMaxBytes, + telegramCfg, + allowFrom, + groupAllowFrom, + resolveGroupPolicy, + resolveTelegramGroupConfig, + shouldSkipUpdate, + processMessage, + logger, +}: RegisterTelegramHandlerParams) => { + const DEFAULT_TEXT_FRAGMENT_MAX_GAP_MS = 1500; + const TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS = 4000; + const TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS = + typeof opts.testTimings?.textFragmentGapMs === "number" && + Number.isFinite(opts.testTimings.textFragmentGapMs) + ? Math.max(10, Math.floor(opts.testTimings.textFragmentGapMs)) + : DEFAULT_TEXT_FRAGMENT_MAX_GAP_MS; + const TELEGRAM_TEXT_FRAGMENT_MAX_ID_GAP = 1; + const TELEGRAM_TEXT_FRAGMENT_MAX_PARTS = 12; + const TELEGRAM_TEXT_FRAGMENT_MAX_TOTAL_CHARS = 50_000; + const mediaGroupTimeoutMs = + typeof opts.testTimings?.mediaGroupFlushMs === "number" && + Number.isFinite(opts.testTimings.mediaGroupFlushMs) + ? Math.max(10, Math.floor(opts.testTimings.mediaGroupFlushMs)) + : MEDIA_GROUP_TIMEOUT_MS; + + const mediaGroupBuffer = new Map(); + let mediaGroupProcessing: Promise = Promise.resolve(); + + type TextFragmentEntry = { + key: string; + messages: Array<{ msg: Message; ctx: TelegramContext; receivedAtMs: number }>; + timer: ReturnType; + }; + const textFragmentBuffer = new Map(); + let textFragmentProcessing: Promise = Promise.resolve(); + + const debounceMs = resolveInboundDebounceMs({ cfg, channel: "telegram" }); + const FORWARD_BURST_DEBOUNCE_MS = 80; + type TelegramDebounceLane = "default" | "forward"; + type TelegramDebounceEntry = { + ctx: TelegramContext; + msg: Message; + allMedia: TelegramMediaRef[]; + storeAllowFrom: string[]; + debounceKey: string | null; + debounceLane: TelegramDebounceLane; + botUsername?: string; + }; + const resolveTelegramDebounceLane = (msg: Message): TelegramDebounceLane => { + const forwardMeta = msg as { + forward_origin?: unknown; + forward_from?: unknown; + forward_from_chat?: unknown; + forward_sender_name?: unknown; + forward_date?: unknown; + }; + return (forwardMeta.forward_origin ?? + forwardMeta.forward_from ?? + forwardMeta.forward_from_chat ?? + forwardMeta.forward_sender_name ?? + forwardMeta.forward_date) + ? "forward" + : "default"; + }; + const buildSyntheticTextMessage = (params: { + base: Message; + text: string; + date?: number; + from?: Message["from"]; + }): Message => ({ + ...params.base, + ...(params.from ? { from: params.from } : {}), + text: params.text, + caption: undefined, + caption_entities: undefined, + entities: undefined, + ...(params.date != null ? { date: params.date } : {}), + }); + const buildSyntheticContext = ( + ctx: Pick & { getFile?: unknown }, + message: Message, + ): TelegramContext => { + const getFile = + typeof ctx.getFile === "function" + ? (ctx.getFile as TelegramContext["getFile"]).bind(ctx as object) + : async () => ({}); + return { message, me: ctx.me, getFile }; + }; + const inboundDebouncer = createInboundDebouncer({ + debounceMs, + resolveDebounceMs: (entry) => + entry.debounceLane === "forward" ? FORWARD_BURST_DEBOUNCE_MS : debounceMs, + buildKey: (entry) => entry.debounceKey, + shouldDebounce: (entry) => { + const text = entry.msg.text ?? entry.msg.caption ?? ""; + const hasDebounceableText = shouldDebounceTextInbound({ + text, + cfg, + commandOptions: { botUsername: entry.botUsername }, + }); + if (entry.debounceLane === "forward") { + // Forwarded bursts often split text + media into adjacent updates. + // Debounce media-only forward entries too so they can coalesce. + return hasDebounceableText || entry.allMedia.length > 0; + } + if (!hasDebounceableText) { + return false; + } + return entry.allMedia.length === 0; + }, + onFlush: async (entries) => { + const last = entries.at(-1); + if (!last) { + return; + } + if (entries.length === 1) { + const replyMedia = await resolveReplyMediaForMessage(last.ctx, last.msg); + await processMessage(last.ctx, last.allMedia, last.storeAllowFrom, undefined, replyMedia); + return; + } + const combinedText = entries + .map((entry) => entry.msg.text ?? entry.msg.caption ?? "") + .filter(Boolean) + .join("\n"); + const combinedMedia = entries.flatMap((entry) => entry.allMedia); + if (!combinedText.trim() && combinedMedia.length === 0) { + return; + } + const first = entries[0]; + const baseCtx = first.ctx; + const syntheticMessage = buildSyntheticTextMessage({ + base: first.msg, + text: combinedText, + date: last.msg.date ?? first.msg.date, + }); + const messageIdOverride = last.msg.message_id ? String(last.msg.message_id) : undefined; + const syntheticCtx = buildSyntheticContext(baseCtx, syntheticMessage); + const replyMedia = await resolveReplyMediaForMessage(baseCtx, syntheticMessage); + await processMessage( + syntheticCtx, + combinedMedia, + first.storeAllowFrom, + messageIdOverride ? { messageIdOverride } : undefined, + replyMedia, + ); + }, + onError: (err, items) => { + runtime.error?.(danger(`telegram debounce flush failed: ${String(err)}`)); + const chatId = items[0]?.msg.chat.id; + if (chatId != null) { + const threadId = items[0]?.msg.message_thread_id; + void bot.api + .sendMessage( + chatId, + "Something went wrong while processing your message. Please try again.", + threadId != null ? { message_thread_id: threadId } : undefined, + ) + .catch((sendErr) => { + logVerbose(`telegram: error fallback send failed: ${String(sendErr)}`); + }); + } + }, + }); + + const resolveTelegramSessionState = (params: { + chatId: number | string; + isGroup: boolean; + isForum: boolean; + messageThreadId?: number; + resolvedThreadId?: number; + senderId?: string | number; + }): { + agentId: string; + sessionEntry: ReturnType[string] | undefined; + sessionKey: string; + model?: string; + } => { + const resolvedThreadId = + params.resolvedThreadId ?? + resolveTelegramForumThreadId({ + isForum: params.isForum, + messageThreadId: params.messageThreadId, + }); + const dmThreadId = !params.isGroup ? params.messageThreadId : undefined; + const topicThreadId = resolvedThreadId ?? dmThreadId; + const { topicConfig } = resolveTelegramGroupConfig(params.chatId, topicThreadId); + const { route } = resolveTelegramConversationRoute({ + cfg, + accountId, + chatId: params.chatId, + isGroup: params.isGroup, + resolvedThreadId, + replyThreadId: topicThreadId, + senderId: params.senderId, + topicAgentId: topicConfig?.agentId, + }); + const baseSessionKey = route.sessionKey; + const threadKeys = + dmThreadId != null + ? resolveThreadSessionKeys({ baseSessionKey, threadId: `${params.chatId}:${dmThreadId}` }) + : null; + const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; + const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId }); + const store = loadSessionStore(storePath); + const entry = resolveSessionStoreEntry({ store, sessionKey }).existing; + const storedOverride = resolveStoredModelOverride({ + sessionEntry: entry, + sessionStore: store, + sessionKey, + }); + if (storedOverride) { + return { + agentId: route.agentId, + sessionEntry: entry, + sessionKey, + model: storedOverride.provider + ? `${storedOverride.provider}/${storedOverride.model}` + : storedOverride.model, + }; + } + const provider = entry?.modelProvider?.trim(); + const model = entry?.model?.trim(); + if (provider && model) { + return { + agentId: route.agentId, + sessionEntry: entry, + sessionKey, + model: `${provider}/${model}`, + }; + } + const modelCfg = cfg.agents?.defaults?.model; + return { + agentId: route.agentId, + sessionEntry: entry, + sessionKey, + model: typeof modelCfg === "string" ? modelCfg : modelCfg?.primary, + }; + }; + + const processMediaGroup = async (entry: MediaGroupEntry) => { + try { + entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id); + + const captionMsg = entry.messages.find((m) => m.msg.caption || m.msg.text); + const primaryEntry = captionMsg ?? entry.messages[0]; + + const allMedia: TelegramMediaRef[] = []; + for (const { ctx } of entry.messages) { + let media; + try { + media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramTransport); + } catch (mediaErr) { + if (!isRecoverableMediaGroupError(mediaErr)) { + throw mediaErr; + } + runtime.log?.( + warn(`media group: skipping photo that failed to fetch: ${String(mediaErr)}`), + ); + continue; + } + if (media) { + allMedia.push({ + path: media.path, + contentType: media.contentType, + stickerMetadata: media.stickerMetadata, + }); + } + } + + const storeAllowFrom = await loadStoreAllowFrom(); + const replyMedia = await resolveReplyMediaForMessage(primaryEntry.ctx, primaryEntry.msg); + await processMessage(primaryEntry.ctx, allMedia, storeAllowFrom, undefined, replyMedia); + } catch (err) { + runtime.error?.(danger(`media group handler failed: ${String(err)}`)); + } + }; + + const flushTextFragments = async (entry: TextFragmentEntry) => { + try { + entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id); + + const first = entry.messages[0]; + const last = entry.messages.at(-1); + if (!first || !last) { + return; + } + + const combinedText = entry.messages.map((m) => m.msg.text ?? "").join(""); + if (!combinedText.trim()) { + return; + } + + const syntheticMessage = buildSyntheticTextMessage({ + base: first.msg, + text: combinedText, + date: last.msg.date ?? first.msg.date, + }); + + const storeAllowFrom = await loadStoreAllowFrom(); + const baseCtx = first.ctx; + + await processMessage(buildSyntheticContext(baseCtx, syntheticMessage), [], storeAllowFrom, { + messageIdOverride: String(last.msg.message_id), + }); + } catch (err) { + runtime.error?.(danger(`text fragment handler failed: ${String(err)}`)); + } + }; + + const queueTextFragmentFlush = async (entry: TextFragmentEntry) => { + textFragmentProcessing = textFragmentProcessing + .then(async () => { + await flushTextFragments(entry); + }) + .catch(() => undefined); + await textFragmentProcessing; + }; + + const runTextFragmentFlush = async (entry: TextFragmentEntry) => { + textFragmentBuffer.delete(entry.key); + await queueTextFragmentFlush(entry); + }; + + const scheduleTextFragmentFlush = (entry: TextFragmentEntry) => { + clearTimeout(entry.timer); + entry.timer = setTimeout(async () => { + await runTextFragmentFlush(entry); + }, TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS); + }; + + const loadStoreAllowFrom = async () => + readChannelAllowFromStore("telegram", process.env, accountId).catch(() => []); + + const resolveReplyMediaForMessage = async ( + ctx: TelegramContext, + msg: Message, + ): Promise => { + const replyMessage = msg.reply_to_message; + if (!replyMessage || !hasInboundMedia(replyMessage)) { + return []; + } + const replyFileId = resolveInboundMediaFileId(replyMessage); + if (!replyFileId) { + return []; + } + try { + const media = await resolveMedia( + { + message: replyMessage, + me: ctx.me, + getFile: async () => await bot.api.getFile(replyFileId), + }, + mediaMaxBytes, + opts.token, + telegramTransport, + ); + if (!media) { + return []; + } + return [ + { + path: media.path, + contentType: media.contentType, + stickerMetadata: media.stickerMetadata, + }, + ]; + } catch (err) { + logger.warn({ chatId: msg.chat.id, error: String(err) }, "reply media fetch failed"); + return []; + } + }; + + const isAllowlistAuthorized = ( + allow: NormalizedAllowFrom, + senderId: string, + senderUsername: string, + ) => + allow.hasWildcard || + (allow.hasEntries && + isSenderAllowed({ + allow, + senderId, + senderUsername, + })); + + const shouldSkipGroupMessage = (params: { + isGroup: boolean; + chatId: string | number; + chatTitle?: string; + resolvedThreadId?: number; + senderId: string; + senderUsername: string; + effectiveGroupAllow: NormalizedAllowFrom; + hasGroupAllowOverride: boolean; + groupConfig?: TelegramGroupConfig; + topicConfig?: TelegramTopicConfig; + }) => { + const { + isGroup, + chatId, + chatTitle, + resolvedThreadId, + senderId, + senderUsername, + effectiveGroupAllow, + hasGroupAllowOverride, + groupConfig, + topicConfig, + } = params; + const baseAccess = evaluateTelegramGroupBaseAccess({ + isGroup, + groupConfig, + topicConfig, + hasGroupAllowOverride, + effectiveGroupAllow, + senderId, + senderUsername, + enforceAllowOverride: true, + requireSenderForAllowOverride: true, + }); + if (!baseAccess.allowed) { + if (baseAccess.reason === "group-disabled") { + logVerbose(`Blocked telegram group ${chatId} (group disabled)`); + return true; + } + if (baseAccess.reason === "topic-disabled") { + logVerbose( + `Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`, + ); + return true; + } + logVerbose( + `Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`, + ); + return true; + } + if (!isGroup) { + return false; + } + const policyAccess = evaluateTelegramGroupPolicyAccess({ + isGroup, + chatId, + cfg, + telegramCfg, + topicConfig, + groupConfig, + effectiveGroupAllow, + senderId, + senderUsername, + resolveGroupPolicy, + enforcePolicy: true, + useTopicAndGroupOverrides: true, + enforceAllowlistAuthorization: true, + allowEmptyAllowlistEntries: false, + requireSenderForAllowlistAuthorization: true, + checkChatAllowlist: true, + }); + if (!policyAccess.allowed) { + if (policyAccess.reason === "group-policy-disabled") { + logVerbose("Blocked telegram group message (groupPolicy: disabled)"); + return true; + } + if (policyAccess.reason === "group-policy-allowlist-no-sender") { + logVerbose("Blocked telegram group message (no sender ID, groupPolicy: allowlist)"); + return true; + } + if (policyAccess.reason === "group-policy-allowlist-empty") { + logVerbose( + "Blocked telegram group message (groupPolicy: allowlist, no group allowlist entries)", + ); + return true; + } + if (policyAccess.reason === "group-policy-allowlist-unauthorized") { + logVerbose(`Blocked telegram group message from ${senderId} (groupPolicy: allowlist)`); + return true; + } + logger.info({ chatId, title: chatTitle, reason: "not-allowed" }, "skipping group message"); + return true; + } + return false; + }; + + type TelegramGroupAllowContext = Awaited>; + type TelegramEventAuthorizationMode = "reaction" | "callback-scope" | "callback-allowlist"; + type TelegramEventAuthorizationResult = { allowed: true } | { allowed: false; reason: string }; + type TelegramEventAuthorizationContext = TelegramGroupAllowContext & { dmPolicy: DmPolicy }; + + const TELEGRAM_EVENT_AUTH_RULES: Record< + TelegramEventAuthorizationMode, + { + enforceDirectAuthorization: boolean; + enforceGroupAllowlistAuthorization: boolean; + deniedDmReason: string; + deniedGroupReason: string; + } + > = { + reaction: { + enforceDirectAuthorization: true, + enforceGroupAllowlistAuthorization: false, + deniedDmReason: "reaction unauthorized by dm policy/allowlist", + deniedGroupReason: "reaction unauthorized by group allowlist", + }, + "callback-scope": { + enforceDirectAuthorization: false, + enforceGroupAllowlistAuthorization: false, + deniedDmReason: "callback unauthorized by inlineButtonsScope", + deniedGroupReason: "callback unauthorized by inlineButtonsScope", + }, + "callback-allowlist": { + enforceDirectAuthorization: true, + // Group auth is already enforced by shouldSkipGroupMessage (group policy + allowlist). + // An extra allowlist gate here would block users whose original command was authorized. + enforceGroupAllowlistAuthorization: false, + deniedDmReason: "callback unauthorized by inlineButtonsScope allowlist", + deniedGroupReason: "callback unauthorized by inlineButtonsScope allowlist", + }, + }; + + const resolveTelegramEventAuthorizationContext = async (params: { + chatId: number; + isGroup: boolean; + isForum: boolean; + messageThreadId?: number; + groupAllowContext?: TelegramGroupAllowContext; + }): Promise => { + const groupAllowContext = + params.groupAllowContext ?? + (await resolveTelegramGroupAllowFromContext({ + chatId: params.chatId, + accountId, + isGroup: params.isGroup, + isForum: params.isForum, + messageThreadId: params.messageThreadId, + groupAllowFrom, + resolveTelegramGroupConfig, + })); + // Use direct config dmPolicy override if available for DMs + const effectiveDmPolicy = + !params.isGroup && + groupAllowContext.groupConfig && + "dmPolicy" in groupAllowContext.groupConfig + ? (groupAllowContext.groupConfig.dmPolicy ?? telegramCfg.dmPolicy ?? "pairing") + : (telegramCfg.dmPolicy ?? "pairing"); + return { dmPolicy: effectiveDmPolicy, ...groupAllowContext }; + }; + + const authorizeTelegramEventSender = (params: { + chatId: number; + chatTitle?: string; + isGroup: boolean; + senderId: string; + senderUsername: string; + mode: TelegramEventAuthorizationMode; + context: TelegramEventAuthorizationContext; + }): TelegramEventAuthorizationResult => { + const { chatId, chatTitle, isGroup, senderId, senderUsername, mode, context } = params; + const { + dmPolicy, + resolvedThreadId, + storeAllowFrom, + groupConfig, + topicConfig, + groupAllowOverride, + effectiveGroupAllow, + hasGroupAllowOverride, + } = context; + const authRules = TELEGRAM_EVENT_AUTH_RULES[mode]; + const { + enforceDirectAuthorization, + enforceGroupAllowlistAuthorization, + deniedDmReason, + deniedGroupReason, + } = authRules; + if ( + shouldSkipGroupMessage({ + isGroup, + chatId, + chatTitle, + resolvedThreadId, + senderId, + senderUsername, + effectiveGroupAllow, + hasGroupAllowOverride, + groupConfig, + topicConfig, + }) + ) { + return { allowed: false, reason: "group-policy" }; + } + + if (!isGroup && enforceDirectAuthorization) { + if (dmPolicy === "disabled") { + logVerbose( + `Blocked telegram direct event from ${senderId || "unknown"} (${deniedDmReason})`, + ); + return { allowed: false, reason: "direct-disabled" }; + } + if (dmPolicy !== "open") { + // For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom + const dmAllowFrom = groupAllowOverride ?? allowFrom; + const effectiveDmAllow = normalizeDmAllowFromWithStore({ + allowFrom: dmAllowFrom, + storeAllowFrom, + dmPolicy, + }); + if (!isAllowlistAuthorized(effectiveDmAllow, senderId, senderUsername)) { + logVerbose(`Blocked telegram direct sender ${senderId || "unknown"} (${deniedDmReason})`); + return { allowed: false, reason: "direct-unauthorized" }; + } + } + } + if (isGroup && enforceGroupAllowlistAuthorization) { + if (!isAllowlistAuthorized(effectiveGroupAllow, senderId, senderUsername)) { + logVerbose(`Blocked telegram group sender ${senderId || "unknown"} (${deniedGroupReason})`); + return { allowed: false, reason: "group-unauthorized" }; + } + } + return { allowed: true }; + }; + + // Handle emoji reactions to messages. + bot.on("message_reaction", async (ctx) => { + try { + const reaction = ctx.messageReaction; + if (!reaction) { + return; + } + if (shouldSkipUpdate(ctx)) { + return; + } + + const chatId = reaction.chat.id; + const messageId = reaction.message_id; + const user = reaction.user; + const senderId = user?.id != null ? String(user.id) : ""; + const senderUsername = user?.username ?? ""; + const isGroup = reaction.chat.type === "group" || reaction.chat.type === "supergroup"; + const isForum = reaction.chat.is_forum === true; + + // Resolve reaction notification mode (default: "own"). + const reactionMode = telegramCfg.reactionNotifications ?? "own"; + if (reactionMode === "off") { + return; + } + if (user?.is_bot) { + return; + } + if (reactionMode === "own" && !wasSentByBot(chatId, messageId)) { + return; + } + const eventAuthContext = await resolveTelegramEventAuthorizationContext({ + chatId, + isGroup, + isForum, + }); + const senderAuthorization = authorizeTelegramEventSender({ + chatId, + chatTitle: reaction.chat.title, + isGroup, + senderId, + senderUsername, + mode: "reaction", + context: eventAuthContext, + }); + if (!senderAuthorization.allowed) { + return; + } + + // Enforce requireTopic for DM reactions: since Telegram doesn't provide messageThreadId + // for reactions, we cannot determine if the reaction came from a topic, so block all + // reactions if requireTopic is enabled for this DM. + if (!isGroup) { + const requireTopic = (eventAuthContext.groupConfig as TelegramDirectConfig | undefined) + ?.requireTopic; + if (requireTopic === true) { + logVerbose( + `Blocked telegram reaction in DM ${chatId}: requireTopic=true but topic unknown for reactions`, + ); + return; + } + } + + // Detect added reactions. + const oldEmojis = new Set( + reaction.old_reaction + .filter((r): r is ReactionTypeEmoji => r.type === "emoji") + .map((r) => r.emoji), + ); + const addedReactions = reaction.new_reaction + .filter((r): r is ReactionTypeEmoji => r.type === "emoji") + .filter((r) => !oldEmojis.has(r.emoji)); + + if (addedReactions.length === 0) { + return; + } + + // Build sender label. + const senderName = user + ? [user.first_name, user.last_name].filter(Boolean).join(" ").trim() || user.username + : undefined; + const senderUsernameLabel = user?.username ? `@${user.username}` : undefined; + let senderLabel = senderName; + if (senderName && senderUsernameLabel) { + senderLabel = `${senderName} (${senderUsernameLabel})`; + } else if (!senderName && senderUsernameLabel) { + senderLabel = senderUsernameLabel; + } + if (!senderLabel && user?.id) { + senderLabel = `id:${user.id}`; + } + senderLabel = senderLabel || "unknown"; + + // Reactions target a specific message_id; the Telegram Bot API does not include + // message_thread_id on MessageReactionUpdated, so we route to the chat-level + // session (forum topic routing is not available for reactions). + const resolvedThreadId = isForum + ? resolveTelegramForumThreadId({ isForum, messageThreadId: undefined }) + : undefined; + const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId); + const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId }); + // Fresh config for bindings lookup; other routing inputs are payload-derived. + const route = resolveAgentRoute({ + cfg: loadConfig(), + channel: "telegram", + accountId, + peer: { kind: isGroup ? "group" : "direct", id: peerId }, + parentPeer, + }); + const sessionKey = route.sessionKey; + + // Enqueue system event for each added reaction. + for (const r of addedReactions) { + const emoji = r.emoji; + const text = `Telegram reaction added: ${emoji} by ${senderLabel} on msg ${messageId}`; + enqueueSystemEvent(text, { + sessionKey, + contextKey: `telegram:reaction:add:${chatId}:${messageId}:${user?.id ?? "anon"}:${emoji}`, + }); + logVerbose(`telegram: reaction event enqueued: ${text}`); + } + } catch (err) { + runtime.error?.(danger(`telegram reaction handler failed: ${String(err)}`)); + } + }); + const processInboundMessage = async (params: { + ctx: TelegramContext; + msg: Message; + chatId: number; + resolvedThreadId?: number; + dmThreadId?: number; + storeAllowFrom: string[]; + sendOversizeWarning: boolean; + oversizeLogMessage: string; + }) => { + const { + ctx, + msg, + chatId, + resolvedThreadId, + dmThreadId, + storeAllowFrom, + sendOversizeWarning, + oversizeLogMessage, + } = params; + + // Text fragment handling - Telegram splits long pastes into multiple inbound messages (~4096 chars). + // We buffer “near-limit” messages and append immediately-following parts. + const text = typeof msg.text === "string" ? msg.text : undefined; + const isCommandLike = (text ?? "").trim().startsWith("/"); + if (text && !isCommandLike) { + const nowMs = Date.now(); + const senderId = msg.from?.id != null ? String(msg.from.id) : "unknown"; + // Use resolvedThreadId for forum groups, dmThreadId for DM topics + const threadId = resolvedThreadId ?? dmThreadId; + const key = `text:${chatId}:${threadId ?? "main"}:${senderId}`; + const existing = textFragmentBuffer.get(key); + + if (existing) { + const last = existing.messages.at(-1); + const lastMsgId = last?.msg.message_id; + const lastReceivedAtMs = last?.receivedAtMs ?? nowMs; + const idGap = typeof lastMsgId === "number" ? msg.message_id - lastMsgId : Infinity; + const timeGapMs = nowMs - lastReceivedAtMs; + const canAppend = + idGap > 0 && + idGap <= TELEGRAM_TEXT_FRAGMENT_MAX_ID_GAP && + timeGapMs >= 0 && + timeGapMs <= TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS; + + if (canAppend) { + const currentTotalChars = existing.messages.reduce( + (sum, m) => sum + (m.msg.text?.length ?? 0), + 0, + ); + const nextTotalChars = currentTotalChars + text.length; + if ( + existing.messages.length + 1 <= TELEGRAM_TEXT_FRAGMENT_MAX_PARTS && + nextTotalChars <= TELEGRAM_TEXT_FRAGMENT_MAX_TOTAL_CHARS + ) { + existing.messages.push({ msg, ctx, receivedAtMs: nowMs }); + scheduleTextFragmentFlush(existing); + return; + } + } + + // Not appendable (or limits exceeded): flush buffered entry first, then continue normally. + clearTimeout(existing.timer); + textFragmentBuffer.delete(key); + textFragmentProcessing = textFragmentProcessing + .then(async () => { + await flushTextFragments(existing); + }) + .catch(() => undefined); + await textFragmentProcessing; + } + + const shouldStart = text.length >= TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS; + if (shouldStart) { + const entry: TextFragmentEntry = { + key, + messages: [{ msg, ctx, receivedAtMs: nowMs }], + timer: setTimeout(() => {}, TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS), + }; + textFragmentBuffer.set(key, entry); + scheduleTextFragmentFlush(entry); + return; + } + } + + // Media group handling - buffer multi-image messages + const mediaGroupId = msg.media_group_id; + if (mediaGroupId) { + const existing = mediaGroupBuffer.get(mediaGroupId); + if (existing) { + clearTimeout(existing.timer); + existing.messages.push({ msg, ctx }); + existing.timer = setTimeout(async () => { + mediaGroupBuffer.delete(mediaGroupId); + mediaGroupProcessing = mediaGroupProcessing + .then(async () => { + await processMediaGroup(existing); + }) + .catch(() => undefined); + await mediaGroupProcessing; + }, mediaGroupTimeoutMs); + } else { + const entry: MediaGroupEntry = { + messages: [{ msg, ctx }], + timer: setTimeout(async () => { + mediaGroupBuffer.delete(mediaGroupId); + mediaGroupProcessing = mediaGroupProcessing + .then(async () => { + await processMediaGroup(entry); + }) + .catch(() => undefined); + await mediaGroupProcessing; + }, mediaGroupTimeoutMs), + }; + mediaGroupBuffer.set(mediaGroupId, entry); + } + return; + } + + let media: Awaited> = null; + try { + media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramTransport); + } catch (mediaErr) { + if (isMediaSizeLimitError(mediaErr)) { + if (sendOversizeWarning) { + const limitMb = Math.round(mediaMaxBytes / (1024 * 1024)); + await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime, + fn: () => + bot.api.sendMessage(chatId, `⚠️ File too large. Maximum size is ${limitMb}MB.`, { + reply_to_message_id: msg.message_id, + }), + }).catch(() => {}); + } + logger.warn({ chatId, error: String(mediaErr) }, oversizeLogMessage); + return; + } + logger.warn({ chatId, error: String(mediaErr) }, "media fetch failed"); + await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime, + fn: () => + bot.api.sendMessage(chatId, "⚠️ Failed to download media. Please try again.", { + reply_to_message_id: msg.message_id, + }), + }).catch(() => {}); + return; + } + + // Skip sticker-only messages where the sticker was skipped (animated/video) + // These have no media and no text content to process. + const hasText = Boolean(getTelegramTextParts(msg).text.trim()); + if (msg.sticker && !media && !hasText) { + logVerbose("telegram: skipping sticker-only message (unsupported sticker type)"); + return; + } + + const allMedia = media + ? [ + { + path: media.path, + contentType: media.contentType, + stickerMetadata: media.stickerMetadata, + }, + ] + : []; + const senderId = msg.from?.id ? String(msg.from.id) : ""; + const conversationThreadId = resolvedThreadId ?? dmThreadId; + const conversationKey = + conversationThreadId != null ? `${chatId}:topic:${conversationThreadId}` : String(chatId); + const debounceLane = resolveTelegramDebounceLane(msg); + const debounceKey = senderId + ? `telegram:${accountId ?? "default"}:${conversationKey}:${senderId}:${debounceLane}` + : null; + await inboundDebouncer.enqueue({ + ctx, + msg, + allMedia, + storeAllowFrom, + debounceKey, + debounceLane, + botUsername: ctx.me?.username, + }); + }; + bot.on("callback_query", async (ctx) => { + const callback = ctx.callbackQuery; + if (!callback) { + return; + } + if (shouldSkipUpdate(ctx)) { + return; + } + const answerCallbackQuery = + typeof (ctx as { answerCallbackQuery?: unknown }).answerCallbackQuery === "function" + ? () => ctx.answerCallbackQuery() + : () => bot.api.answerCallbackQuery(callback.id); + // Answer immediately to prevent Telegram from retrying while we process + await withTelegramApiErrorLogging({ + operation: "answerCallbackQuery", + runtime, + fn: answerCallbackQuery, + }).catch(() => {}); + try { + const data = (callback.data ?? "").trim(); + const callbackMessage = callback.message; + if (!data || !callbackMessage) { + return; + } + const editCallbackMessage = async ( + text: string, + params?: Parameters[3], + ) => { + const editTextFn = (ctx as { editMessageText?: unknown }).editMessageText; + if (typeof editTextFn === "function") { + return await ctx.editMessageText(text, params); + } + return await bot.api.editMessageText( + callbackMessage.chat.id, + callbackMessage.message_id, + text, + params, + ); + }; + const clearCallbackButtons = async () => { + const emptyKeyboard = { inline_keyboard: [] }; + const replyMarkup = { reply_markup: emptyKeyboard }; + const editReplyMarkupFn = (ctx as { editMessageReplyMarkup?: unknown }) + .editMessageReplyMarkup; + if (typeof editReplyMarkupFn === "function") { + return await ctx.editMessageReplyMarkup(replyMarkup); + } + const apiEditReplyMarkupFn = (bot.api as { editMessageReplyMarkup?: unknown }) + .editMessageReplyMarkup; + if (typeof apiEditReplyMarkupFn === "function") { + return await bot.api.editMessageReplyMarkup( + callbackMessage.chat.id, + callbackMessage.message_id, + replyMarkup, + ); + } + // Fallback path for older clients that do not expose editMessageReplyMarkup. + const messageText = callbackMessage.text ?? callbackMessage.caption; + if (typeof messageText !== "string" || messageText.trim().length === 0) { + return undefined; + } + return await editCallbackMessage(messageText, replyMarkup); + }; + const deleteCallbackMessage = async () => { + const deleteFn = (ctx as { deleteMessage?: unknown }).deleteMessage; + if (typeof deleteFn === "function") { + return await ctx.deleteMessage(); + } + return await bot.api.deleteMessage(callbackMessage.chat.id, callbackMessage.message_id); + }; + const replyToCallbackChat = async ( + text: string, + params?: Parameters[2], + ) => { + const replyFn = (ctx as { reply?: unknown }).reply; + if (typeof replyFn === "function") { + return await ctx.reply(text, params); + } + return await bot.api.sendMessage(callbackMessage.chat.id, text, params); + }; + + const chatId = callbackMessage.chat.id; + const isGroup = + callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup"; + const isApprovalCallback = APPROVE_CALLBACK_DATA_RE.test(data); + const inlineButtonsScope = resolveTelegramInlineButtonsScope({ + cfg, + accountId, + }); + const execApprovalButtonsEnabled = + isApprovalCallback && + shouldEnableTelegramExecApprovalButtons({ + cfg, + accountId, + to: String(chatId), + }); + if (!execApprovalButtonsEnabled) { + if (inlineButtonsScope === "off") { + return; + } + if (inlineButtonsScope === "dm" && isGroup) { + return; + } + if (inlineButtonsScope === "group" && !isGroup) { + return; + } + } + + const messageThreadId = callbackMessage.message_thread_id; + const isForum = callbackMessage.chat.is_forum === true; + const eventAuthContext = await resolveTelegramEventAuthorizationContext({ + chatId, + isGroup, + isForum, + messageThreadId, + }); + const { resolvedThreadId, dmThreadId, storeAllowFrom, groupConfig } = eventAuthContext; + const requireTopic = (groupConfig as { requireTopic?: boolean } | undefined)?.requireTopic; + if (!isGroup && requireTopic === true && dmThreadId == null) { + logVerbose( + `Blocked telegram callback in DM ${chatId}: requireTopic=true but no topic present`, + ); + return; + } + const senderId = callback.from?.id ? String(callback.from.id) : ""; + const senderUsername = callback.from?.username ?? ""; + const authorizationMode: TelegramEventAuthorizationMode = + !execApprovalButtonsEnabled && inlineButtonsScope === "allowlist" + ? "callback-allowlist" + : "callback-scope"; + const senderAuthorization = authorizeTelegramEventSender({ + chatId, + chatTitle: callbackMessage.chat.title, + isGroup, + senderId, + senderUsername, + mode: authorizationMode, + context: eventAuthContext, + }); + if (!senderAuthorization.allowed) { + return; + } + + if (isApprovalCallback) { + if ( + !isTelegramExecApprovalClientEnabled({ cfg, accountId }) || + !isTelegramExecApprovalApprover({ cfg, accountId, senderId }) + ) { + logVerbose( + `Blocked telegram exec approval callback from ${senderId || "unknown"} (not an approver)`, + ); + return; + } + try { + await clearCallbackButtons(); + } catch (editErr) { + const errStr = String(editErr); + if ( + !errStr.includes("message is not modified") && + !errStr.includes("there is no text in the message to edit") + ) { + logVerbose(`telegram: failed to clear approval callback buttons: ${errStr}`); + } + } + } + + const paginationMatch = data.match(/^commands_page_(\d+|noop)(?::(.+))?$/); + if (paginationMatch) { + const pageValue = paginationMatch[1]; + if (pageValue === "noop") { + return; + } + + const page = Number.parseInt(pageValue, 10); + if (Number.isNaN(page) || page < 1) { + return; + } + + const agentId = paginationMatch[2]?.trim() || resolveDefaultAgentId(cfg); + const skillCommands = listSkillCommandsForAgents({ + cfg, + agentIds: [agentId], + }); + const result = buildCommandsMessagePaginated(cfg, skillCommands, { + page, + surface: "telegram", + }); + + const keyboard = + result.totalPages > 1 + ? buildInlineKeyboard( + buildCommandsPaginationKeyboard(result.currentPage, result.totalPages, agentId), + ) + : undefined; + + try { + await editCallbackMessage(result.text, keyboard ? { reply_markup: keyboard } : undefined); + } catch (editErr) { + const errStr = String(editErr); + if (!errStr.includes("message is not modified")) { + throw editErr; + } + } + return; + } + + // Model selection callback handler (mdl_prov, mdl_list_*, mdl_sel_*, mdl_back) + const modelCallback = parseModelCallbackData(data); + if (modelCallback) { + const sessionState = resolveTelegramSessionState({ + chatId, + isGroup, + isForum, + messageThreadId, + resolvedThreadId, + senderId, + }); + const modelData = await buildModelsProviderData(cfg, sessionState.agentId); + const { byProvider, providers } = modelData; + + const editMessageWithButtons = async ( + text: string, + buttons: ReturnType, + ) => { + const keyboard = buildInlineKeyboard(buttons); + try { + await editCallbackMessage(text, keyboard ? { reply_markup: keyboard } : undefined); + } catch (editErr) { + const errStr = String(editErr); + if (errStr.includes("no text in the message")) { + try { + await deleteCallbackMessage(); + } catch {} + await replyToCallbackChat(text, keyboard ? { reply_markup: keyboard } : undefined); + } else if (!errStr.includes("message is not modified")) { + throw editErr; + } + } + }; + + if (modelCallback.type === "providers" || modelCallback.type === "back") { + if (providers.length === 0) { + await editMessageWithButtons("No providers available.", []); + return; + } + const providerInfos: ProviderInfo[] = providers.map((p) => ({ + id: p, + count: byProvider.get(p)?.size ?? 0, + })); + const buttons = buildProviderKeyboard(providerInfos); + await editMessageWithButtons("Select a provider:", buttons); + return; + } + + if (modelCallback.type === "list") { + const { provider, page } = modelCallback; + const modelSet = byProvider.get(provider); + if (!modelSet || modelSet.size === 0) { + // Provider not found or no models - show providers list + const providerInfos: ProviderInfo[] = providers.map((p) => ({ + id: p, + count: byProvider.get(p)?.size ?? 0, + })); + const buttons = buildProviderKeyboard(providerInfos); + await editMessageWithButtons( + `Unknown provider: ${provider}\n\nSelect a provider:`, + buttons, + ); + return; + } + const models = [...modelSet].toSorted(); + const pageSize = getModelsPageSize(); + const totalPages = calculateTotalPages(models.length, pageSize); + const safePage = Math.max(1, Math.min(page, totalPages)); + + // Resolve current model from session (prefer overrides) + const currentSessionState = resolveTelegramSessionState({ + chatId, + isGroup, + isForum, + messageThreadId, + resolvedThreadId, + senderId, + }); + const currentModel = currentSessionState.model; + + const buttons = buildModelsKeyboard({ + provider, + models, + currentModel, + currentPage: safePage, + totalPages, + pageSize, + }); + const text = formatModelsAvailableHeader({ + provider, + total: models.length, + cfg, + agentDir: resolveAgentDir(cfg, currentSessionState.agentId), + sessionEntry: currentSessionState.sessionEntry, + }); + await editMessageWithButtons(text, buttons); + return; + } + + if (modelCallback.type === "select") { + const selection = resolveModelSelection({ + callback: modelCallback, + providers, + byProvider, + }); + if (selection.kind !== "resolved") { + const providerInfos: ProviderInfo[] = providers.map((p) => ({ + id: p, + count: byProvider.get(p)?.size ?? 0, + })); + const buttons = buildProviderKeyboard(providerInfos); + await editMessageWithButtons( + `Could not resolve model "${selection.model}".\n\nSelect a provider:`, + buttons, + ); + return; + } + + const modelSet = byProvider.get(selection.provider); + if (!modelSet?.has(selection.model)) { + await editMessageWithButtons( + `❌ Model "${selection.provider}/${selection.model}" is not allowed.`, + [], + ); + return; + } + + // Directly set model override in session + try { + // Get session store path + const storePath = resolveStorePath(cfg.session?.store, { + agentId: sessionState.agentId, + }); + + const resolvedDefault = resolveDefaultModelForAgent({ + cfg, + agentId: sessionState.agentId, + }); + const isDefaultSelection = + selection.provider === resolvedDefault.provider && + selection.model === resolvedDefault.model; + + await updateSessionStore(storePath, (store) => { + const sessionKey = sessionState.sessionKey; + const entry = store[sessionKey] ?? {}; + store[sessionKey] = entry; + applyModelOverrideToSessionEntry({ + entry, + selection: { + provider: selection.provider, + model: selection.model, + isDefault: isDefaultSelection, + }, + }); + }); + + // Update message to show success with visual feedback + const actionText = isDefaultSelection + ? "reset to default" + : `changed to **${selection.provider}/${selection.model}**`; + await editMessageWithButtons( + `✅ Model ${actionText}\n\nThis model will be used for your next message.`, + [], // Empty buttons = remove inline keyboard + ); + } catch (err) { + await editMessageWithButtons(`❌ Failed to change model: ${String(err)}`, []); + } + return; + } + + return; + } + + const syntheticMessage = buildSyntheticTextMessage({ + base: callbackMessage, + from: callback.from, + text: data, + }); + await processMessage(buildSyntheticContext(ctx, syntheticMessage), [], storeAllowFrom, { + forceWasMentioned: true, + messageIdOverride: callback.id, + }); + } catch (err) { + runtime.error?.(danger(`callback handler failed: ${String(err)}`)); + } + }); + + // Handle group migration to supergroup (chat ID changes) + bot.on("message:migrate_to_chat_id", async (ctx) => { + try { + const msg = ctx.message; + if (!msg?.migrate_to_chat_id) { + return; + } + if (shouldSkipUpdate(ctx)) { + return; + } + + const oldChatId = String(msg.chat.id); + const newChatId = String(msg.migrate_to_chat_id); + const chatTitle = msg.chat.title ?? "Unknown"; + + runtime.log?.(warn(`[telegram] Group migrated: "${chatTitle}" ${oldChatId} → ${newChatId}`)); + + if (!resolveChannelConfigWrites({ cfg, channelId: "telegram", accountId })) { + runtime.log?.(warn("[telegram] Config writes disabled; skipping group config migration.")); + return; + } + + // Check if old chat ID has config and migrate it + const currentConfig = loadConfig(); + const migration = migrateTelegramGroupConfig({ + cfg: currentConfig, + accountId, + oldChatId, + newChatId, + }); + + if (migration.migrated) { + runtime.log?.(warn(`[telegram] Migrating group config from ${oldChatId} to ${newChatId}`)); + migrateTelegramGroupConfig({ cfg, accountId, oldChatId, newChatId }); + await writeConfigFile(currentConfig); + runtime.log?.(warn(`[telegram] Group config migrated and saved successfully`)); + } else if (migration.skippedExisting) { + runtime.log?.( + warn( + `[telegram] Group config already exists for ${newChatId}; leaving ${oldChatId} unchanged`, + ), + ); + } else { + runtime.log?.( + warn(`[telegram] No config found for old group ID ${oldChatId}, migration logged only`), + ); + } + } catch (err) { + runtime.error?.(danger(`[telegram] Group migration handler failed: ${String(err)}`)); + } + }); + + type InboundTelegramEvent = { + ctxForDedupe: TelegramUpdateKeyContext; + ctx: TelegramContext; + msg: Message; + chatId: number; + isGroup: boolean; + isForum: boolean; + messageThreadId?: number; + senderId: string; + senderUsername: string; + requireConfiguredGroup: boolean; + sendOversizeWarning: boolean; + oversizeLogMessage: string; + errorMessage: string; + }; + + const handleInboundMessageLike = async (event: InboundTelegramEvent) => { + try { + if (shouldSkipUpdate(event.ctxForDedupe)) { + return; + } + const eventAuthContext = await resolveTelegramEventAuthorizationContext({ + chatId: event.chatId, + isGroup: event.isGroup, + isForum: event.isForum, + messageThreadId: event.messageThreadId, + }); + const { + dmPolicy, + resolvedThreadId, + dmThreadId, + storeAllowFrom, + groupConfig, + topicConfig, + groupAllowOverride, + effectiveGroupAllow, + hasGroupAllowOverride, + } = eventAuthContext; + // For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom + const dmAllowFrom = groupAllowOverride ?? allowFrom; + const effectiveDmAllow = normalizeDmAllowFromWithStore({ + allowFrom: dmAllowFrom, + storeAllowFrom, + dmPolicy, + }); + + if (event.requireConfiguredGroup && (!groupConfig || groupConfig.enabled === false)) { + logVerbose(`Blocked telegram channel ${event.chatId} (channel disabled)`); + return; + } + + if ( + shouldSkipGroupMessage({ + isGroup: event.isGroup, + chatId: event.chatId, + chatTitle: event.msg.chat.title, + resolvedThreadId, + senderId: event.senderId, + senderUsername: event.senderUsername, + effectiveGroupAllow, + hasGroupAllowOverride, + groupConfig, + topicConfig, + }) + ) { + return; + } + + if (!event.isGroup && (hasInboundMedia(event.msg) || hasReplyTargetMedia(event.msg))) { + const dmAuthorized = await enforceTelegramDmAccess({ + isGroup: event.isGroup, + dmPolicy, + msg: event.msg, + chatId: event.chatId, + effectiveDmAllow, + accountId, + bot, + logger, + }); + if (!dmAuthorized) { + return; + } + } + + await processInboundMessage({ + ctx: event.ctx, + msg: event.msg, + chatId: event.chatId, + resolvedThreadId, + dmThreadId, + storeAllowFrom, + sendOversizeWarning: event.sendOversizeWarning, + oversizeLogMessage: event.oversizeLogMessage, + }); + } catch (err) { + runtime.error?.(danger(`${event.errorMessage}: ${String(err)}`)); + } + }; + + bot.on("message", async (ctx) => { + const msg = ctx.message; + if (!msg) { + return; + } + await handleInboundMessageLike({ + ctxForDedupe: ctx, + ctx: buildSyntheticContext(ctx, msg), + msg, + chatId: msg.chat.id, + isGroup: msg.chat.type === "group" || msg.chat.type === "supergroup", + isForum: msg.chat.is_forum === true, + messageThreadId: msg.message_thread_id, + senderId: msg.from?.id != null ? String(msg.from.id) : "", + senderUsername: msg.from?.username ?? "", + requireConfiguredGroup: false, + sendOversizeWarning: true, + oversizeLogMessage: "media exceeds size limit", + errorMessage: "handler failed", + }); + }); + + // Handle channel posts — enables bot-to-bot communication via Telegram channels. + // Telegram bots cannot see other bot messages in groups, but CAN in channels. + // This handler normalizes channel_post updates into the standard message pipeline. + bot.on("channel_post", async (ctx) => { + const post = ctx.channelPost; + if (!post) { + return; + } + + const chatId = post.chat.id; + const syntheticFrom = post.sender_chat + ? { + id: post.sender_chat.id, + is_bot: true as const, + first_name: post.sender_chat.title || "Channel", + username: post.sender_chat.username, + } + : { + id: chatId, + is_bot: true as const, + first_name: post.chat.title || "Channel", + username: post.chat.username, + }; + const syntheticMsg: Message = { + ...post, + from: post.from ?? syntheticFrom, + chat: { + ...post.chat, + type: "supergroup" as const, + }, + } as Message; + + await handleInboundMessageLike({ + ctxForDedupe: ctx, + ctx: buildSyntheticContext(ctx, syntheticMsg), + msg: syntheticMsg, + chatId, + isGroup: true, + isForum: false, + senderId: + post.sender_chat?.id != null + ? String(post.sender_chat.id) + : post.from?.id != null + ? String(post.from.id) + : "", + senderUsername: post.sender_chat?.username ?? post.from?.username ?? "", + requireConfiguredGroup: true, + sendOversizeWarning: false, + oversizeLogMessage: "channel post media exceeds size limit", + errorMessage: "channel_post handler failed", + }); + }); +}; diff --git a/src/telegram/bot-message-context.acp-bindings.test.ts b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts similarity index 98% rename from src/telegram/bot-message-context.acp-bindings.test.ts rename to extensions/telegram/src/bot-message-context.acp-bindings.test.ts index 1e073366347..1f9adb41a72 100644 --- a/src/telegram/bot-message-context.acp-bindings.test.ts +++ b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn()); const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn()); -vi.mock("../acp/persistent-bindings.js", () => ({ +vi.mock("../../../src/acp/persistent-bindings.js", () => ({ ensureConfiguredAcpBindingSession: (...args: unknown[]) => ensureConfiguredAcpBindingSessionMock(...args), resolveConfiguredAcpBindingRecord: (...args: unknown[]) => diff --git a/src/telegram/bot-message-context.audio-transcript.test.ts b/extensions/telegram/src/bot-message-context.audio-transcript.test.ts similarity index 98% rename from src/telegram/bot-message-context.audio-transcript.test.ts rename to extensions/telegram/src/bot-message-context.audio-transcript.test.ts index 1cd0e15df31..a9e60736e70 100644 --- a/src/telegram/bot-message-context.audio-transcript.test.ts +++ b/extensions/telegram/src/bot-message-context.audio-transcript.test.ts @@ -6,7 +6,7 @@ const DEFAULT_MODEL = "anthropic/claude-opus-4-5"; const DEFAULT_WORKSPACE = "/tmp/openclaw"; const DEFAULT_MENTION_PATTERN = "\\bbot\\b"; -vi.mock("../media-understanding/audio-preflight.js", () => ({ +vi.mock("../../../src/media-understanding/audio-preflight.js", () => ({ transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args), })); diff --git a/extensions/telegram/src/bot-message-context.body.ts b/extensions/telegram/src/bot-message-context.body.ts new file mode 100644 index 00000000000..8290b02169d --- /dev/null +++ b/extensions/telegram/src/bot-message-context.body.ts @@ -0,0 +1,288 @@ +import { + findModelInCatalog, + loadModelCatalog, + modelSupportsVision, +} from "../../../src/agents/model-catalog.js"; +import { resolveDefaultModelForAgent } from "../../../src/agents/model-selection.js"; +import { hasControlCommand } from "../../../src/auto-reply/command-detection.js"; +import { + recordPendingHistoryEntryIfEnabled, + type HistoryEntry, +} from "../../../src/auto-reply/reply/history.js"; +import { + buildMentionRegexes, + matchesMentionWithExplicit, +} from "../../../src/auto-reply/reply/mentions.js"; +import type { MsgContext } from "../../../src/auto-reply/templating.js"; +import { resolveControlCommandGate } from "../../../src/channels/command-gating.js"; +import { formatLocationText, type NormalizedLocation } from "../../../src/channels/location.js"; +import { logInboundDrop } from "../../../src/channels/logging.js"; +import { resolveMentionGatingWithBypass } from "../../../src/channels/mention-gating.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { + TelegramDirectConfig, + TelegramGroupConfig, + TelegramTopicConfig, +} from "../../../src/config/types.js"; +import { logVerbose } from "../../../src/globals.js"; +import type { NormalizedAllowFrom } from "./bot-access.js"; +import { isSenderAllowed } from "./bot-access.js"; +import type { + TelegramLogger, + TelegramMediaRef, + TelegramMessageContextOptions, +} from "./bot-message-context.types.js"; +import { + buildSenderLabel, + buildTelegramGroupPeerId, + expandTextLinks, + extractTelegramLocation, + getTelegramTextParts, + hasBotMention, + resolveTelegramMediaPlaceholder, +} from "./bot/helpers.js"; +import type { TelegramContext } from "./bot/types.js"; +import { isTelegramForumServiceMessage } from "./forum-service-message.js"; + +export type TelegramInboundBodyResult = { + bodyText: string; + rawBody: string; + historyKey?: string; + commandAuthorized: boolean; + effectiveWasMentioned: boolean; + canDetectMention: boolean; + shouldBypassMention: boolean; + stickerCacheHit: boolean; + locationData?: NormalizedLocation; +}; + +async function resolveStickerVisionSupport(params: { + cfg: OpenClawConfig; + agentId?: string; +}): Promise { + try { + const catalog = await loadModelCatalog({ config: params.cfg }); + const defaultModel = resolveDefaultModelForAgent({ + cfg: params.cfg, + agentId: params.agentId, + }); + const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model); + if (!entry) { + return false; + } + return modelSupportsVision(entry); + } catch { + return false; + } +} + +export async function resolveTelegramInboundBody(params: { + cfg: OpenClawConfig; + primaryCtx: TelegramContext; + msg: TelegramContext["message"]; + allMedia: TelegramMediaRef[]; + isGroup: boolean; + chatId: number | string; + senderId: string; + senderUsername: string; + resolvedThreadId?: number; + routeAgentId?: string; + effectiveGroupAllow: NormalizedAllowFrom; + effectiveDmAllow: NormalizedAllowFrom; + groupConfig?: TelegramGroupConfig | TelegramDirectConfig; + topicConfig?: TelegramTopicConfig; + requireMention?: boolean; + options?: TelegramMessageContextOptions; + groupHistories: Map; + historyLimit: number; + logger: TelegramLogger; +}): Promise { + const { + cfg, + primaryCtx, + msg, + allMedia, + isGroup, + chatId, + senderId, + senderUsername, + resolvedThreadId, + routeAgentId, + effectiveGroupAllow, + effectiveDmAllow, + groupConfig, + topicConfig, + requireMention, + options, + groupHistories, + historyLimit, + logger, + } = params; + const botUsername = primaryCtx.me?.username?.toLowerCase(); + const mentionRegexes = buildMentionRegexes(cfg, routeAgentId); + const messageTextParts = getTelegramTextParts(msg); + const allowForCommands = isGroup ? effectiveGroupAllow : effectiveDmAllow; + const senderAllowedForCommands = isSenderAllowed({ + allow: allowForCommands, + senderId, + senderUsername, + }); + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const hasControlCommandInMessage = hasControlCommand(messageTextParts.text, cfg, { + botUsername, + }); + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }], + allowTextCommands: true, + hasControlCommand: hasControlCommandInMessage, + }); + const commandAuthorized = commandGate.commandAuthorized; + const historyKey = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : undefined; + + let placeholder = resolveTelegramMediaPlaceholder(msg) ?? ""; + const cachedStickerDescription = allMedia[0]?.stickerMetadata?.cachedDescription; + const stickerSupportsVision = msg.sticker + ? await resolveStickerVisionSupport({ cfg, agentId: routeAgentId }) + : false; + const stickerCacheHit = Boolean(cachedStickerDescription) && !stickerSupportsVision; + if (stickerCacheHit) { + const emoji = allMedia[0]?.stickerMetadata?.emoji; + const setName = allMedia[0]?.stickerMetadata?.setName; + const stickerContext = [emoji, setName ? `from "${setName}"` : null].filter(Boolean).join(" "); + placeholder = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${cachedStickerDescription}`; + } + + const locationData = extractTelegramLocation(msg); + const locationText = locationData ? formatLocationText(locationData) : undefined; + const rawText = expandTextLinks(messageTextParts.text, messageTextParts.entities).trim(); + const hasUserText = Boolean(rawText || locationText); + let rawBody = [rawText, locationText].filter(Boolean).join("\n").trim(); + if (!rawBody) { + rawBody = placeholder; + } + if (!rawBody && allMedia.length === 0) { + return null; + } + + let bodyText = rawBody; + const hasAudio = allMedia.some((media) => media.contentType?.startsWith("audio/")); + const disableAudioPreflight = + (topicConfig?.disableAudioPreflight ?? + (groupConfig as TelegramGroupConfig | undefined)?.disableAudioPreflight) === true; + + let preflightTranscript: string | undefined; + const needsPreflightTranscription = + isGroup && + requireMention && + hasAudio && + !hasUserText && + mentionRegexes.length > 0 && + !disableAudioPreflight; + + if (needsPreflightTranscription) { + try { + const { transcribeFirstAudio } = + await import("../../../src/media-understanding/audio-preflight.js"); + const tempCtx: MsgContext = { + MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined, + MediaTypes: + allMedia.length > 0 + ? (allMedia.map((m) => m.contentType).filter(Boolean) as string[]) + : undefined, + }; + preflightTranscript = await transcribeFirstAudio({ + ctx: tempCtx, + cfg, + agentDir: undefined, + }); + } catch (err) { + logVerbose(`telegram: audio preflight transcription failed: ${String(err)}`); + } + } + + if (hasAudio && bodyText === "" && preflightTranscript) { + bodyText = preflightTranscript; + } + + if (!bodyText && allMedia.length > 0) { + if (hasAudio) { + bodyText = preflightTranscript || ""; + } else { + bodyText = `${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`; + } + } + + const hasAnyMention = messageTextParts.entities.some((ent) => ent.type === "mention"); + const explicitlyMentioned = botUsername ? hasBotMention(msg, botUsername) : false; + const computedWasMentioned = matchesMentionWithExplicit({ + text: messageTextParts.text, + mentionRegexes, + explicit: { + hasAnyMention, + isExplicitlyMentioned: explicitlyMentioned, + canResolveExplicit: Boolean(botUsername), + }, + transcript: preflightTranscript, + }); + const wasMentioned = options?.forceWasMentioned === true ? true : computedWasMentioned; + + if (isGroup && commandGate.shouldBlock) { + logInboundDrop({ + log: logVerbose, + channel: "telegram", + reason: "control command (unauthorized)", + target: senderId ?? "unknown", + }); + return null; + } + + const botId = primaryCtx.me?.id; + const replyFromId = msg.reply_to_message?.from?.id; + const replyToBotMessage = botId != null && replyFromId === botId; + const isReplyToServiceMessage = + replyToBotMessage && isTelegramForumServiceMessage(msg.reply_to_message); + const implicitMention = replyToBotMessage && !isReplyToServiceMessage; + const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0; + const mentionGate = resolveMentionGatingWithBypass({ + isGroup, + requireMention: Boolean(requireMention), + canDetectMention, + wasMentioned, + implicitMention: isGroup && Boolean(requireMention) && implicitMention, + hasAnyMention, + allowTextCommands: true, + hasControlCommand: hasControlCommandInMessage, + commandAuthorized, + }); + const effectiveWasMentioned = mentionGate.effectiveWasMentioned; + if (isGroup && requireMention && canDetectMention && mentionGate.shouldSkip) { + logger.info({ chatId, reason: "no-mention" }, "skipping group message"); + recordPendingHistoryEntryIfEnabled({ + historyMap: groupHistories, + historyKey: historyKey ?? "", + limit: historyLimit, + entry: historyKey + ? { + sender: buildSenderLabel(msg, senderId || chatId), + body: rawBody, + timestamp: msg.date ? msg.date * 1000 : undefined, + messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined, + } + : null, + }); + return null; + } + + return { + bodyText, + rawBody, + historyKey, + commandAuthorized, + effectiveWasMentioned, + canDetectMention, + shouldBypassMention: mentionGate.shouldBypassMention, + stickerCacheHit, + locationData: locationData ?? undefined, + }; +} diff --git a/src/telegram/bot-message-context.dm-threads.test.ts b/extensions/telegram/src/bot-message-context.dm-threads.test.ts similarity index 97% rename from src/telegram/bot-message-context.dm-threads.test.ts rename to extensions/telegram/src/bot-message-context.dm-threads.test.ts index eba4c19c88c..23fb0cdcc19 100644 --- a/src/telegram/bot-message-context.dm-threads.test.ts +++ b/extensions/telegram/src/bot-message-context.dm-threads.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it } from "vitest"; -import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js"; +import { + clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, +} from "../../../src/config/config.js"; import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; describe("buildTelegramMessageContext dm thread sessions", () => { diff --git a/src/telegram/bot-message-context.dm-topic-threadid.test.ts b/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts similarity index 98% rename from src/telegram/bot-message-context.dm-topic-threadid.test.ts rename to extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts index ba566898db8..8f8375fd11a 100644 --- a/src/telegram/bot-message-context.dm-topic-threadid.test.ts +++ b/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts @@ -3,7 +3,7 @@ import { buildTelegramMessageContextForTest } from "./bot-message-context.test-h // Mock recordInboundSession to capture updateLastRoute parameter const recordInboundSessionMock = vi.fn().mockResolvedValue(undefined); -vi.mock("../channels/session.js", () => ({ +vi.mock("../../../src/channels/session.js", () => ({ recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), })); diff --git a/src/telegram/bot-message-context.implicit-mention.test.ts b/extensions/telegram/src/bot-message-context.implicit-mention.test.ts similarity index 100% rename from src/telegram/bot-message-context.implicit-mention.test.ts rename to extensions/telegram/src/bot-message-context.implicit-mention.test.ts diff --git a/extensions/telegram/src/bot-message-context.named-account-dm.test.ts b/extensions/telegram/src/bot-message-context.named-account-dm.test.ts new file mode 100644 index 00000000000..a60904514ba --- /dev/null +++ b/extensions/telegram/src/bot-message-context.named-account-dm.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, +} from "../../../src/config/config.js"; +import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; + +const recordInboundSessionMock = vi.fn().mockResolvedValue(undefined); +vi.mock("../../../src/channels/session.js", () => ({ + recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), +})); + +describe("buildTelegramMessageContext named-account DM fallback", () => { + const baseCfg = { + agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } }, + channels: { telegram: {} }, + messages: { groupChat: { mentionPatterns: [] } }, + }; + + afterEach(() => { + clearRuntimeConfigSnapshot(); + recordInboundSessionMock.mockClear(); + }); + + function getLastUpdateLastRoute(): { sessionKey?: string } | undefined { + const callArgs = recordInboundSessionMock.mock.calls.at(-1)?.[0] as { + updateLastRoute?: { sessionKey?: string }; + }; + return callArgs?.updateLastRoute; + } + + function buildNamedAccountDmMessage(messageId = 1) { + return { + message_id: messageId, + chat: { id: 814912386, type: "private" as const }, + date: 1700000000 + messageId - 1, + text: "hello", + from: { id: 814912386, first_name: "Alice" }, + }; + } + + async function buildNamedAccountDmContext(accountId = "atlas", messageId = 1) { + setRuntimeConfigSnapshot(baseCfg); + return await buildTelegramMessageContextForTest({ + cfg: baseCfg, + accountId, + message: buildNamedAccountDmMessage(messageId), + }); + } + + it("allows DM through for a named account with no explicit binding", async () => { + setRuntimeConfigSnapshot(baseCfg); + + const ctx = await buildTelegramMessageContextForTest({ + cfg: baseCfg, + accountId: "atlas", + message: { + message_id: 1, + chat: { id: 814912386, type: "private" }, + date: 1700000000, + text: "hello", + from: { id: 814912386, first_name: "Alice" }, + }, + }); + + expect(ctx).not.toBeNull(); + expect(ctx?.route.matchedBy).toBe("default"); + expect(ctx?.route.accountId).toBe("atlas"); + }); + + it("uses a per-account session key for named-account DMs", async () => { + const ctx = await buildNamedAccountDmContext(); + + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:814912386"); + }); + + it("keeps named-account fallback lastRoute on the isolated DM session", async () => { + const ctx = await buildNamedAccountDmContext(); + + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:814912386"); + expect(getLastUpdateLastRoute()?.sessionKey).toBe("agent:main:telegram:atlas:direct:814912386"); + }); + + it("isolates sessions between named accounts that share the default agent", async () => { + const atlas = await buildNamedAccountDmContext("atlas", 1); + const skynet = await buildNamedAccountDmContext("skynet", 2); + + expect(atlas?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:814912386"); + expect(skynet?.ctxPayload?.SessionKey).toBe("agent:main:telegram:skynet:direct:814912386"); + expect(atlas?.ctxPayload?.SessionKey).not.toBe(skynet?.ctxPayload?.SessionKey); + }); + + it("keeps identity-linked peer canonicalization in the named-account fallback path", async () => { + const cfg = { + ...baseCfg, + session: { + identityLinks: { + "alice-shared": ["telegram:814912386"], + }, + }, + }; + setRuntimeConfigSnapshot(cfg); + + const ctx = await buildTelegramMessageContextForTest({ + cfg, + accountId: "atlas", + message: { + message_id: 1, + chat: { id: 999999999, type: "private" }, + date: 1700000000, + text: "hello", + from: { id: 814912386, first_name: "Alice" }, + }, + }); + + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:alice-shared"); + }); + + it("still drops named-account group messages without an explicit binding", async () => { + setRuntimeConfigSnapshot(baseCfg); + + const ctx = await buildTelegramMessageContextForTest({ + cfg: baseCfg, + accountId: "atlas", + options: { forceWasMentioned: true }, + resolveGroupActivation: () => true, + message: { + message_id: 1, + chat: { id: -1001234567890, type: "supergroup", title: "Test Group" }, + date: 1700000000, + text: "@bot hello", + from: { id: 814912386, first_name: "Alice" }, + }, + }); + + expect(ctx).toBeNull(); + }); + + it("does not change the default-account DM session key", async () => { + setRuntimeConfigSnapshot(baseCfg); + + const ctx = await buildTelegramMessageContextForTest({ + cfg: baseCfg, + message: { + message_id: 1, + chat: { id: 42, type: "private" }, + date: 1700000000, + text: "hello", + from: { id: 42, first_name: "Alice" }, + }, + }); + + expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main"); + }); +}); diff --git a/src/telegram/bot-message-context.sender-prefix.test.ts b/extensions/telegram/src/bot-message-context.sender-prefix.test.ts similarity index 100% rename from src/telegram/bot-message-context.sender-prefix.test.ts rename to extensions/telegram/src/bot-message-context.sender-prefix.test.ts diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts new file mode 100644 index 00000000000..1a2f54cf22f --- /dev/null +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -0,0 +1,320 @@ +import { normalizeCommandBody } from "../../../src/auto-reply/commands-registry.js"; +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "../../../src/auto-reply/envelope.js"; +import { + buildPendingHistoryContextFromMap, + type HistoryEntry, +} from "../../../src/auto-reply/reply/history.js"; +import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js"; +import { toLocationContext } from "../../../src/channels/location.js"; +import { recordInboundSession } from "../../../src/channels/session.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../../../src/config/sessions.js"; +import type { + TelegramDirectConfig, + TelegramGroupConfig, + TelegramTopicConfig, +} from "../../../src/config/types.js"; +import { logVerbose, shouldLogVerbose } from "../../../src/globals.js"; +import type { ResolvedAgentRoute } from "../../../src/routing/resolve-route.js"; +import { resolveInboundLastRouteSessionKey } from "../../../src/routing/resolve-route.js"; +import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../src/security/dm-policy-shared.js"; +import { normalizeAllowFrom } from "./bot-access.js"; +import type { + TelegramMediaRef, + TelegramMessageContextOptions, +} from "./bot-message-context.types.js"; +import { + buildGroupLabel, + buildSenderLabel, + buildSenderName, + buildTelegramGroupFrom, + describeReplyTarget, + normalizeForwardedContext, + type TelegramThreadSpec, +} from "./bot/helpers.js"; +import type { TelegramContext } from "./bot/types.js"; +import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js"; + +export async function buildTelegramInboundContextPayload(params: { + cfg: OpenClawConfig; + primaryCtx: TelegramContext; + msg: TelegramContext["message"]; + allMedia: TelegramMediaRef[]; + replyMedia: TelegramMediaRef[]; + isGroup: boolean; + isForum: boolean; + chatId: number | string; + senderId: string; + senderUsername: string; + resolvedThreadId?: number; + dmThreadId?: number; + threadSpec: TelegramThreadSpec; + route: ResolvedAgentRoute; + rawBody: string; + bodyText: string; + historyKey?: string; + historyLimit: number; + groupHistories: Map; + groupConfig?: TelegramGroupConfig | TelegramDirectConfig; + topicConfig?: TelegramTopicConfig; + stickerCacheHit: boolean; + effectiveWasMentioned: boolean; + commandAuthorized: boolean; + locationData?: import("../../../src/channels/location.js").NormalizedLocation; + options?: TelegramMessageContextOptions; + dmAllowFrom?: Array; +}): Promise<{ + ctxPayload: ReturnType; + skillFilter: string[] | undefined; +}> { + const { + cfg, + primaryCtx, + msg, + allMedia, + replyMedia, + isGroup, + isForum, + chatId, + senderId, + senderUsername, + resolvedThreadId, + dmThreadId, + threadSpec, + route, + rawBody, + bodyText, + historyKey, + historyLimit, + groupHistories, + groupConfig, + topicConfig, + stickerCacheHit, + effectiveWasMentioned, + commandAuthorized, + locationData, + options, + dmAllowFrom, + } = params; + const replyTarget = describeReplyTarget(msg); + const forwardOrigin = normalizeForwardedContext(msg); + const replyForwardAnnotation = replyTarget?.forwardedFrom + ? `[Forwarded from ${replyTarget.forwardedFrom.from}${ + replyTarget.forwardedFrom.date + ? ` at ${new Date(replyTarget.forwardedFrom.date * 1000).toISOString()}` + : "" + }]\n` + : ""; + const replySuffix = replyTarget + ? replyTarget.kind === "quote" + ? `\n\n[Quoting ${replyTarget.sender}${ + replyTarget.id ? ` id:${replyTarget.id}` : "" + }]\n${replyForwardAnnotation}"${replyTarget.body}"\n[/Quoting]` + : `\n\n[Replying to ${replyTarget.sender}${ + replyTarget.id ? ` id:${replyTarget.id}` : "" + }]\n${replyForwardAnnotation}${replyTarget.body}\n[/Replying]` + : ""; + const forwardPrefix = forwardOrigin + ? `[Forwarded from ${forwardOrigin.from}${ + forwardOrigin.date ? ` at ${new Date(forwardOrigin.date * 1000).toISOString()}` : "" + }]\n` + : ""; + const groupLabel = isGroup ? buildGroupLabel(msg, chatId, resolvedThreadId) : undefined; + const senderName = buildSenderName(msg); + const conversationLabel = isGroup + ? (groupLabel ?? `group:${chatId}`) + : buildSenderLabel(msg, senderId || chatId); + const storePath = resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const body = formatInboundEnvelope({ + channel: "Telegram", + from: conversationLabel, + timestamp: msg.date ? msg.date * 1000 : undefined, + body: `${forwardPrefix}${bodyText}${replySuffix}`, + chatType: isGroup ? "group" : "direct", + sender: { + name: senderName, + username: senderUsername || undefined, + id: senderId || undefined, + }, + previousTimestamp, + envelope: envelopeOptions, + }); + let combinedBody = body; + if (isGroup && historyKey && historyLimit > 0) { + combinedBody = buildPendingHistoryContextFromMap({ + historyMap: groupHistories, + historyKey, + limit: historyLimit, + currentMessage: combinedBody, + formatEntry: (entry) => + formatInboundEnvelope({ + channel: "Telegram", + from: groupLabel ?? `group:${chatId}`, + timestamp: entry.timestamp, + body: `${entry.body} [id:${entry.messageId ?? "unknown"} chat:${chatId}]`, + chatType: "group", + senderLabel: entry.sender, + envelope: envelopeOptions, + }), + }); + } + + const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({ + groupConfig, + topicConfig, + }); + const commandBody = normalizeCommandBody(rawBody, { + botUsername: primaryCtx.me?.username?.toLowerCase(), + }); + const inboundHistory = + isGroup && historyKey && historyLimit > 0 + ? (groupHistories.get(historyKey) ?? []).map((entry) => ({ + sender: entry.sender, + body: entry.body, + timestamp: entry.timestamp, + })) + : undefined; + const currentMediaForContext = stickerCacheHit ? [] : allMedia; + const contextMedia = [...currentMediaForContext, ...replyMedia]; + const ctxPayload = finalizeInboundContext({ + Body: combinedBody, + BodyForAgent: bodyText, + InboundHistory: inboundHistory, + RawBody: rawBody, + CommandBody: commandBody, + From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`, + To: `telegram:${chatId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isGroup ? "group" : "direct", + ConversationLabel: conversationLabel, + GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, + GroupSystemPrompt: isGroup || (!isGroup && groupConfig) ? groupSystemPrompt : undefined, + SenderName: senderName, + SenderId: senderId || undefined, + SenderUsername: senderUsername || undefined, + Provider: "telegram", + Surface: "telegram", + BotUsername: primaryCtx.me?.username ?? undefined, + MessageSid: options?.messageIdOverride ?? String(msg.message_id), + ReplyToId: replyTarget?.id, + ReplyToBody: replyTarget?.body, + ReplyToSender: replyTarget?.sender, + ReplyToIsQuote: replyTarget?.kind === "quote" ? true : undefined, + ReplyToForwardedFrom: replyTarget?.forwardedFrom?.from, + ReplyToForwardedFromType: replyTarget?.forwardedFrom?.fromType, + ReplyToForwardedFromId: replyTarget?.forwardedFrom?.fromId, + ReplyToForwardedFromUsername: replyTarget?.forwardedFrom?.fromUsername, + ReplyToForwardedFromTitle: replyTarget?.forwardedFrom?.fromTitle, + ReplyToForwardedDate: replyTarget?.forwardedFrom?.date + ? replyTarget.forwardedFrom.date * 1000 + : undefined, + ForwardedFrom: forwardOrigin?.from, + ForwardedFromType: forwardOrigin?.fromType, + ForwardedFromId: forwardOrigin?.fromId, + ForwardedFromUsername: forwardOrigin?.fromUsername, + ForwardedFromTitle: forwardOrigin?.fromTitle, + ForwardedFromSignature: forwardOrigin?.fromSignature, + ForwardedFromChatType: forwardOrigin?.fromChatType, + ForwardedFromMessageId: forwardOrigin?.fromMessageId, + ForwardedDate: forwardOrigin?.date ? forwardOrigin.date * 1000 : undefined, + Timestamp: msg.date ? msg.date * 1000 : undefined, + WasMentioned: isGroup ? effectiveWasMentioned : undefined, + MediaPath: contextMedia.length > 0 ? contextMedia[0]?.path : undefined, + MediaType: contextMedia.length > 0 ? contextMedia[0]?.contentType : undefined, + MediaUrl: contextMedia.length > 0 ? contextMedia[0]?.path : undefined, + MediaPaths: contextMedia.length > 0 ? contextMedia.map((m) => m.path) : undefined, + MediaUrls: contextMedia.length > 0 ? contextMedia.map((m) => m.path) : undefined, + MediaTypes: + contextMedia.length > 0 + ? (contextMedia.map((m) => m.contentType).filter(Boolean) as string[]) + : undefined, + Sticker: allMedia[0]?.stickerMetadata, + StickerMediaIncluded: allMedia[0]?.stickerMetadata ? !stickerCacheHit : undefined, + ...(locationData ? toLocationContext(locationData) : undefined), + CommandAuthorized: commandAuthorized, + MessageThreadId: threadSpec.id, + IsForum: isForum, + OriginatingChannel: "telegram" as const, + OriginatingTo: `telegram:${chatId}`, + }); + + const pinnedMainDmOwner = !isGroup + ? resolvePinnedMainDmOwnerFromAllowlist({ + dmScope: cfg.session?.dmScope, + allowFrom: dmAllowFrom, + normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0], + }) + : null; + const updateLastRouteSessionKey = resolveInboundLastRouteSessionKey({ + route, + sessionKey: route.sessionKey, + }); + + await recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + updateLastRoute: !isGroup + ? { + sessionKey: updateLastRouteSessionKey, + channel: "telegram", + to: `telegram:${chatId}`, + accountId: route.accountId, + threadId: dmThreadId != null ? String(dmThreadId) : undefined, + mainDmOwnerPin: + updateLastRouteSessionKey === route.mainSessionKey && pinnedMainDmOwner && senderId + ? { + ownerRecipient: pinnedMainDmOwner, + senderRecipient: senderId, + onSkip: ({ ownerRecipient, senderRecipient }) => { + logVerbose( + `telegram: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, + ); + }, + } + : undefined, + } + : undefined, + onRecordError: (err) => { + logVerbose(`telegram: failed updating session meta: ${String(err)}`); + }, + }); + + if (replyTarget && shouldLogVerbose()) { + const preview = replyTarget.body.replace(/\s+/g, " ").slice(0, 120); + logVerbose( + `telegram reply-context: replyToId=${replyTarget.id} replyToSender=${replyTarget.sender} replyToBody="${preview}"`, + ); + } + + if (forwardOrigin && shouldLogVerbose()) { + logVerbose( + `telegram forward-context: forwardedFrom="${forwardOrigin.from}" type=${forwardOrigin.fromType}`, + ); + } + + if (shouldLogVerbose()) { + const preview = body.slice(0, 200).replace(/\n/g, "\\n"); + const mediaInfo = allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : ""; + const topicInfo = resolvedThreadId != null ? ` topic=${resolvedThreadId}` : ""; + logVerbose( + `telegram inbound: chatId=${chatId} from=${ctxPayload.From} len=${body.length}${mediaInfo}${topicInfo} preview="${preview}"`, + ); + } + + return { + ctxPayload, + skillFilter, + }; +} diff --git a/src/telegram/bot-message-context.test-harness.ts b/extensions/telegram/src/bot-message-context.test-harness.ts similarity index 100% rename from src/telegram/bot-message-context.test-harness.ts rename to extensions/telegram/src/bot-message-context.test-harness.ts diff --git a/src/telegram/bot-message-context.thread-binding.test.ts b/extensions/telegram/src/bot-message-context.thread-binding.test.ts similarity index 95% rename from src/telegram/bot-message-context.thread-binding.test.ts rename to extensions/telegram/src/bot-message-context.thread-binding.test.ts index 07a625fa782..e635b6f4a11 100644 --- a/src/telegram/bot-message-context.thread-binding.test.ts +++ b/extensions/telegram/src/bot-message-context.thread-binding.test.ts @@ -9,9 +9,9 @@ const hoisted = vi.hoisted(() => { }; }); -vi.mock("../infra/outbound/session-binding-service.js", async (importOriginal) => { +vi.mock("../../../src/infra/outbound/session-binding-service.js", async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal(); return { ...actual, getSessionBindingService: () => ({ diff --git a/src/telegram/bot-message-context.topic-agentid.test.ts b/extensions/telegram/src/bot-message-context.topic-agentid.test.ts similarity index 95% rename from src/telegram/bot-message-context.topic-agentid.test.ts rename to extensions/telegram/src/bot-message-context.topic-agentid.test.ts index d3e24060278..ed55c11b36f 100644 --- a/src/telegram/bot-message-context.topic-agentid.test.ts +++ b/extensions/telegram/src/bot-message-context.topic-agentid.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { loadConfig } from "../config/config.js"; +import { loadConfig } from "../../../src/config/config.js"; import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; const { defaultRouteConfig } = vi.hoisted(() => ({ @@ -12,8 +12,8 @@ const { defaultRouteConfig } = vi.hoisted(() => ({ }, })); -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: vi.fn(() => defaultRouteConfig), diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts new file mode 100644 index 00000000000..03bcd429018 --- /dev/null +++ b/extensions/telegram/src/bot-message-context.ts @@ -0,0 +1,473 @@ +import { ensureConfiguredAcpRouteReady } from "../../../src/acp/persistent-bindings.route.js"; +import { resolveAckReaction } from "../../../src/agents/identity.js"; +import { shouldAckReaction as shouldAckReactionGate } from "../../../src/channels/ack-reactions.js"; +import { logInboundDrop } from "../../../src/channels/logging.js"; +import { + createStatusReactionController, + type StatusReactionController, +} from "../../../src/channels/status-reactions.js"; +import { loadConfig } from "../../../src/config/config.js"; +import type { TelegramDirectConfig, TelegramGroupConfig } from "../../../src/config/types.js"; +import { logVerbose } from "../../../src/globals.js"; +import { recordChannelActivity } from "../../../src/infra/channel-activity.js"; +import { buildAgentSessionKey, deriveLastRoutePolicy } from "../../../src/routing/resolve-route.js"; +import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../../../src/routing/session-key.js"; +import { withTelegramApiErrorLogging } from "./api-logging.js"; +import { firstDefined, normalizeAllowFrom, normalizeDmAllowFromWithStore } from "./bot-access.js"; +import { resolveTelegramInboundBody } from "./bot-message-context.body.js"; +import { buildTelegramInboundContextPayload } from "./bot-message-context.session.js"; +import type { BuildTelegramMessageContextParams } from "./bot-message-context.types.js"; +import { + buildTypingThreadParams, + resolveTelegramDirectPeerId, + resolveTelegramThreadSpec, +} from "./bot/helpers.js"; +import { resolveTelegramConversationRoute } from "./conversation-route.js"; +import { enforceTelegramDmAccess } from "./dm-access.js"; +import { evaluateTelegramGroupBaseAccess } from "./group-access.js"; +import { + buildTelegramStatusReactionVariants, + resolveTelegramAllowedEmojiReactions, + resolveTelegramReactionVariant, + resolveTelegramStatusReactionEmojis, +} from "./status-reaction-variants.js"; + +export type { + BuildTelegramMessageContextParams, + TelegramMediaRef, +} from "./bot-message-context.types.js"; + +export const buildTelegramMessageContext = async ({ + primaryCtx, + allMedia, + replyMedia = [], + storeAllowFrom, + options, + bot, + cfg, + account, + historyLimit, + groupHistories, + dmPolicy, + allowFrom, + groupAllowFrom, + ackReactionScope, + logger, + resolveGroupActivation, + resolveGroupRequireMention, + resolveTelegramGroupConfig, + sendChatActionHandler, +}: BuildTelegramMessageContextParams) => { + const msg = primaryCtx.message; + const chatId = msg.chat.id; + const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; + const senderId = msg.from?.id ? String(msg.from.id) : ""; + const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; + const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true; + const threadSpec = resolveTelegramThreadSpec({ + isGroup, + isForum, + messageThreadId, + }); + const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined; + const replyThreadId = threadSpec.id; + const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined; + const threadIdForConfig = resolvedThreadId ?? dmThreadId; + const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, threadIdForConfig); + // Use direct config dmPolicy override if available for DMs + const effectiveDmPolicy = + !isGroup && groupConfig && "dmPolicy" in groupConfig + ? (groupConfig.dmPolicy ?? dmPolicy) + : dmPolicy; + // Fresh config for bindings lookup; other routing inputs are payload-derived. + const freshCfg = loadConfig(); + let { route, configuredBinding, configuredBindingSessionKey } = resolveTelegramConversationRoute({ + cfg: freshCfg, + accountId: account.accountId, + chatId, + isGroup, + resolvedThreadId, + replyThreadId, + senderId, + topicAgentId: topicConfig?.agentId, + }); + const requiresExplicitAccountBinding = ( + candidate: ReturnType["route"], + ): boolean => candidate.accountId !== DEFAULT_ACCOUNT_ID && candidate.matchedBy === "default"; + const isNamedAccountFallback = requiresExplicitAccountBinding(route); + // Named-account groups still require an explicit binding; DMs get a + // per-account fallback session key below to preserve isolation. + if (isNamedAccountFallback && isGroup) { + logInboundDrop({ + log: logVerbose, + channel: "telegram", + reason: "non-default account requires explicit binding", + target: route.accountId, + }); + return null; + } + // Calculate groupAllowOverride first - it's needed for both DM and group allowlist checks + const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom); + // For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom + const dmAllowFrom = groupAllowOverride ?? allowFrom; + const effectiveDmAllow = normalizeDmAllowFromWithStore({ + allowFrom: dmAllowFrom, + storeAllowFrom, + dmPolicy: effectiveDmPolicy, + }); + // Group sender checks are explicit and must not inherit DM pairing-store entries. + const effectiveGroupAllow = normalizeAllowFrom(groupAllowOverride ?? groupAllowFrom); + const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined"; + const senderUsername = msg.from?.username ?? ""; + const baseAccess = evaluateTelegramGroupBaseAccess({ + isGroup, + groupConfig, + topicConfig, + hasGroupAllowOverride, + effectiveGroupAllow, + senderId, + senderUsername, + enforceAllowOverride: true, + requireSenderForAllowOverride: false, + }); + if (!baseAccess.allowed) { + if (baseAccess.reason === "group-disabled") { + logVerbose(`Blocked telegram group ${chatId} (group disabled)`); + return null; + } + if (baseAccess.reason === "topic-disabled") { + logVerbose( + `Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`, + ); + return null; + } + logVerbose( + isGroup + ? `Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)` + : `Blocked telegram DM sender ${senderId || "unknown"} (DM allowFrom override)`, + ); + return null; + } + + const requireTopic = (groupConfig as TelegramDirectConfig | undefined)?.requireTopic; + const topicRequiredButMissing = !isGroup && requireTopic === true && dmThreadId == null; + if (topicRequiredButMissing) { + logVerbose(`Blocked telegram DM ${chatId}: requireTopic=true but no topic present`); + return null; + } + + const sendTyping = async () => { + await withTelegramApiErrorLogging({ + operation: "sendChatAction", + fn: () => + sendChatActionHandler.sendChatAction( + chatId, + "typing", + buildTypingThreadParams(replyThreadId), + ), + }); + }; + + const sendRecordVoice = async () => { + try { + await withTelegramApiErrorLogging({ + operation: "sendChatAction", + fn: () => + sendChatActionHandler.sendChatAction( + chatId, + "record_voice", + buildTypingThreadParams(replyThreadId), + ), + }); + } catch (err) { + logVerbose(`telegram record_voice cue failed for chat ${chatId}: ${String(err)}`); + } + }; + + if ( + !(await enforceTelegramDmAccess({ + isGroup, + dmPolicy: effectiveDmPolicy, + msg, + chatId, + effectiveDmAllow, + accountId: account.accountId, + bot, + logger, + })) + ) { + return null; + } + const ensureConfiguredBindingReady = async (): Promise => { + if (!configuredBinding) { + return true; + } + const ensured = await ensureConfiguredAcpRouteReady({ + cfg: freshCfg, + configuredBinding, + }); + if (ensured.ok) { + logVerbose( + `telegram: using configured ACP binding for ${configuredBinding.spec.conversationId} -> ${configuredBindingSessionKey}`, + ); + return true; + } + logVerbose( + `telegram: configured ACP binding unavailable for ${configuredBinding.spec.conversationId}: ${ensured.error}`, + ); + logInboundDrop({ + log: logVerbose, + channel: "telegram", + reason: "configured ACP binding unavailable", + target: configuredBinding.spec.conversationId, + }); + return false; + }; + + const baseSessionKey = isNamedAccountFallback + ? buildAgentSessionKey({ + agentId: route.agentId, + channel: "telegram", + accountId: route.accountId, + peer: { + kind: "direct", + id: resolveTelegramDirectPeerId({ + chatId, + senderId, + }), + }, + dmScope: "per-account-channel-peer", + identityLinks: freshCfg.session?.identityLinks, + }).toLowerCase() + : route.sessionKey; + // DMs: use thread suffix for session isolation (works regardless of dmScope) + const threadKeys = + dmThreadId != null + ? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` }) + : null; + const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; + route = { + ...route, + sessionKey, + lastRoutePolicy: deriveLastRoutePolicy({ + sessionKey, + mainSessionKey: route.mainSessionKey, + }), + }; + // Compute requireMention after access checks and final route selection. + const activationOverride = resolveGroupActivation({ + chatId, + messageThreadId: resolvedThreadId, + sessionKey: sessionKey, + agentId: route.agentId, + }); + const baseRequireMention = resolveGroupRequireMention(chatId); + const requireMention = firstDefined( + activationOverride, + topicConfig?.requireMention, + (groupConfig as TelegramGroupConfig | undefined)?.requireMention, + baseRequireMention, + ); + + recordChannelActivity({ + channel: "telegram", + accountId: account.accountId, + direction: "inbound", + }); + + const bodyResult = await resolveTelegramInboundBody({ + cfg, + primaryCtx, + msg, + allMedia, + isGroup, + chatId, + senderId, + senderUsername, + resolvedThreadId, + routeAgentId: route.agentId, + effectiveGroupAllow, + effectiveDmAllow, + groupConfig, + topicConfig, + requireMention, + options, + groupHistories, + historyLimit, + logger, + }); + if (!bodyResult) { + return null; + } + + if (!(await ensureConfiguredBindingReady())) { + return null; + } + + // ACK reactions + const ackReaction = resolveAckReaction(cfg, route.agentId, { + channel: "telegram", + accountId: account.accountId, + }); + const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; + const shouldAckReaction = () => + Boolean( + ackReaction && + shouldAckReactionGate({ + scope: ackReactionScope, + isDirect: !isGroup, + isGroup, + isMentionableGroup: isGroup, + requireMention: Boolean(requireMention), + canDetectMention: bodyResult.canDetectMention, + effectiveWasMentioned: bodyResult.effectiveWasMentioned, + shouldBypassMention: bodyResult.shouldBypassMention, + }), + ); + const api = bot.api as unknown as { + setMessageReaction?: ( + chatId: number | string, + messageId: number, + reactions: Array<{ type: "emoji"; emoji: string }>, + ) => Promise; + getChat?: (chatId: number | string) => Promise; + }; + const reactionApi = + typeof api.setMessageReaction === "function" ? api.setMessageReaction.bind(api) : null; + const getChatApi = typeof api.getChat === "function" ? api.getChat.bind(api) : null; + + // Status Reactions controller (lifecycle reactions) + const statusReactionsConfig = cfg.messages?.statusReactions; + const statusReactionsEnabled = + statusReactionsConfig?.enabled === true && Boolean(reactionApi) && shouldAckReaction(); + const resolvedStatusReactionEmojis = resolveTelegramStatusReactionEmojis({ + initialEmoji: ackReaction, + overrides: statusReactionsConfig?.emojis, + }); + const statusReactionVariantsByEmoji = buildTelegramStatusReactionVariants( + resolvedStatusReactionEmojis, + ); + let allowedStatusReactionEmojisPromise: Promise | null> | null = null; + const statusReactionController: StatusReactionController | null = + statusReactionsEnabled && msg.message_id + ? createStatusReactionController({ + enabled: true, + adapter: { + setReaction: async (emoji: string) => { + if (reactionApi) { + if (!allowedStatusReactionEmojisPromise) { + allowedStatusReactionEmojisPromise = resolveTelegramAllowedEmojiReactions({ + chat: msg.chat, + chatId, + getChat: getChatApi ?? undefined, + }).catch((err) => { + logVerbose( + `telegram status-reaction available_reactions lookup failed for chat ${chatId}: ${String(err)}`, + ); + return null; + }); + } + const allowedStatusReactionEmojis = await allowedStatusReactionEmojisPromise; + const resolvedEmoji = resolveTelegramReactionVariant({ + requestedEmoji: emoji, + variantsByRequestedEmoji: statusReactionVariantsByEmoji, + allowedEmojiReactions: allowedStatusReactionEmojis, + }); + if (!resolvedEmoji) { + return; + } + await reactionApi(chatId, msg.message_id, [ + { type: "emoji", emoji: resolvedEmoji }, + ]); + } + }, + // Telegram replaces atomically — no removeReaction needed + }, + initialEmoji: ackReaction, + emojis: resolvedStatusReactionEmojis, + timing: statusReactionsConfig?.timing, + onError: (err) => { + logVerbose(`telegram status-reaction error for chat ${chatId}: ${String(err)}`); + }, + }) + : null; + + // When status reactions are enabled, setQueued() replaces the simple ack reaction + const ackReactionPromise = statusReactionController + ? shouldAckReaction() + ? Promise.resolve(statusReactionController.setQueued()).then( + () => true, + () => false, + ) + : null + : shouldAckReaction() && msg.message_id && reactionApi + ? withTelegramApiErrorLogging({ + operation: "setMessageReaction", + fn: () => reactionApi(chatId, msg.message_id, [{ type: "emoji", emoji: ackReaction }]), + }).then( + () => true, + (err) => { + logVerbose(`telegram react failed for chat ${chatId}: ${String(err)}`); + return false; + }, + ) + : null; + + const { ctxPayload, skillFilter } = await buildTelegramInboundContextPayload({ + cfg, + primaryCtx, + msg, + allMedia, + replyMedia, + isGroup, + isForum, + chatId, + senderId, + senderUsername, + resolvedThreadId, + dmThreadId, + threadSpec, + route, + rawBody: bodyResult.rawBody, + bodyText: bodyResult.bodyText, + historyKey: bodyResult.historyKey, + historyLimit, + groupHistories, + groupConfig, + topicConfig, + stickerCacheHit: bodyResult.stickerCacheHit, + effectiveWasMentioned: bodyResult.effectiveWasMentioned, + locationData: bodyResult.locationData, + options, + dmAllowFrom, + commandAuthorized: bodyResult.commandAuthorized, + }); + + return { + ctxPayload, + primaryCtx, + msg, + chatId, + isGroup, + resolvedThreadId, + threadSpec, + replyThreadId, + isForum, + historyKey: bodyResult.historyKey, + historyLimit, + groupHistories, + route, + skillFilter, + sendTyping, + sendRecordVoice, + ackReactionPromise, + reactionApi, + removeAckAfterReply, + statusReactionController, + accountId: account.accountId, + }; +}; + +export type TelegramMessageContext = NonNullable< + Awaited> +>; diff --git a/extensions/telegram/src/bot-message-context.types.ts b/extensions/telegram/src/bot-message-context.types.ts new file mode 100644 index 00000000000..2853c1a8e34 --- /dev/null +++ b/extensions/telegram/src/bot-message-context.types.ts @@ -0,0 +1,65 @@ +import type { Bot } from "grammy"; +import type { HistoryEntry } from "../../../src/auto-reply/reply/history.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { + DmPolicy, + TelegramDirectConfig, + TelegramGroupConfig, + TelegramTopicConfig, +} from "../../../src/config/types.js"; +import type { StickerMetadata, TelegramContext } from "./bot/types.js"; + +export type TelegramMediaRef = { + path: string; + contentType?: string; + stickerMetadata?: StickerMetadata; +}; + +export type TelegramMessageContextOptions = { + forceWasMentioned?: boolean; + messageIdOverride?: string; +}; + +export type TelegramLogger = { + info: (obj: Record, msg: string) => void; +}; + +export type ResolveTelegramGroupConfig = ( + chatId: string | number, + messageThreadId?: number, +) => { + groupConfig?: TelegramGroupConfig | TelegramDirectConfig; + topicConfig?: TelegramTopicConfig; +}; + +export type ResolveGroupActivation = (params: { + chatId: string | number; + agentId?: string; + messageThreadId?: number; + sessionKey?: string; +}) => boolean | undefined; + +export type ResolveGroupRequireMention = (chatId: string | number) => boolean; + +export type BuildTelegramMessageContextParams = { + primaryCtx: TelegramContext; + allMedia: TelegramMediaRef[]; + replyMedia?: TelegramMediaRef[]; + storeAllowFrom: string[]; + options?: TelegramMessageContextOptions; + bot: Bot; + cfg: OpenClawConfig; + account: { accountId: string }; + historyLimit: number; + groupHistories: Map; + dmPolicy: DmPolicy; + allowFrom?: Array; + groupAllowFrom?: Array; + ackReactionScope: "off" | "none" | "group-mentions" | "group-all" | "direct" | "all"; + logger: TelegramLogger; + resolveGroupActivation: ResolveGroupActivation; + resolveGroupRequireMention: ResolveGroupRequireMention; + resolveTelegramGroupConfig: ResolveTelegramGroupConfig; + /** Global (per-account) handler for sendChatAction 401 backoff (#27092). */ + sendChatActionHandler: import("./sendchataction-401-backoff.js").TelegramSendChatActionHandler; +}; diff --git a/src/telegram/bot-message-dispatch.sticker-media.test.ts b/extensions/telegram/src/bot-message-dispatch.sticker-media.test.ts similarity index 100% rename from src/telegram/bot-message-dispatch.sticker-media.test.ts rename to extensions/telegram/src/bot-message-dispatch.sticker-media.test.ts diff --git a/src/telegram/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts similarity index 99% rename from src/telegram/bot-message-dispatch.test.ts rename to extensions/telegram/src/bot-message-dispatch.test.ts index 62255706fbd..156d9296ae7 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -1,7 +1,7 @@ import path from "node:path"; import type { Bot } from "grammy"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { STATE_DIR } from "../config/paths.js"; +import { STATE_DIR } from "../../../src/config/paths.js"; import { createSequencedTestDraftStream, createTestDraftStream, @@ -18,7 +18,7 @@ vi.mock("./draft-stream.js", () => ({ createTelegramDraftStream, })); -vi.mock("../auto-reply/reply/provider-dispatcher.js", () => ({ +vi.mock("../../../src/auto-reply/reply/provider-dispatcher.js", () => ({ dispatchReplyWithBufferedBlockDispatcher, })); @@ -30,8 +30,8 @@ vi.mock("./send.js", () => ({ editMessageTelegram, })); -vi.mock("../config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadSessionStore, diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts new file mode 100644 index 00000000000..a9c0e625508 --- /dev/null +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -0,0 +1,853 @@ +import type { Bot } from "grammy"; +import { resolveAgentDir } from "../../../src/agents/agent-scope.js"; +import { + findModelInCatalog, + loadModelCatalog, + modelSupportsVision, +} from "../../../src/agents/model-catalog.js"; +import { resolveDefaultModelForAgent } from "../../../src/agents/model-selection.js"; +import { resolveChunkMode } from "../../../src/auto-reply/chunk.js"; +import { clearHistoryEntriesIfEnabled } from "../../../src/auto-reply/reply/history.js"; +import { dispatchReplyWithBufferedBlockDispatcher } from "../../../src/auto-reply/reply/provider-dispatcher.js"; +import type { ReplyPayload } from "../../../src/auto-reply/types.js"; +import { removeAckReactionAfterReply } from "../../../src/channels/ack-reactions.js"; +import { logAckFailure, logTypingFailure } from "../../../src/channels/logging.js"; +import { createReplyPrefixOptions } from "../../../src/channels/reply-prefix.js"; +import { createTypingCallbacks } from "../../../src/channels/typing.js"; +import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; +import { + loadSessionStore, + resolveSessionStoreEntry, + resolveStorePath, +} from "../../../src/config/sessions.js"; +import type { + OpenClawConfig, + ReplyToMode, + TelegramAccountConfig, +} from "../../../src/config/types.js"; +import { danger, logVerbose } from "../../../src/globals.js"; +import { getAgentScopedMediaLocalRoots } from "../../../src/media/local-roots.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; +import type { TelegramMessageContext } from "./bot-message-context.js"; +import type { TelegramBotOptions } from "./bot.js"; +import { deliverReplies } from "./bot/delivery.js"; +import type { TelegramStreamMode } from "./bot/types.js"; +import type { TelegramInlineButtons } from "./button-types.js"; +import { createTelegramDraftStream } from "./draft-stream.js"; +import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js"; +import { renderTelegramHtmlText } from "./format.js"; +import { + type ArchivedPreview, + createLaneDeliveryStateTracker, + createLaneTextDeliverer, + type DraftLaneState, + type LaneName, + type LanePreviewLifecycle, +} from "./lane-delivery.js"; +import { + createTelegramReasoningStepState, + splitTelegramReasoningText, +} from "./reasoning-lane-coordinator.js"; +import { editMessageTelegram } from "./send.js"; +import { cacheSticker, describeStickerImage } from "./sticker-cache.js"; + +const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; + +/** Minimum chars before sending first streaming message (improves push notification UX) */ +const DRAFT_MIN_INITIAL_CHARS = 30; + +async function resolveStickerVisionSupport(cfg: OpenClawConfig, agentId: string) { + try { + const catalog = await loadModelCatalog({ config: cfg }); + const defaultModel = resolveDefaultModelForAgent({ cfg, agentId }); + const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model); + if (!entry) { + return false; + } + return modelSupportsVision(entry); + } catch { + return false; + } +} + +export function pruneStickerMediaFromContext( + ctxPayload: { + MediaPath?: string; + MediaUrl?: string; + MediaType?: string; + MediaPaths?: string[]; + MediaUrls?: string[]; + MediaTypes?: string[]; + }, + opts?: { stickerMediaIncluded?: boolean }, +) { + if (opts?.stickerMediaIncluded === false) { + return; + } + const nextMediaPaths = Array.isArray(ctxPayload.MediaPaths) + ? ctxPayload.MediaPaths.slice(1) + : undefined; + const nextMediaUrls = Array.isArray(ctxPayload.MediaUrls) + ? ctxPayload.MediaUrls.slice(1) + : undefined; + const nextMediaTypes = Array.isArray(ctxPayload.MediaTypes) + ? ctxPayload.MediaTypes.slice(1) + : undefined; + ctxPayload.MediaPaths = nextMediaPaths && nextMediaPaths.length > 0 ? nextMediaPaths : undefined; + ctxPayload.MediaUrls = nextMediaUrls && nextMediaUrls.length > 0 ? nextMediaUrls : undefined; + ctxPayload.MediaTypes = nextMediaTypes && nextMediaTypes.length > 0 ? nextMediaTypes : undefined; + ctxPayload.MediaPath = ctxPayload.MediaPaths?.[0]; + ctxPayload.MediaUrl = ctxPayload.MediaUrls?.[0] ?? ctxPayload.MediaPath; + ctxPayload.MediaType = ctxPayload.MediaTypes?.[0]; +} + +type DispatchTelegramMessageParams = { + context: TelegramMessageContext; + bot: Bot; + cfg: OpenClawConfig; + runtime: RuntimeEnv; + replyToMode: ReplyToMode; + streamMode: TelegramStreamMode; + textLimit: number; + telegramCfg: TelegramAccountConfig; + opts: Pick; +}; + +type TelegramReasoningLevel = "off" | "on" | "stream"; + +function resolveTelegramReasoningLevel(params: { + cfg: OpenClawConfig; + sessionKey?: string; + agentId: string; +}): TelegramReasoningLevel { + const { cfg, sessionKey, agentId } = params; + if (!sessionKey) { + return "off"; + } + try { + const storePath = resolveStorePath(cfg.session?.store, { agentId }); + const store = loadSessionStore(storePath, { skipCache: true }); + const entry = resolveSessionStoreEntry({ store, sessionKey }).existing; + const level = entry?.reasoningLevel; + if (level === "on" || level === "stream") { + return level; + } + } catch { + // Fall through to default. + } + return "off"; +} + +export const dispatchTelegramMessage = async ({ + context, + bot, + cfg, + runtime, + replyToMode, + streamMode, + textLimit, + telegramCfg, + opts, +}: DispatchTelegramMessageParams) => { + const { + ctxPayload, + msg, + chatId, + isGroup, + threadSpec, + historyKey, + historyLimit, + groupHistories, + route, + skillFilter, + sendTyping, + sendRecordVoice, + ackReactionPromise, + reactionApi, + removeAckAfterReply, + statusReactionController, + } = context; + + const draftMaxChars = Math.min(textLimit, 4096); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "telegram", + accountId: route.accountId, + }); + const renderDraftPreview = (text: string) => ({ + text: renderTelegramHtmlText(text, { tableMode }), + parseMode: "HTML" as const, + }); + const accountBlockStreamingEnabled = + typeof telegramCfg.blockStreaming === "boolean" + ? telegramCfg.blockStreaming + : cfg.agents?.defaults?.blockStreamingDefault === "on"; + const resolvedReasoningLevel = resolveTelegramReasoningLevel({ + cfg, + sessionKey: ctxPayload.SessionKey, + agentId: route.agentId, + }); + const forceBlockStreamingForReasoning = resolvedReasoningLevel === "on"; + const streamReasoningDraft = resolvedReasoningLevel === "stream"; + const previewStreamingEnabled = streamMode !== "off"; + const canStreamAnswerDraft = + previewStreamingEnabled && !accountBlockStreamingEnabled && !forceBlockStreamingForReasoning; + const canStreamReasoningDraft = canStreamAnswerDraft || streamReasoningDraft; + const draftReplyToMessageId = + replyToMode !== "off" && typeof msg.message_id === "number" ? msg.message_id : undefined; + const draftMinInitialChars = DRAFT_MIN_INITIAL_CHARS; + // Keep DM preview lanes on real message transport. Native draft previews still + // require a draft->message materialize hop, and that overlap keeps reintroducing + // a visible duplicate flash at finalize time. + const useMessagePreviewTransportForDm = threadSpec?.scope === "dm" && canStreamAnswerDraft; + const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId); + const archivedAnswerPreviews: ArchivedPreview[] = []; + const archivedReasoningPreviewIds: number[] = []; + const createDraftLane = (laneName: LaneName, enabled: boolean): DraftLaneState => { + const stream = enabled + ? createTelegramDraftStream({ + api: bot.api, + chatId, + maxChars: draftMaxChars, + thread: threadSpec, + previewTransport: useMessagePreviewTransportForDm ? "message" : "auto", + replyToMessageId: draftReplyToMessageId, + minInitialChars: draftMinInitialChars, + renderText: renderDraftPreview, + onSupersededPreview: + laneName === "answer" || laneName === "reasoning" + ? (preview) => { + if (laneName === "reasoning") { + if (!archivedReasoningPreviewIds.includes(preview.messageId)) { + archivedReasoningPreviewIds.push(preview.messageId); + } + return; + } + archivedAnswerPreviews.push({ + messageId: preview.messageId, + textSnapshot: preview.textSnapshot, + deleteIfUnused: true, + }); + } + : undefined, + log: logVerbose, + warn: logVerbose, + }) + : undefined; + return { + stream, + lastPartialText: "", + hasStreamedMessage: false, + }; + }; + const lanes: Record = { + answer: createDraftLane("answer", canStreamAnswerDraft), + reasoning: createDraftLane("reasoning", canStreamReasoningDraft), + }; + // Active preview lifecycle answers "can this current preview still be + // finalized?" Cleanup retention is separate so archived-preview decisions do + // not poison the active lane. + const activePreviewLifecycleByLane: Record = { + answer: "transient", + reasoning: "transient", + }; + const retainPreviewOnCleanupByLane: Record = { + answer: false, + reasoning: false, + }; + const answerLane = lanes.answer; + const reasoningLane = lanes.reasoning; + let splitReasoningOnNextStream = false; + let skipNextAnswerMessageStartRotation = false; + let draftLaneEventQueue = Promise.resolve(); + const reasoningStepState = createTelegramReasoningStepState(); + const enqueueDraftLaneEvent = (task: () => Promise): Promise => { + const next = draftLaneEventQueue.then(task); + draftLaneEventQueue = next.catch((err) => { + logVerbose(`telegram: draft lane callback failed: ${String(err)}`); + }); + return draftLaneEventQueue; + }; + type SplitLaneSegment = { lane: LaneName; text: string }; + type SplitLaneSegmentsResult = { + segments: SplitLaneSegment[]; + suppressedReasoningOnly: boolean; + }; + const splitTextIntoLaneSegments = (text?: string): SplitLaneSegmentsResult => { + const split = splitTelegramReasoningText(text); + const segments: SplitLaneSegment[] = []; + const suppressReasoning = resolvedReasoningLevel === "off"; + if (split.reasoningText && !suppressReasoning) { + segments.push({ lane: "reasoning", text: split.reasoningText }); + } + if (split.answerText) { + segments.push({ lane: "answer", text: split.answerText }); + } + return { + segments, + suppressedReasoningOnly: + Boolean(split.reasoningText) && suppressReasoning && !split.answerText, + }; + }; + const resetDraftLaneState = (lane: DraftLaneState) => { + lane.lastPartialText = ""; + lane.hasStreamedMessage = false; + }; + const rotateAnswerLaneForNewAssistantMessage = async () => { + let didForceNewMessage = false; + if (answerLane.hasStreamedMessage) { + // Materialize the current streamed draft into a permanent message + // so it remains visible across tool boundaries. + const materializedId = await answerLane.stream?.materialize?.(); + const previewMessageId = materializedId ?? answerLane.stream?.messageId(); + if ( + typeof previewMessageId === "number" && + activePreviewLifecycleByLane.answer === "transient" + ) { + archivedAnswerPreviews.push({ + messageId: previewMessageId, + textSnapshot: answerLane.lastPartialText, + deleteIfUnused: false, + }); + } + answerLane.stream?.forceNewMessage(); + didForceNewMessage = true; + } + resetDraftLaneState(answerLane); + if (didForceNewMessage) { + // New assistant message boundary: this lane now tracks a fresh preview lifecycle. + activePreviewLifecycleByLane.answer = "transient"; + retainPreviewOnCleanupByLane.answer = false; + } + return didForceNewMessage; + }; + const updateDraftFromPartial = (lane: DraftLaneState, text: string | undefined) => { + const laneStream = lane.stream; + if (!laneStream || !text) { + return; + } + if (text === lane.lastPartialText) { + return; + } + // Mark that we've received streaming content (for forceNewMessage decision). + lane.hasStreamedMessage = true; + // Some providers briefly emit a shorter prefix snapshot (for example + // "Sure." -> "Sure" -> "Sure."). Keep the longer preview to avoid + // visible punctuation flicker. + if ( + lane.lastPartialText && + lane.lastPartialText.startsWith(text) && + text.length < lane.lastPartialText.length + ) { + return; + } + lane.lastPartialText = text; + laneStream.update(text); + }; + const ingestDraftLaneSegments = async (text: string | undefined) => { + const split = splitTextIntoLaneSegments(text); + const hasAnswerSegment = split.segments.some((segment) => segment.lane === "answer"); + if (hasAnswerSegment && activePreviewLifecycleByLane.answer !== "transient") { + // Some providers can emit the first partial of a new assistant message before + // onAssistantMessageStart() arrives. Rotate preemptively so we do not edit + // the previously finalized preview message with the next message's text. + skipNextAnswerMessageStartRotation = await rotateAnswerLaneForNewAssistantMessage(); + } + for (const segment of split.segments) { + if (segment.lane === "reasoning") { + reasoningStepState.noteReasoningHint(); + reasoningStepState.noteReasoningDelivered(); + } + updateDraftFromPartial(lanes[segment.lane], segment.text); + } + }; + const flushDraftLane = async (lane: DraftLaneState) => { + if (!lane.stream) { + return; + } + await lane.stream.flush(); + }; + + const disableBlockStreaming = !previewStreamingEnabled + ? true + : forceBlockStreamingForReasoning + ? false + : typeof telegramCfg.blockStreaming === "boolean" + ? !telegramCfg.blockStreaming + : canStreamAnswerDraft + ? true + : undefined; + + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId: route.agentId, + channel: "telegram", + accountId: route.accountId, + }); + const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId); + + // Handle uncached stickers: get a dedicated vision description before dispatch + // This ensures we cache a raw description rather than a conversational response + const sticker = ctxPayload.Sticker; + if (sticker?.fileId && sticker.fileUniqueId && ctxPayload.MediaPath) { + const agentDir = resolveAgentDir(cfg, route.agentId); + const stickerSupportsVision = await resolveStickerVisionSupport(cfg, route.agentId); + let description = sticker.cachedDescription ?? null; + if (!description) { + description = await describeStickerImage({ + imagePath: ctxPayload.MediaPath, + cfg, + agentDir, + agentId: route.agentId, + }); + } + if (description) { + // Format the description with sticker context + const stickerContext = [sticker.emoji, sticker.setName ? `from "${sticker.setName}"` : null] + .filter(Boolean) + .join(" "); + const formattedDesc = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${description}`; + + sticker.cachedDescription = description; + if (!stickerSupportsVision) { + // Update context to use description instead of image + ctxPayload.Body = formattedDesc; + ctxPayload.BodyForAgent = formattedDesc; + // Drop only the sticker attachment; keep replied media context if present. + pruneStickerMediaFromContext(ctxPayload, { + stickerMediaIncluded: ctxPayload.StickerMediaIncluded, + }); + } + + // Cache the description for future encounters + if (sticker.fileId) { + cacheSticker({ + fileId: sticker.fileId, + fileUniqueId: sticker.fileUniqueId, + emoji: sticker.emoji, + setName: sticker.setName, + description, + cachedAt: new Date().toISOString(), + receivedFrom: ctxPayload.From, + }); + logVerbose(`telegram: cached sticker description for ${sticker.fileUniqueId}`); + } else { + logVerbose(`telegram: skipped sticker cache (missing fileId)`); + } + } + } + + const replyQuoteText = + ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody + ? ctxPayload.ReplyToBody.trim() || undefined + : undefined; + const deliveryState = createLaneDeliveryStateTracker(); + const clearGroupHistory = () => { + if (isGroup && historyKey) { + clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit }); + } + }; + const deliveryBaseOptions = { + chatId: String(chatId), + accountId: route.accountId, + sessionKeyForInternalHooks: ctxPayload.SessionKey, + mirrorIsGroup: isGroup, + mirrorGroupId: isGroup ? String(chatId) : undefined, + token: opts.token, + runtime, + bot, + mediaLocalRoots, + replyToMode, + textLimit, + thread: threadSpec, + tableMode, + chunkMode, + linkPreview: telegramCfg.linkPreview, + replyQuoteText, + }; + const applyTextToPayload = (payload: ReplyPayload, text: string): ReplyPayload => { + if (payload.text === text) { + return payload; + } + return { ...payload, text }; + }; + const sendPayload = async (payload: ReplyPayload) => { + const result = await deliverReplies({ + ...deliveryBaseOptions, + replies: [payload], + onVoiceRecording: sendRecordVoice, + }); + if (result.delivered) { + deliveryState.markDelivered(); + } + return result.delivered; + }; + const deliverLaneText = createLaneTextDeliverer({ + lanes, + archivedAnswerPreviews, + activePreviewLifecycleByLane, + retainPreviewOnCleanupByLane, + draftMaxChars, + applyTextToPayload, + sendPayload, + flushDraftLane, + stopDraftLane: async (lane) => { + await lane.stream?.stop(); + }, + editPreview: async ({ messageId, text, previewButtons }) => { + await editMessageTelegram(chatId, messageId, text, { + api: bot.api, + cfg, + accountId: route.accountId, + linkPreview: telegramCfg.linkPreview, + buttons: previewButtons, + }); + }, + deletePreviewMessage: async (messageId) => { + await bot.api.deleteMessage(chatId, messageId); + }, + log: logVerbose, + markDelivered: () => { + deliveryState.markDelivered(); + }, + }); + + let queuedFinal = false; + + if (statusReactionController) { + void statusReactionController.setThinking(); + } + + const typingCallbacks = createTypingCallbacks({ + start: sendTyping, + onStartError: (err) => { + logTypingFailure({ + log: logVerbose, + channel: "telegram", + target: String(chatId), + error: err, + }); + }, + }); + + let dispatchError: unknown; + try { + ({ queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg, + dispatcherOptions: { + ...prefixOptions, + typingCallbacks, + deliver: async (payload, info) => { + if (info.kind === "final") { + // Assistant callbacks are fire-and-forget; ensure queued boundary + // rotations/partials are applied before final delivery mapping. + await enqueueDraftLaneEvent(async () => {}); + } + if ( + shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg, + accountId: route.accountId, + payload, + }) + ) { + queuedFinal = true; + return; + } + const previewButtons = ( + payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined + )?.buttons; + const split = splitTextIntoLaneSegments(payload.text); + const segments = split.segments; + const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + + const flushBufferedFinalAnswer = async () => { + const buffered = reasoningStepState.takeBufferedFinalAnswer(); + if (!buffered) { + return; + } + const bufferedButtons = ( + buffered.payload.channelData?.telegram as + | { buttons?: TelegramInlineButtons } + | undefined + )?.buttons; + await deliverLaneText({ + laneName: "answer", + text: buffered.text, + payload: buffered.payload, + infoKind: "final", + previewButtons: bufferedButtons, + }); + reasoningStepState.resetForNextStep(); + }; + + for (const segment of segments) { + if ( + segment.lane === "answer" && + info.kind === "final" && + reasoningStepState.shouldBufferFinalAnswer() + ) { + reasoningStepState.bufferFinalAnswer({ + payload, + text: segment.text, + }); + continue; + } + if (segment.lane === "reasoning") { + reasoningStepState.noteReasoningHint(); + } + const result = await deliverLaneText({ + laneName: segment.lane, + text: segment.text, + payload, + infoKind: info.kind, + previewButtons, + allowPreviewUpdateForNonFinal: segment.lane === "reasoning", + }); + if (segment.lane === "reasoning") { + if (result !== "skipped") { + reasoningStepState.noteReasoningDelivered(); + await flushBufferedFinalAnswer(); + } + continue; + } + if (info.kind === "final") { + if (reasoningLane.hasStreamedMessage) { + activePreviewLifecycleByLane.reasoning = "complete"; + retainPreviewOnCleanupByLane.reasoning = true; + } + reasoningStepState.resetForNextStep(); + } + } + if (segments.length > 0) { + return; + } + if (split.suppressedReasoningOnly) { + if (hasMedia) { + const payloadWithoutSuppressedReasoning = + typeof payload.text === "string" ? { ...payload, text: "" } : payload; + await sendPayload(payloadWithoutSuppressedReasoning); + } + if (info.kind === "final") { + await flushBufferedFinalAnswer(); + } + return; + } + + if (info.kind === "final") { + await answerLane.stream?.stop(); + await reasoningLane.stream?.stop(); + reasoningStepState.resetForNextStep(); + } + const canSendAsIs = + hasMedia || (typeof payload.text === "string" && payload.text.length > 0); + if (!canSendAsIs) { + if (info.kind === "final") { + await flushBufferedFinalAnswer(); + } + return; + } + await sendPayload(payload); + if (info.kind === "final") { + await flushBufferedFinalAnswer(); + } + }, + onSkip: (_payload, info) => { + if (info.reason !== "silent") { + deliveryState.markNonSilentSkip(); + } + }, + onError: (err, info) => { + deliveryState.markNonSilentFailure(); + runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`)); + }, + }, + replyOptions: { + skillFilter, + disableBlockStreaming, + onPartialReply: + answerLane.stream || reasoningLane.stream + ? (payload) => + enqueueDraftLaneEvent(async () => { + await ingestDraftLaneSegments(payload.text); + }) + : undefined, + onReasoningStream: reasoningLane.stream + ? (payload) => + enqueueDraftLaneEvent(async () => { + // Split between reasoning blocks only when the next reasoning + // stream starts. Splitting at reasoning-end can orphan the active + // preview and cause duplicate reasoning sends on reasoning final. + if (splitReasoningOnNextStream) { + reasoningLane.stream?.forceNewMessage(); + resetDraftLaneState(reasoningLane); + splitReasoningOnNextStream = false; + } + await ingestDraftLaneSegments(payload.text); + }) + : undefined, + onAssistantMessageStart: answerLane.stream + ? () => + enqueueDraftLaneEvent(async () => { + reasoningStepState.resetForNextStep(); + if (skipNextAnswerMessageStartRotation) { + skipNextAnswerMessageStartRotation = false; + activePreviewLifecycleByLane.answer = "transient"; + retainPreviewOnCleanupByLane.answer = false; + return; + } + await rotateAnswerLaneForNewAssistantMessage(); + // Message-start is an explicit assistant-message boundary. + // Even when no forceNewMessage happened (e.g. prior answer had no + // streamed partials), the next partial belongs to a fresh lifecycle + // and must not trigger late pre-rotation mid-message. + activePreviewLifecycleByLane.answer = "transient"; + retainPreviewOnCleanupByLane.answer = false; + }) + : undefined, + onReasoningEnd: reasoningLane.stream + ? () => + enqueueDraftLaneEvent(async () => { + // Split when/if a later reasoning block begins. + splitReasoningOnNextStream = reasoningLane.hasStreamedMessage; + }) + : undefined, + onToolStart: statusReactionController + ? async (payload) => { + await statusReactionController.setTool(payload.name); + } + : undefined, + onCompactionStart: statusReactionController + ? () => statusReactionController.setCompacting() + : undefined, + onCompactionEnd: statusReactionController + ? async () => { + statusReactionController.cancelPending(); + await statusReactionController.setThinking(); + } + : undefined, + onModelSelected, + }, + })); + } catch (err) { + dispatchError = err; + runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`)); + } finally { + // Upstream assistant callbacks are fire-and-forget; drain queued lane work + // before stream cleanup so boundary rotations/materialization complete first. + await draftLaneEventQueue; + // Must stop() first to flush debounced content before clear() wipes state. + const streamCleanupStates = new Map< + NonNullable, + { shouldClear: boolean } + >(); + const lanesToCleanup: Array<{ laneName: LaneName; lane: DraftLaneState }> = [ + { laneName: "answer", lane: answerLane }, + { laneName: "reasoning", lane: reasoningLane }, + ]; + for (const laneState of lanesToCleanup) { + const stream = laneState.lane.stream; + if (!stream) { + continue; + } + // Don't clear (delete) the stream if: (a) it was finalized, or + // (b) the active stream message is itself a boundary-finalized archive. + const activePreviewMessageId = stream.messageId(); + const hasBoundaryFinalizedActivePreview = + laneState.laneName === "answer" && + typeof activePreviewMessageId === "number" && + archivedAnswerPreviews.some( + (p) => p.deleteIfUnused === false && p.messageId === activePreviewMessageId, + ); + const shouldClear = + !retainPreviewOnCleanupByLane[laneState.laneName] && !hasBoundaryFinalizedActivePreview; + const existing = streamCleanupStates.get(stream); + if (!existing) { + streamCleanupStates.set(stream, { shouldClear }); + continue; + } + existing.shouldClear = existing.shouldClear && shouldClear; + } + for (const [stream, cleanupState] of streamCleanupStates) { + await stream.stop(); + if (cleanupState.shouldClear) { + await stream.clear(); + } + } + for (const archivedPreview of archivedAnswerPreviews) { + if (archivedPreview.deleteIfUnused === false) { + continue; + } + try { + await bot.api.deleteMessage(chatId, archivedPreview.messageId); + } catch (err) { + logVerbose( + `telegram: archived answer preview cleanup failed (${archivedPreview.messageId}): ${String(err)}`, + ); + } + } + for (const messageId of archivedReasoningPreviewIds) { + try { + await bot.api.deleteMessage(chatId, messageId); + } catch (err) { + logVerbose( + `telegram: archived reasoning preview cleanup failed (${messageId}): ${String(err)}`, + ); + } + } + } + let sentFallback = false; + const deliverySummary = deliveryState.snapshot(); + if ( + dispatchError || + (!deliverySummary.delivered && + (deliverySummary.skippedNonSilent > 0 || deliverySummary.failedNonSilent > 0)) + ) { + const fallbackText = dispatchError + ? "Something went wrong while processing your request. Please try again." + : EMPTY_RESPONSE_FALLBACK; + const result = await deliverReplies({ + replies: [{ text: fallbackText }], + ...deliveryBaseOptions, + }); + sentFallback = result.delivered; + } + + const hasFinalResponse = queuedFinal || sentFallback; + + if (statusReactionController && !hasFinalResponse) { + void statusReactionController.setError().catch((err) => { + logVerbose(`telegram: status reaction error finalize failed: ${String(err)}`); + }); + } + + if (!hasFinalResponse) { + clearGroupHistory(); + return; + } + + if (statusReactionController) { + void statusReactionController.setDone().catch((err) => { + logVerbose(`telegram: status reaction finalize failed: ${String(err)}`); + }); + } else { + removeAckReactionAfterReply({ + removeAfterReply: removeAckAfterReply, + ackReactionPromise, + ackReactionValue: ackReactionPromise ? "ack" : null, + remove: () => reactionApi?.(chatId, msg.message_id ?? 0, []) ?? Promise.resolve(), + onError: (err) => { + if (!msg.message_id) { + return; + } + logAckFailure({ + log: logVerbose, + channel: "telegram", + target: `${chatId}/${msg.message_id}`, + error: err, + }); + }, + }); + } + clearGroupHistory(); +}; diff --git a/src/telegram/bot-message.test.ts b/extensions/telegram/src/bot-message.test.ts similarity index 100% rename from src/telegram/bot-message.test.ts rename to extensions/telegram/src/bot-message.test.ts diff --git a/extensions/telegram/src/bot-message.ts b/extensions/telegram/src/bot-message.ts new file mode 100644 index 00000000000..0a5d44c65db --- /dev/null +++ b/extensions/telegram/src/bot-message.ts @@ -0,0 +1,107 @@ +import type { ReplyToMode } from "../../../src/config/config.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.telegram.js"; +import { danger } from "../../../src/globals.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; +import { + buildTelegramMessageContext, + type BuildTelegramMessageContextParams, + type TelegramMediaRef, +} from "./bot-message-context.js"; +import { dispatchTelegramMessage } from "./bot-message-dispatch.js"; +import type { TelegramBotOptions } from "./bot.js"; +import type { TelegramContext, TelegramStreamMode } from "./bot/types.js"; + +/** Dependencies injected once when creating the message processor. */ +type TelegramMessageProcessorDeps = Omit< + BuildTelegramMessageContextParams, + "primaryCtx" | "allMedia" | "storeAllowFrom" | "options" +> & { + telegramCfg: TelegramAccountConfig; + runtime: RuntimeEnv; + replyToMode: ReplyToMode; + streamMode: TelegramStreamMode; + textLimit: number; + opts: Pick; +}; + +export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDeps) => { + const { + bot, + cfg, + account, + telegramCfg, + historyLimit, + groupHistories, + dmPolicy, + allowFrom, + groupAllowFrom, + ackReactionScope, + logger, + resolveGroupActivation, + resolveGroupRequireMention, + resolveTelegramGroupConfig, + sendChatActionHandler, + runtime, + replyToMode, + streamMode, + textLimit, + opts, + } = deps; + + return async ( + primaryCtx: TelegramContext, + allMedia: TelegramMediaRef[], + storeAllowFrom: string[], + options?: { messageIdOverride?: string; forceWasMentioned?: boolean }, + replyMedia?: TelegramMediaRef[], + ) => { + const context = await buildTelegramMessageContext({ + primaryCtx, + allMedia, + replyMedia, + storeAllowFrom, + options, + bot, + cfg, + account, + historyLimit, + groupHistories, + dmPolicy, + allowFrom, + groupAllowFrom, + ackReactionScope, + logger, + resolveGroupActivation, + resolveGroupRequireMention, + resolveTelegramGroupConfig, + sendChatActionHandler, + }); + if (!context) { + return; + } + try { + await dispatchTelegramMessage({ + context, + bot, + cfg, + runtime, + replyToMode, + streamMode, + textLimit, + telegramCfg, + opts, + }); + } catch (err) { + runtime.error?.(danger(`telegram message processing failed: ${String(err)}`)); + try { + await bot.api.sendMessage( + context.chatId, + "Something went wrong while processing your request. Please try again.", + context.threadSpec?.id != null ? { message_thread_id: context.threadSpec.id } : undefined, + ); + } catch { + // Best-effort fallback; delivery may fail if the bot was blocked or the chat is invalid. + } + } + }; +}; diff --git a/src/telegram/bot-native-command-menu.test.ts b/extensions/telegram/src/bot-native-command-menu.test.ts similarity index 100% rename from src/telegram/bot-native-command-menu.test.ts rename to extensions/telegram/src/bot-native-command-menu.test.ts diff --git a/extensions/telegram/src/bot-native-command-menu.ts b/extensions/telegram/src/bot-native-command-menu.ts new file mode 100644 index 00000000000..73fa2d2345a --- /dev/null +++ b/extensions/telegram/src/bot-native-command-menu.ts @@ -0,0 +1,254 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { Bot } from "grammy"; +import { resolveStateDir } from "../../../src/config/paths.js"; +import { + normalizeTelegramCommandName, + TELEGRAM_COMMAND_NAME_PATTERN, +} from "../../../src/config/telegram-custom-commands.js"; +import { logVerbose } from "../../../src/globals.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; +import { withTelegramApiErrorLogging } from "./api-logging.js"; + +export const TELEGRAM_MAX_COMMANDS = 100; +const TELEGRAM_COMMAND_RETRY_RATIO = 0.8; + +export type TelegramMenuCommand = { + command: string; + description: string; +}; + +type TelegramPluginCommandSpec = { + name: unknown; + description: unknown; +}; + +function isBotCommandsTooMuchError(err: unknown): boolean { + if (!err) { + return false; + } + const pattern = /\bBOT_COMMANDS_TOO_MUCH\b/i; + if (typeof err === "string") { + return pattern.test(err); + } + if (err instanceof Error) { + if (pattern.test(err.message)) { + return true; + } + } + if (typeof err === "object") { + const maybe = err as { description?: unknown; message?: unknown }; + if (typeof maybe.description === "string" && pattern.test(maybe.description)) { + return true; + } + if (typeof maybe.message === "string" && pattern.test(maybe.message)) { + return true; + } + } + return false; +} + +function formatTelegramCommandRetrySuccessLog(params: { + initialCount: number; + acceptedCount: number; +}): string { + const omittedCount = Math.max(0, params.initialCount - params.acceptedCount); + return ( + `Telegram accepted ${params.acceptedCount} commands after BOT_COMMANDS_TOO_MUCH ` + + `(started with ${params.initialCount}; omitted ${omittedCount}). ` + + "Reduce plugin/skill/custom commands to expose more menu entries." + ); +} + +export function buildPluginTelegramMenuCommands(params: { + specs: TelegramPluginCommandSpec[]; + existingCommands: Set; +}): { commands: TelegramMenuCommand[]; issues: string[] } { + const { specs, existingCommands } = params; + const commands: TelegramMenuCommand[] = []; + const issues: string[] = []; + const pluginCommandNames = new Set(); + + for (const spec of specs) { + const rawName = typeof spec.name === "string" ? spec.name : ""; + const normalized = normalizeTelegramCommandName(rawName); + if (!normalized || !TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) { + const invalidName = rawName.trim() ? rawName : ""; + issues.push( + `Plugin command "/${invalidName}" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).`, + ); + continue; + } + const description = typeof spec.description === "string" ? spec.description.trim() : ""; + if (!description) { + issues.push(`Plugin command "/${normalized}" is missing a description.`); + continue; + } + if (existingCommands.has(normalized)) { + if (pluginCommandNames.has(normalized)) { + issues.push(`Plugin command "/${normalized}" is duplicated.`); + } else { + issues.push(`Plugin command "/${normalized}" conflicts with an existing Telegram command.`); + } + continue; + } + pluginCommandNames.add(normalized); + existingCommands.add(normalized); + commands.push({ command: normalized, description }); + } + + return { commands, issues }; +} + +export function buildCappedTelegramMenuCommands(params: { + allCommands: TelegramMenuCommand[]; + maxCommands?: number; +}): { + commandsToRegister: TelegramMenuCommand[]; + totalCommands: number; + maxCommands: number; + overflowCount: number; +} { + const { allCommands } = params; + const maxCommands = params.maxCommands ?? TELEGRAM_MAX_COMMANDS; + const totalCommands = allCommands.length; + const overflowCount = Math.max(0, totalCommands - maxCommands); + const commandsToRegister = allCommands.slice(0, maxCommands); + return { commandsToRegister, totalCommands, maxCommands, overflowCount }; +} + +/** Compute a stable hash of the command list for change detection. */ +export function hashCommandList(commands: TelegramMenuCommand[]): string { + const sorted = [...commands].toSorted((a, b) => a.command.localeCompare(b.command)); + return createHash("sha256").update(JSON.stringify(sorted)).digest("hex").slice(0, 16); +} + +function hashBotIdentity(botIdentity?: string): string { + const normalized = botIdentity?.trim(); + if (!normalized) { + return "no-bot"; + } + return createHash("sha256").update(normalized).digest("hex").slice(0, 16); +} + +function resolveCommandHashPath(accountId?: string, botIdentity?: string): string { + const stateDir = resolveStateDir(process.env, os.homedir); + const normalizedAccount = accountId?.trim().replace(/[^a-z0-9._-]+/gi, "_") || "default"; + const botHash = hashBotIdentity(botIdentity); + return path.join(stateDir, "telegram", `command-hash-${normalizedAccount}-${botHash}.txt`); +} + +async function readCachedCommandHash( + accountId?: string, + botIdentity?: string, +): Promise { + try { + return (await fs.readFile(resolveCommandHashPath(accountId, botIdentity), "utf-8")).trim(); + } catch { + return null; + } +} + +async function writeCachedCommandHash( + accountId: string | undefined, + botIdentity: string | undefined, + hash: string, +): Promise { + const filePath = resolveCommandHashPath(accountId, botIdentity); + try { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, hash, "utf-8"); + } catch { + // Best-effort: failing to cache the hash just means the next restart + // will sync commands again, which is the pre-fix behaviour. + } +} + +export function syncTelegramMenuCommands(params: { + bot: Bot; + runtime: RuntimeEnv; + commandsToRegister: TelegramMenuCommand[]; + accountId?: string; + botIdentity?: string; +}): void { + const { bot, runtime, commandsToRegister, accountId, botIdentity } = params; + const sync = async () => { + // Skip sync if the command list hasn't changed since the last successful + // sync. This prevents hitting Telegram's 429 rate limit when the gateway + // is restarted several times in quick succession. + // See: openclaw/openclaw#32017 + const currentHash = hashCommandList(commandsToRegister); + const cachedHash = await readCachedCommandHash(accountId, botIdentity); + if (cachedHash === currentHash) { + logVerbose("telegram: command menu unchanged; skipping sync"); + return; + } + + // Keep delete -> set ordering to avoid stale deletions racing after fresh registrations. + let deleteSucceeded = true; + if (typeof bot.api.deleteMyCommands === "function") { + deleteSucceeded = await withTelegramApiErrorLogging({ + operation: "deleteMyCommands", + runtime, + fn: () => bot.api.deleteMyCommands(), + }) + .then(() => true) + .catch(() => false); + } + + if (commandsToRegister.length === 0) { + if (!deleteSucceeded) { + runtime.log?.("telegram: deleteMyCommands failed; skipping empty-menu hash cache write"); + return; + } + await writeCachedCommandHash(accountId, botIdentity, currentHash); + return; + } + + let retryCommands = commandsToRegister; + const initialCommandCount = commandsToRegister.length; + while (retryCommands.length > 0) { + try { + await withTelegramApiErrorLogging({ + operation: "setMyCommands", + runtime, + shouldLog: (err) => !isBotCommandsTooMuchError(err), + fn: () => bot.api.setMyCommands(retryCommands), + }); + if (retryCommands.length < initialCommandCount) { + runtime.log?.( + formatTelegramCommandRetrySuccessLog({ + initialCount: initialCommandCount, + acceptedCount: retryCommands.length, + }), + ); + } + await writeCachedCommandHash(accountId, botIdentity, currentHash); + return; + } catch (err) { + if (!isBotCommandsTooMuchError(err)) { + throw err; + } + const nextCount = Math.floor(retryCommands.length * TELEGRAM_COMMAND_RETRY_RATIO); + const reducedCount = + nextCount < retryCommands.length ? nextCount : retryCommands.length - 1; + if (reducedCount <= 0) { + runtime.error?.( + "Telegram rejected native command registration (BOT_COMMANDS_TOO_MUCH); leaving menu empty. Reduce commands or disable channels.telegram.commands.native.", + ); + return; + } + runtime.log?.( + `Telegram rejected ${retryCommands.length} commands (BOT_COMMANDS_TOO_MUCH); retrying with ${reducedCount}.`, + ); + retryCommands = retryCommands.slice(0, reducedCount); + } + } + }; + + void sync().catch((err) => { + runtime.error?.(`Telegram command sync failed: ${String(err)}`); + }); +} diff --git a/extensions/telegram/src/bot-native-commands.group-auth.test.ts b/extensions/telegram/src/bot-native-commands.group-auth.test.ts new file mode 100644 index 00000000000..efee344b907 --- /dev/null +++ b/extensions/telegram/src/bot-native-commands.group-auth.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.js"; +import { + createNativeCommandsHarness, + createTelegramGroupCommandContext, + findNotAuthorizedCalls, +} from "./bot-native-commands.test-helpers.js"; + +describe("native command auth in groups", () => { + function setup(params: { + cfg?: OpenClawConfig; + telegramCfg?: TelegramAccountConfig; + allowFrom?: string[]; + groupAllowFrom?: string[]; + useAccessGroups?: boolean; + groupConfig?: Record; + resolveGroupPolicy?: () => ChannelGroupPolicy; + }) { + return createNativeCommandsHarness({ + cfg: params.cfg ?? ({} as OpenClawConfig), + telegramCfg: params.telegramCfg ?? ({} as TelegramAccountConfig), + allowFrom: params.allowFrom ?? [], + groupAllowFrom: params.groupAllowFrom ?? [], + useAccessGroups: params.useAccessGroups ?? false, + resolveGroupPolicy: + params.resolveGroupPolicy ?? + (() => + ({ + allowlistEnabled: false, + allowed: true, + }) as ChannelGroupPolicy), + groupConfig: params.groupConfig, + }); + } + + it("authorizes native commands in groups when sender is in groupAllowFrom", async () => { + const { handlers, sendMessage } = setup({ + groupAllowFrom: ["12345"], + useAccessGroups: true, + // no allowFrom — sender is NOT in DM allowlist + }); + + const ctx = createTelegramGroupCommandContext(); + + await handlers.status?.(ctx); + + const notAuthCalls = findNotAuthorizedCalls(sendMessage); + expect(notAuthCalls).toHaveLength(0); + }); + + it("authorizes native commands in groups from commands.allowFrom.telegram", async () => { + const { handlers, sendMessage } = setup({ + cfg: { + commands: { + allowFrom: { + telegram: ["12345"], + }, + }, + } as OpenClawConfig, + allowFrom: ["99999"], + groupAllowFrom: ["99999"], + useAccessGroups: true, + }); + + const ctx = createTelegramGroupCommandContext(); + + await handlers.status?.(ctx); + + const notAuthCalls = findNotAuthorizedCalls(sendMessage); + expect(notAuthCalls).toHaveLength(0); + }); + + it("uses commands.allowFrom.telegram as the sole auth source when configured", async () => { + const { handlers, sendMessage } = setup({ + cfg: { + commands: { + allowFrom: { + telegram: ["99999"], + }, + }, + } as OpenClawConfig, + groupAllowFrom: ["12345"], + useAccessGroups: true, + }); + + const ctx = createTelegramGroupCommandContext(); + + await handlers.status?.(ctx); + + expect(sendMessage).toHaveBeenCalledWith( + -100999, + "You are not authorized to use this command.", + expect.objectContaining({ message_thread_id: 42 }), + ); + }); + + it("keeps groupPolicy disabled enforced when commands.allowFrom is configured", async () => { + const { handlers, sendMessage } = setup({ + cfg: { + commands: { + allowFrom: { + telegram: ["12345"], + }, + }, + } as OpenClawConfig, + telegramCfg: { + groupPolicy: "disabled", + } as TelegramAccountConfig, + useAccessGroups: true, + resolveGroupPolicy: () => + ({ + allowlistEnabled: false, + allowed: false, + }) as ChannelGroupPolicy, + }); + + const ctx = createTelegramGroupCommandContext(); + + await handlers.status?.(ctx); + + expect(sendMessage).toHaveBeenCalledWith( + -100999, + "Telegram group commands are disabled.", + expect.objectContaining({ message_thread_id: 42 }), + ); + }); + + it("keeps group chat allowlists enforced when commands.allowFrom is configured", async () => { + const { handlers, sendMessage } = setup({ + cfg: { + commands: { + allowFrom: { + telegram: ["12345"], + }, + }, + } as OpenClawConfig, + useAccessGroups: true, + resolveGroupPolicy: () => + ({ + allowlistEnabled: true, + allowed: false, + }) as ChannelGroupPolicy, + }); + + const ctx = createTelegramGroupCommandContext(); + + await handlers.status?.(ctx); + + expect(sendMessage).toHaveBeenCalledWith( + -100999, + "This group is not allowed.", + expect.objectContaining({ message_thread_id: 42 }), + ); + }); + + it("rejects native commands in groups when sender is in neither allowlist", async () => { + const { handlers, sendMessage } = setup({ + allowFrom: ["99999"], + groupAllowFrom: ["99999"], + useAccessGroups: true, + }); + + const ctx = createTelegramGroupCommandContext({ + username: "intruder", + }); + + await handlers.status?.(ctx); + + const notAuthCalls = findNotAuthorizedCalls(sendMessage); + expect(notAuthCalls.length).toBeGreaterThan(0); + }); + + it("replies in the originating forum topic when auth is rejected", async () => { + const { handlers, sendMessage } = setup({ + allowFrom: ["99999"], + groupAllowFrom: ["99999"], + useAccessGroups: true, + }); + + const ctx = createTelegramGroupCommandContext({ + username: "intruder", + }); + + await handlers.status?.(ctx); + + expect(sendMessage).toHaveBeenCalledWith( + -100999, + "You are not authorized to use this command.", + expect.objectContaining({ message_thread_id: 42 }), + ); + }); +}); diff --git a/src/telegram/bot-native-commands.plugin-auth.test.ts b/extensions/telegram/src/bot-native-commands.plugin-auth.test.ts similarity index 86% rename from src/telegram/bot-native-commands.plugin-auth.test.ts rename to extensions/telegram/src/bot-native-commands.plugin-auth.test.ts index d611250bdeb..68268fb047b 100644 --- a/src/telegram/bot-native-commands.plugin-auth.test.ts +++ b/extensions/telegram/src/bot-native-commands.plugin-auth.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import type { TelegramAccountConfig } from "../config/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.js"; import { createNativeCommandsHarness, deliverReplies, @@ -11,17 +11,19 @@ import { type GetPluginCommandSpecsMock = { mockReturnValue: ( - value: ReturnType, + value: ReturnType, ) => unknown; }; type MatchPluginCommandMock = { mockReturnValue: ( - value: ReturnType, + value: ReturnType, ) => unknown; }; type ExecutePluginCommandMock = { mockResolvedValue: ( - value: Awaited>, + value: Awaited< + ReturnType + >, ) => unknown; }; diff --git a/src/telegram/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts similarity index 94% rename from src/telegram/bot-native-commands.session-meta.test.ts rename to extensions/telegram/src/bot-native-commands.session-meta.test.ts index 43b5bb4133f..db3fdc23bba 100644 --- a/src/telegram/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { registerTelegramNativeCommands, type RegisterTelegramHandlerParams, @@ -10,11 +10,11 @@ type RegisterTelegramNativeCommandsParams = Parameters[0]; type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< @@ -54,31 +54,31 @@ const sessionBindingMocks = vi.hoisted(() => ({ touch: vi.fn(), })); -vi.mock("../acp/persistent-bindings.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/acp/persistent-bindings.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveConfiguredAcpBindingRecord: persistentBindingMocks.resolveConfiguredAcpBindingRecord, ensureConfiguredAcpBindingSession: persistentBindingMocks.ensureConfiguredAcpBindingSession, }; }); -vi.mock("../config/sessions.js", () => ({ +vi.mock("../../../src/config/sessions.js", () => ({ recordSessionMetaFromInbound: sessionMocks.recordSessionMetaFromInbound, resolveStorePath: sessionMocks.resolveStorePath, })); -vi.mock("../pairing/pairing-store.js", () => ({ +vi.mock("../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: vi.fn(async () => []), })); -vi.mock("../auto-reply/reply/inbound-context.js", () => ({ +vi.mock("../../../src/auto-reply/reply/inbound-context.js", () => ({ finalizeInboundContext: vi.fn((ctx: unknown) => ctx), })); -vi.mock("../auto-reply/reply/provider-dispatcher.js", () => ({ +vi.mock("../../../src/auto-reply/reply/provider-dispatcher.js", () => ({ dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher, })); -vi.mock("../channels/reply-prefix.js", () => ({ +vi.mock("../../../src/channels/reply-prefix.js", () => ({ createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })), })); -vi.mock("../infra/outbound/session-binding-service.js", () => ({ +vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({ getSessionBindingService: () => ({ bind: vi.fn(), getCapabilities: vi.fn(), @@ -88,11 +88,11 @@ vi.mock("../infra/outbound/session-binding-service.js", () => ({ unbind: vi.fn(), }), })); -vi.mock("../auto-reply/skill-commands.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/auto-reply/skill-commands.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, listSkillCommandsForAgents: vi.fn(() => []) }; }); -vi.mock("../plugins/commands.js", () => ({ +vi.mock("../../../src/plugins/commands.js", () => ({ getPluginCommandSpecs: vi.fn(() => []), matchPluginCommand: vi.fn(() => null), executePluginCommand: vi.fn(async () => ({ text: "ok" })), @@ -300,7 +300,7 @@ function createConfiguredAcpTopicBinding(boundSessionKey: string) { status: "active", boundAt: 0, }, - } satisfies import("../acp/persistent-bindings.js").ResolvedConfiguredAcpBinding; + } satisfies import("../../../src/acp/persistent-bindings.js").ResolvedConfiguredAcpBinding; } function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType) { diff --git a/src/telegram/bot-native-commands.skills-allowlist.test.ts b/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts similarity index 93% rename from src/telegram/bot-native-commands.skills-allowlist.test.ts rename to extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts index 40a428064e1..c026392f9f9 100644 --- a/src/telegram/bot-native-commands.skills-allowlist.test.ts +++ b/extensions/telegram/src/bot-native-commands.skills-allowlist.test.ts @@ -2,9 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { writeSkill } from "../agents/skills.e2e-test-helpers.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { TelegramAccountConfig } from "../config/types.js"; +import { writeSkill } from "../../../src/agents/skills.e2e-test-helpers.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; const pluginCommandMocks = vi.hoisted(() => ({ @@ -16,7 +16,7 @@ const deliveryMocks = vi.hoisted(() => ({ deliverReplies: vi.fn(async () => ({ delivered: true })), })); -vi.mock("../plugins/commands.js", () => ({ +vi.mock("../../../src/plugins/commands.js", () => ({ getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs, matchPluginCommand: pluginCommandMocks.matchPluginCommand, executePluginCommand: pluginCommandMocks.executePluginCommand, diff --git a/src/telegram/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts similarity index 88% rename from src/telegram/bot-native-commands.test-helpers.ts rename to extensions/telegram/src/bot-native-commands.test-helpers.ts index eef028c8315..0b4babb180e 100644 --- a/src/telegram/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -1,15 +1,17 @@ import { vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import type { ChannelGroupPolicy } from "../config/group-policy.js"; -import type { TelegramAccountConfig } from "../config/types.js"; -import type { RuntimeEnv } from "../runtime.js"; -import type { MockFn } from "../test-utils/vitest-mock-fn.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; +import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; type RegisterTelegramNativeCommandsParams = Parameters[0]; -type GetPluginCommandSpecsFn = typeof import("../plugins/commands.js").getPluginCommandSpecs; -type MatchPluginCommandFn = typeof import("../plugins/commands.js").matchPluginCommand; -type ExecutePluginCommandFn = typeof import("../plugins/commands.js").executePluginCommand; +type GetPluginCommandSpecsFn = + typeof import("../../../src/plugins/commands.js").getPluginCommandSpecs; +type MatchPluginCommandFn = typeof import("../../../src/plugins/commands.js").matchPluginCommand; +type ExecutePluginCommandFn = + typeof import("../../../src/plugins/commands.js").executePluginCommand; type AnyMock = MockFn<(...args: unknown[]) => unknown>; type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise>; type NativeCommandHarness = { @@ -35,7 +37,7 @@ export const getPluginCommandSpecs = pluginCommandMocks.getPluginCommandSpecs; export const matchPluginCommand = pluginCommandMocks.matchPluginCommand; export const executePluginCommand = pluginCommandMocks.executePluginCommand; -vi.mock("../plugins/commands.js", () => ({ +vi.mock("../../../src/plugins/commands.js", () => ({ getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs, matchPluginCommand: pluginCommandMocks.matchPluginCommand, executePluginCommand: pluginCommandMocks.executePluginCommand, @@ -46,7 +48,7 @@ const deliveryMocks = vi.hoisted(() => ({ })); export const deliverReplies = deliveryMocks.deliverReplies; vi.mock("./bot/delivery.js", () => ({ deliverReplies: deliveryMocks.deliverReplies })); -vi.mock("../pairing/pairing-store.js", () => ({ +vi.mock("../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: vi.fn(async () => []), })); diff --git a/src/telegram/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts similarity index 94% rename from src/telegram/bot-native-commands.test.ts rename to extensions/telegram/src/bot-native-commands.test.ts index a208649c62b..f6ebfe0dfe8 100644 --- a/src/telegram/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -1,10 +1,10 @@ import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { STATE_DIR } from "../config/paths.js"; -import { TELEGRAM_COMMAND_NAME_PATTERN } from "../config/telegram-custom-commands.js"; -import type { TelegramAccountConfig } from "../config/types.js"; -import type { RuntimeEnv } from "../runtime.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { STATE_DIR } from "../../../src/config/paths.js"; +import { TELEGRAM_COMMAND_NAME_PATTERN } from "../../../src/config/telegram-custom-commands.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; const { listSkillCommandsForAgents } = vi.hoisted(() => ({ @@ -19,14 +19,14 @@ const deliveryMocks = vi.hoisted(() => ({ deliverReplies: vi.fn(async () => ({ delivered: true })), })); -vi.mock("../auto-reply/skill-commands.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/auto-reply/skill-commands.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, listSkillCommandsForAgents, }; }); -vi.mock("../plugins/commands.js", () => ({ +vi.mock("../../../src/plugins/commands.js", () => ({ getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs, matchPluginCommand: pluginCommandMocks.matchPluginCommand, executePluginCommand: pluginCommandMocks.executePluginCommand, diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts new file mode 100644 index 00000000000..7dd91f6ad63 --- /dev/null +++ b/extensions/telegram/src/bot-native-commands.ts @@ -0,0 +1,900 @@ +import type { Bot, Context } from "grammy"; +import { ensureConfiguredAcpRouteReady } from "../../../src/acp/persistent-bindings.route.js"; +import { resolveChunkMode } from "../../../src/auto-reply/chunk.js"; +import { resolveCommandAuthorization } from "../../../src/auto-reply/command-auth.js"; +import type { CommandArgs } from "../../../src/auto-reply/commands-registry.js"; +import { + buildCommandTextFromArgs, + findCommandByNativeName, + listNativeCommandSpecs, + listNativeCommandSpecsForConfig, + parseCommandArgs, + resolveCommandArgMenu, +} from "../../../src/auto-reply/commands-registry.js"; +import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js"; +import { dispatchReplyWithBufferedBlockDispatcher } from "../../../src/auto-reply/reply/provider-dispatcher.js"; +import { listSkillCommandsForAgents } from "../../../src/auto-reply/skill-commands.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "../../../src/channels/command-gating.js"; +import { resolveNativeCommandSessionTargets } from "../../../src/channels/native-command-session-targets.js"; +import { createReplyPrefixOptions } from "../../../src/channels/reply-prefix.js"; +import { recordInboundSessionMetaSafe } from "../../../src/channels/session-meta.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js"; +import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; +import { + normalizeTelegramCommandName, + resolveTelegramCustomCommands, + TELEGRAM_COMMAND_NAME_PATTERN, +} from "../../../src/config/telegram-custom-commands.js"; +import type { + ReplyToMode, + TelegramAccountConfig, + TelegramDirectConfig, + TelegramGroupConfig, + TelegramTopicConfig, +} from "../../../src/config/types.js"; +import { danger, logVerbose } from "../../../src/globals.js"; +import { getChildLogger } from "../../../src/logging.js"; +import { getAgentScopedMediaLocalRoots } from "../../../src/media/local-roots.js"; +import { + executePluginCommand, + getPluginCommandSpecs, + matchPluginCommand, +} from "../../../src/plugins/commands.js"; +import { resolveAgentRoute } from "../../../src/routing/resolve-route.js"; +import { resolveThreadSessionKeys } from "../../../src/routing/session-key.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; +import { withTelegramApiErrorLogging } from "./api-logging.js"; +import { isSenderAllowed, normalizeDmAllowFromWithStore } from "./bot-access.js"; +import type { TelegramMediaRef } from "./bot-message-context.js"; +import { + buildCappedTelegramMenuCommands, + buildPluginTelegramMenuCommands, + syncTelegramMenuCommands, +} from "./bot-native-command-menu.js"; +import { TelegramUpdateKeyContext } from "./bot-updates.js"; +import { TelegramBotOptions } from "./bot.js"; +import { deliverReplies } from "./bot/delivery.js"; +import { + buildTelegramThreadParams, + buildSenderName, + buildTelegramGroupFrom, + resolveTelegramGroupAllowFromContext, + resolveTelegramThreadSpec, +} from "./bot/helpers.js"; +import type { TelegramContext } from "./bot/types.js"; +import { resolveTelegramConversationRoute } from "./conversation-route.js"; +import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js"; +import type { TelegramTransport } from "./fetch.js"; +import { + evaluateTelegramGroupBaseAccess, + evaluateTelegramGroupPolicyAccess, +} from "./group-access.js"; +import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js"; +import { buildInlineKeyboard } from "./send.js"; + +const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; + +type TelegramNativeCommandContext = Context & { match?: string }; + +type TelegramCommandAuthResult = { + chatId: number; + isGroup: boolean; + isForum: boolean; + resolvedThreadId?: number; + senderId: string; + senderUsername: string; + groupConfig?: TelegramGroupConfig; + topicConfig?: TelegramTopicConfig; + commandAuthorized: boolean; +}; + +export type RegisterTelegramHandlerParams = { + cfg: OpenClawConfig; + accountId: string; + bot: Bot; + mediaMaxBytes: number; + opts: TelegramBotOptions; + telegramTransport?: TelegramTransport; + runtime: RuntimeEnv; + telegramCfg: TelegramAccountConfig; + allowFrom?: Array; + groupAllowFrom?: Array; + resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy; + resolveTelegramGroupConfig: ( + chatId: string | number, + messageThreadId?: number, + ) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig }; + shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean; + processMessage: ( + ctx: TelegramContext, + allMedia: TelegramMediaRef[], + storeAllowFrom: string[], + options?: { + messageIdOverride?: string; + forceWasMentioned?: boolean; + }, + replyMedia?: TelegramMediaRef[], + ) => Promise; + logger: ReturnType; +}; + +type RegisterTelegramNativeCommandsParams = { + bot: Bot; + cfg: OpenClawConfig; + runtime: RuntimeEnv; + accountId: string; + telegramCfg: TelegramAccountConfig; + allowFrom?: Array; + groupAllowFrom?: Array; + replyToMode: ReplyToMode; + textLimit: number; + useAccessGroups: boolean; + nativeEnabled: boolean; + nativeSkillsEnabled: boolean; + nativeDisabledExplicit: boolean; + resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy; + resolveTelegramGroupConfig: ( + chatId: string | number, + messageThreadId?: number, + ) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig }; + shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean; + opts: { token: string }; +}; + +async function resolveTelegramCommandAuth(params: { + msg: NonNullable; + bot: Bot; + cfg: OpenClawConfig; + accountId: string; + telegramCfg: TelegramAccountConfig; + allowFrom?: Array; + groupAllowFrom?: Array; + useAccessGroups: boolean; + resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy; + resolveTelegramGroupConfig: ( + chatId: string | number, + messageThreadId?: number, + ) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig }; + requireAuth: boolean; +}): Promise { + const { + msg, + bot, + cfg, + accountId, + telegramCfg, + allowFrom, + groupAllowFrom, + useAccessGroups, + resolveGroupPolicy, + resolveTelegramGroupConfig, + requireAuth, + } = params; + const chatId = msg.chat.id; + const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; + const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; + const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true; + const threadSpec = resolveTelegramThreadSpec({ + isGroup, + isForum, + messageThreadId, + }); + const threadParams = buildTelegramThreadParams(threadSpec) ?? {}; + const groupAllowContext = await resolveTelegramGroupAllowFromContext({ + chatId, + accountId, + isGroup, + isForum, + messageThreadId, + groupAllowFrom, + resolveTelegramGroupConfig, + }); + const { + resolvedThreadId, + dmThreadId, + storeAllowFrom, + groupConfig, + topicConfig, + groupAllowOverride, + effectiveGroupAllow, + hasGroupAllowOverride, + } = groupAllowContext; + // Use direct config dmPolicy override if available for DMs + const effectiveDmPolicy = + !isGroup && groupConfig && "dmPolicy" in groupConfig + ? (groupConfig.dmPolicy ?? telegramCfg.dmPolicy ?? "pairing") + : (telegramCfg.dmPolicy ?? "pairing"); + const requireTopic = (groupConfig as TelegramDirectConfig | undefined)?.requireTopic; + if (!isGroup && requireTopic === true && dmThreadId == null) { + logVerbose(`Blocked telegram command in DM ${chatId}: requireTopic=true but no topic present`); + return null; + } + // For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom + const dmAllowFrom = groupAllowOverride ?? allowFrom; + const senderId = msg.from?.id ? String(msg.from.id) : ""; + const senderUsername = msg.from?.username ?? ""; + const commandsAllowFrom = cfg.commands?.allowFrom; + const commandsAllowFromConfigured = + commandsAllowFrom != null && + typeof commandsAllowFrom === "object" && + (Array.isArray(commandsAllowFrom.telegram) || Array.isArray(commandsAllowFrom["*"])); + const commandsAllowFromAccess = commandsAllowFromConfigured + ? resolveCommandAuthorization({ + ctx: { + Provider: "telegram", + Surface: "telegram", + OriginatingChannel: "telegram", + AccountId: accountId, + ChatType: isGroup ? "group" : "direct", + From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`, + SenderId: senderId || undefined, + SenderUsername: senderUsername || undefined, + }, + cfg, + // commands.allowFrom is the only auth source when configured. + commandAuthorized: false, + }) + : null; + + const sendAuthMessage = async (text: string) => { + await withTelegramApiErrorLogging({ + operation: "sendMessage", + fn: () => bot.api.sendMessage(chatId, text, threadParams), + }); + return null; + }; + const rejectNotAuthorized = async () => { + return await sendAuthMessage("You are not authorized to use this command."); + }; + + const baseAccess = evaluateTelegramGroupBaseAccess({ + isGroup, + groupConfig, + topicConfig, + hasGroupAllowOverride, + effectiveGroupAllow, + senderId, + senderUsername, + enforceAllowOverride: requireAuth, + requireSenderForAllowOverride: true, + }); + if (!baseAccess.allowed) { + if (baseAccess.reason === "group-disabled") { + return await sendAuthMessage("This group is disabled."); + } + if (baseAccess.reason === "topic-disabled") { + return await sendAuthMessage("This topic is disabled."); + } + return await rejectNotAuthorized(); + } + + const policyAccess = evaluateTelegramGroupPolicyAccess({ + isGroup, + chatId, + cfg, + telegramCfg, + topicConfig, + groupConfig, + effectiveGroupAllow, + senderId, + senderUsername, + resolveGroupPolicy, + enforcePolicy: useAccessGroups, + useTopicAndGroupOverrides: false, + enforceAllowlistAuthorization: requireAuth && !commandsAllowFromConfigured, + allowEmptyAllowlistEntries: true, + requireSenderForAllowlistAuthorization: true, + checkChatAllowlist: useAccessGroups, + }); + if (!policyAccess.allowed) { + if (policyAccess.reason === "group-policy-disabled") { + return await sendAuthMessage("Telegram group commands are disabled."); + } + if ( + policyAccess.reason === "group-policy-allowlist-no-sender" || + policyAccess.reason === "group-policy-allowlist-unauthorized" + ) { + return await rejectNotAuthorized(); + } + if (policyAccess.reason === "group-chat-not-allowed") { + return await sendAuthMessage("This group is not allowed."); + } + } + + const dmAllow = normalizeDmAllowFromWithStore({ + allowFrom: dmAllowFrom, + storeAllowFrom: isGroup ? [] : storeAllowFrom, + dmPolicy: effectiveDmPolicy, + }); + const senderAllowed = isSenderAllowed({ + allow: dmAllow, + senderId, + senderUsername, + }); + const groupSenderAllowed = isGroup + ? isSenderAllowed({ allow: effectiveGroupAllow, senderId, senderUsername }) + : false; + const commandAuthorized = commandsAllowFromConfigured + ? Boolean(commandsAllowFromAccess?.isAuthorizedSender) + : resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers: [ + { configured: dmAllow.hasEntries, allowed: senderAllowed }, + ...(isGroup + ? [{ configured: effectiveGroupAllow.hasEntries, allowed: groupSenderAllowed }] + : []), + ], + modeWhenAccessGroupsOff: "configured", + }); + if (requireAuth && !commandAuthorized) { + return await rejectNotAuthorized(); + } + + return { + chatId, + isGroup, + isForum, + resolvedThreadId, + senderId, + senderUsername, + groupConfig, + topicConfig, + commandAuthorized, + }; +} + +export const registerTelegramNativeCommands = ({ + bot, + cfg, + runtime, + accountId, + telegramCfg, + allowFrom, + groupAllowFrom, + replyToMode, + textLimit, + useAccessGroups, + nativeEnabled, + nativeSkillsEnabled, + nativeDisabledExplicit, + resolveGroupPolicy, + resolveTelegramGroupConfig, + shouldSkipUpdate, + opts, +}: RegisterTelegramNativeCommandsParams) => { + const boundRoute = + nativeEnabled && nativeSkillsEnabled + ? resolveAgentRoute({ cfg, channel: "telegram", accountId }) + : null; + if (nativeEnabled && nativeSkillsEnabled && !boundRoute) { + runtime.log?.( + "nativeSkillsEnabled is true but no agent route is bound for this Telegram account; skill commands will not appear in the native menu.", + ); + } + const skillCommands = + nativeEnabled && nativeSkillsEnabled && boundRoute + ? listSkillCommandsForAgents({ cfg, agentIds: [boundRoute.agentId] }) + : []; + const nativeCommands = nativeEnabled + ? listNativeCommandSpecsForConfig(cfg, { + skillCommands, + provider: "telegram", + }) + : []; + const reservedCommands = new Set( + listNativeCommandSpecs().map((command) => normalizeTelegramCommandName(command.name)), + ); + for (const command of skillCommands) { + reservedCommands.add(command.name.toLowerCase()); + } + const customResolution = resolveTelegramCustomCommands({ + commands: telegramCfg.customCommands, + reservedCommands, + }); + for (const issue of customResolution.issues) { + runtime.error?.(danger(issue.message)); + } + const customCommands = customResolution.commands; + const pluginCommandSpecs = getPluginCommandSpecs("telegram"); + const existingCommands = new Set( + [ + ...nativeCommands.map((command) => normalizeTelegramCommandName(command.name)), + ...customCommands.map((command) => command.command), + ].map((command) => command.toLowerCase()), + ); + const pluginCatalog = buildPluginTelegramMenuCommands({ + specs: pluginCommandSpecs, + existingCommands, + }); + for (const issue of pluginCatalog.issues) { + runtime.error?.(danger(issue)); + } + const allCommandsFull: Array<{ command: string; description: string }> = [ + ...nativeCommands + .map((command) => { + const normalized = normalizeTelegramCommandName(command.name); + if (!TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) { + runtime.error?.( + danger( + `Native command "${command.name}" is invalid for Telegram (resolved to "${normalized}"). Skipping.`, + ), + ); + return null; + } + return { + command: normalized, + description: command.description, + }; + }) + .filter((cmd): cmd is { command: string; description: string } => cmd !== null), + ...(nativeEnabled ? pluginCatalog.commands : []), + ...customCommands, + ]; + const { commandsToRegister, totalCommands, maxCommands, overflowCount } = + buildCappedTelegramMenuCommands({ + allCommands: allCommandsFull, + }); + if (overflowCount > 0) { + runtime.log?.( + `Telegram limits bots to ${maxCommands} commands. ` + + `${totalCommands} configured; registering first ${maxCommands}. ` + + `Use channels.telegram.commands.native: false to disable, or reduce plugin/skill/custom commands.`, + ); + } + // Telegram only limits the setMyCommands payload (menu entries). + // Keep hidden commands callable by registering handlers for the full catalog. + syncTelegramMenuCommands({ + bot, + runtime, + commandsToRegister, + accountId, + botIdentity: opts.token, + }); + + const resolveCommandRuntimeContext = async (params: { + msg: NonNullable; + isGroup: boolean; + isForum: boolean; + resolvedThreadId?: number; + senderId?: string; + topicAgentId?: string; + }): Promise<{ + chatId: number; + threadSpec: ReturnType; + route: ReturnType["route"]; + mediaLocalRoots: readonly string[] | undefined; + tableMode: ReturnType; + chunkMode: ReturnType; + } | null> => { + const { msg, isGroup, isForum, resolvedThreadId, senderId, topicAgentId } = params; + const chatId = msg.chat.id; + const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; + const threadSpec = resolveTelegramThreadSpec({ + isGroup, + isForum, + messageThreadId, + }); + let { route, configuredBinding } = resolveTelegramConversationRoute({ + cfg, + accountId, + chatId, + isGroup, + resolvedThreadId, + replyThreadId: threadSpec.id, + senderId, + topicAgentId, + }); + if (configuredBinding) { + const ensured = await ensureConfiguredAcpRouteReady({ + cfg, + configuredBinding, + }); + if (!ensured.ok) { + logVerbose( + `telegram native command: configured ACP binding unavailable for topic ${configuredBinding.spec.conversationId}: ${ensured.error}`, + ); + await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime, + fn: () => + bot.api.sendMessage( + chatId, + "Configured ACP binding is unavailable right now. Please try again.", + buildTelegramThreadParams(threadSpec) ?? {}, + ), + }); + return null; + } + } + const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "telegram", + accountId: route.accountId, + }); + const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId); + return { chatId, threadSpec, route, mediaLocalRoots, tableMode, chunkMode }; + }; + const buildCommandDeliveryBaseOptions = (params: { + chatId: string | number; + accountId: string; + sessionKeyForInternalHooks?: string; + mirrorIsGroup?: boolean; + mirrorGroupId?: string; + mediaLocalRoots?: readonly string[]; + threadSpec: ReturnType; + tableMode: ReturnType; + chunkMode: ReturnType; + }) => ({ + chatId: String(params.chatId), + accountId: params.accountId, + sessionKeyForInternalHooks: params.sessionKeyForInternalHooks, + mirrorIsGroup: params.mirrorIsGroup, + mirrorGroupId: params.mirrorGroupId, + token: opts.token, + runtime, + bot, + mediaLocalRoots: params.mediaLocalRoots, + replyToMode, + textLimit, + thread: params.threadSpec, + tableMode: params.tableMode, + chunkMode: params.chunkMode, + linkPreview: telegramCfg.linkPreview, + }); + + if (commandsToRegister.length > 0 || pluginCatalog.commands.length > 0) { + if (typeof (bot as unknown as { command?: unknown }).command !== "function") { + logVerbose("telegram: bot.command unavailable; skipping native handlers"); + } else { + for (const command of nativeCommands) { + const normalizedCommandName = normalizeTelegramCommandName(command.name); + bot.command(normalizedCommandName, async (ctx: TelegramNativeCommandContext) => { + const msg = ctx.message; + if (!msg) { + return; + } + if (shouldSkipUpdate(ctx)) { + return; + } + const auth = await resolveTelegramCommandAuth({ + msg, + bot, + cfg, + accountId, + telegramCfg, + allowFrom, + groupAllowFrom, + useAccessGroups, + resolveGroupPolicy, + resolveTelegramGroupConfig, + requireAuth: true, + }); + if (!auth) { + return; + } + const { + chatId, + isGroup, + isForum, + resolvedThreadId, + senderId, + senderUsername, + groupConfig, + topicConfig, + commandAuthorized, + } = auth; + const runtimeContext = await resolveCommandRuntimeContext({ + msg, + isGroup, + isForum, + resolvedThreadId, + senderId, + topicAgentId: topicConfig?.agentId, + }); + if (!runtimeContext) { + return; + } + const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext; + const threadParams = buildTelegramThreadParams(threadSpec) ?? {}; + + const commandDefinition = findCommandByNativeName(command.name, "telegram"); + const rawText = ctx.match?.trim() ?? ""; + const commandArgs = commandDefinition + ? parseCommandArgs(commandDefinition, rawText) + : rawText + ? ({ raw: rawText } satisfies CommandArgs) + : undefined; + const prompt = commandDefinition + ? buildCommandTextFromArgs(commandDefinition, commandArgs) + : rawText + ? `/${command.name} ${rawText}` + : `/${command.name}`; + const menu = commandDefinition + ? resolveCommandArgMenu({ + command: commandDefinition, + args: commandArgs, + cfg, + }) + : null; + if (menu && commandDefinition) { + const title = + menu.title ?? + `Choose ${menu.arg.description || menu.arg.name} for /${commandDefinition.nativeName ?? commandDefinition.key}.`; + const rows: Array> = []; + for (let i = 0; i < menu.choices.length; i += 2) { + const slice = menu.choices.slice(i, i + 2); + rows.push( + slice.map((choice) => { + const args: CommandArgs = { + values: { [menu.arg.name]: choice.value }, + }; + return { + text: choice.label, + callback_data: buildCommandTextFromArgs(commandDefinition, args), + }; + }), + ); + } + const replyMarkup = buildInlineKeyboard(rows); + await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime, + fn: () => + bot.api.sendMessage(chatId, title, { + ...(replyMarkup ? { reply_markup: replyMarkup } : {}), + ...threadParams, + }), + }); + return; + } + const baseSessionKey = route.sessionKey; + // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums) + const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined; + const threadKeys = + dmThreadId != null + ? resolveThreadSessionKeys({ + baseSessionKey, + threadId: `${chatId}:${dmThreadId}`, + }) + : null; + const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; + const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({ + groupConfig, + topicConfig, + }); + const { sessionKey: commandSessionKey, commandTargetSessionKey } = + resolveNativeCommandSessionTargets({ + agentId: route.agentId, + sessionPrefix: "telegram:slash", + userId: String(senderId || chatId), + targetSessionKey: sessionKey, + }); + const deliveryBaseOptions = buildCommandDeliveryBaseOptions({ + chatId, + accountId: route.accountId, + sessionKeyForInternalHooks: commandSessionKey, + mirrorIsGroup: isGroup, + mirrorGroupId: isGroup ? String(chatId) : undefined, + mediaLocalRoots, + threadSpec, + tableMode, + chunkMode, + }); + const conversationLabel = isGroup + ? msg.chat.title + ? `${msg.chat.title} id:${chatId}` + : `group:${chatId}` + : (buildSenderName(msg) ?? String(senderId || chatId)); + const ctxPayload = finalizeInboundContext({ + Body: prompt, + BodyForAgent: prompt, + RawBody: prompt, + CommandBody: prompt, + CommandArgs: commandArgs, + From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`, + To: `slash:${senderId || chatId}`, + ChatType: isGroup ? "group" : "direct", + ConversationLabel: conversationLabel, + GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, + GroupSystemPrompt: isGroup || (!isGroup && groupConfig) ? groupSystemPrompt : undefined, + SenderName: buildSenderName(msg), + SenderId: senderId || undefined, + SenderUsername: senderUsername || undefined, + Surface: "telegram", + Provider: "telegram", + MessageSid: String(msg.message_id), + Timestamp: msg.date ? msg.date * 1000 : undefined, + WasMentioned: true, + CommandAuthorized: commandAuthorized, + CommandSource: "native" as const, + SessionKey: commandSessionKey, + AccountId: route.accountId, + CommandTargetSessionKey: commandTargetSessionKey, + MessageThreadId: threadSpec.id, + IsForum: isForum, + // Originating context for sub-agent announce routing + OriginatingChannel: "telegram" as const, + OriginatingTo: `telegram:${chatId}`, + }); + + await recordInboundSessionMetaSafe({ + cfg, + agentId: route.agentId, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + onError: (err) => + runtime.error?.( + danger(`telegram slash: failed updating session meta: ${String(err)}`), + ), + }); + + const disableBlockStreaming = + typeof telegramCfg.blockStreaming === "boolean" + ? !telegramCfg.blockStreaming + : undefined; + + const deliveryState = { + delivered: false, + skippedNonSilent: 0, + }; + + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId: route.agentId, + channel: "telegram", + accountId: route.accountId, + }); + + await dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg, + dispatcherOptions: { + ...prefixOptions, + deliver: async (payload, _info) => { + if ( + shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg, + accountId: route.accountId, + payload, + }) + ) { + deliveryState.delivered = true; + return; + } + const result = await deliverReplies({ + replies: [payload], + ...deliveryBaseOptions, + }); + if (result.delivered) { + deliveryState.delivered = true; + } + }, + onSkip: (_payload, info) => { + if (info.reason !== "silent") { + deliveryState.skippedNonSilent += 1; + } + }, + onError: (err, info) => { + runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`)); + }, + }, + replyOptions: { + skillFilter, + disableBlockStreaming, + onModelSelected, + }, + }); + if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) { + await deliverReplies({ + replies: [{ text: EMPTY_RESPONSE_FALLBACK }], + ...deliveryBaseOptions, + }); + } + }); + } + + for (const pluginCommand of pluginCatalog.commands) { + bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => { + const msg = ctx.message; + if (!msg) { + return; + } + if (shouldSkipUpdate(ctx)) { + return; + } + const chatId = msg.chat.id; + const rawText = ctx.match?.trim() ?? ""; + const commandBody = `/${pluginCommand.command}${rawText ? ` ${rawText}` : ""}`; + const match = matchPluginCommand(commandBody); + if (!match) { + await withTelegramApiErrorLogging({ + operation: "sendMessage", + runtime, + fn: () => bot.api.sendMessage(chatId, "Command not found."), + }); + return; + } + const auth = await resolveTelegramCommandAuth({ + msg, + bot, + cfg, + accountId, + telegramCfg, + allowFrom, + groupAllowFrom, + useAccessGroups, + resolveGroupPolicy, + resolveTelegramGroupConfig, + requireAuth: match.command.requireAuth !== false, + }); + if (!auth) { + return; + } + const { senderId, commandAuthorized, isGroup, isForum, resolvedThreadId } = auth; + const runtimeContext = await resolveCommandRuntimeContext({ + msg, + isGroup, + isForum, + resolvedThreadId, + senderId, + topicAgentId: auth.topicConfig?.agentId, + }); + if (!runtimeContext) { + return; + } + const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext; + const deliveryBaseOptions = buildCommandDeliveryBaseOptions({ + chatId, + accountId: route.accountId, + sessionKeyForInternalHooks: route.sessionKey, + mirrorIsGroup: isGroup, + mirrorGroupId: isGroup ? String(chatId) : undefined, + mediaLocalRoots, + threadSpec, + tableMode, + chunkMode, + }); + const from = isGroup + ? buildTelegramGroupFrom(chatId, threadSpec.id) + : `telegram:${chatId}`; + const to = `telegram:${chatId}`; + + const result = await executePluginCommand({ + command: match.command, + args: match.args, + senderId, + channel: "telegram", + isAuthorizedSender: commandAuthorized, + commandBody, + config: cfg, + from, + to, + accountId, + messageThreadId: threadSpec.id, + }); + + if ( + !shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg, + accountId: route.accountId, + payload: result, + }) + ) { + await deliverReplies({ + replies: [result], + ...deliveryBaseOptions, + }); + } + }); + } + } + } else if (nativeDisabledExplicit) { + withTelegramApiErrorLogging({ + operation: "setMyCommands", + runtime, + fn: () => bot.api.setMyCommands([]), + }).catch(() => {}); + } +}; diff --git a/extensions/telegram/src/bot-updates.ts b/extensions/telegram/src/bot-updates.ts new file mode 100644 index 00000000000..3121f1a487e --- /dev/null +++ b/extensions/telegram/src/bot-updates.ts @@ -0,0 +1,67 @@ +import type { Message } from "@grammyjs/types"; +import { createDedupeCache } from "../../../src/infra/dedupe.js"; +import type { TelegramContext } from "./bot/types.js"; + +const MEDIA_GROUP_TIMEOUT_MS = 500; +const RECENT_TELEGRAM_UPDATE_TTL_MS = 5 * 60_000; +const RECENT_TELEGRAM_UPDATE_MAX = 2000; + +export type MediaGroupEntry = { + messages: Array<{ + msg: Message; + ctx: TelegramContext; + }>; + timer: ReturnType; +}; + +export type TelegramUpdateKeyContext = { + update?: { + update_id?: number; + message?: Message; + edited_message?: Message; + channel_post?: Message; + edited_channel_post?: Message; + }; + update_id?: number; + message?: Message; + channelPost?: Message; + editedChannelPost?: Message; + callbackQuery?: { id?: string; message?: Message }; +}; + +export const resolveTelegramUpdateId = (ctx: TelegramUpdateKeyContext) => + ctx.update?.update_id ?? ctx.update_id; + +export const buildTelegramUpdateKey = (ctx: TelegramUpdateKeyContext) => { + const updateId = resolveTelegramUpdateId(ctx); + if (typeof updateId === "number") { + return `update:${updateId}`; + } + const callbackId = ctx.callbackQuery?.id; + if (callbackId) { + return `callback:${callbackId}`; + } + const msg = + ctx.message ?? + ctx.channelPost ?? + ctx.editedChannelPost ?? + ctx.update?.message ?? + ctx.update?.edited_message ?? + ctx.update?.channel_post ?? + ctx.update?.edited_channel_post ?? + ctx.callbackQuery?.message; + const chatId = msg?.chat?.id; + const messageId = msg?.message_id; + if (typeof chatId !== "undefined" && typeof messageId === "number") { + return `message:${chatId}:${messageId}`; + } + return undefined; +}; + +export const createTelegramUpdateDedupe = () => + createDedupeCache({ + ttlMs: RECENT_TELEGRAM_UPDATE_TTL_MS, + maxSize: RECENT_TELEGRAM_UPDATE_MAX, + }); + +export { MEDIA_GROUP_TIMEOUT_MS }; diff --git a/src/telegram/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts similarity index 90% rename from src/telegram/bot.create-telegram-bot.test-harness.ts rename to extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index b0090d62a70..4e590a961c7 100644 --- a/src/telegram/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -1,9 +1,9 @@ import { beforeEach, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import type { MsgContext } from "../auto-reply/templating.js"; -import type { GetReplyOptions, ReplyPayload } from "../auto-reply/types.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { MockFn } from "../test-utils/vitest-mock-fn.js"; +import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js"; +import type { MsgContext } from "../../../src/auto-reply/templating.js"; +import type { GetReplyOptions, ReplyPayload } from "../../../src/auto-reply/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js"; type AnyMock = MockFn<(...args: unknown[]) => unknown>; type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise>; @@ -20,7 +20,7 @@ export function getLoadWebMediaMock(): AnyMock { return loadWebMedia; } -vi.mock("../web/media.js", () => ({ +vi.mock("../../../src/web/media.js", () => ({ loadWebMedia, })); @@ -31,16 +31,16 @@ const { loadConfig } = vi.hoisted((): { loadConfig: AnyMock } => ({ export function getLoadConfigMock(): AnyMock { return loadConfig; } -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig, }; }); -vi.mock("../config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath), @@ -68,7 +68,7 @@ export function getUpsertChannelPairingRequestMock(): AnyAsyncMock { return upsertChannelPairingRequest; } -vi.mock("../pairing/pairing-store.js", () => ({ +vi.mock("../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore, upsertChannelPairingRequest, })); @@ -78,7 +78,7 @@ const skillCommandsHoisted = vi.hoisted(() => ({ })); export const listSkillCommandsForAgents = skillCommandsHoisted.listSkillCommandsForAgents; -vi.mock("../auto-reply/skill-commands.js", () => ({ +vi.mock("../../../src/auto-reply/skill-commands.js", () => ({ listSkillCommandsForAgents, })); @@ -87,7 +87,7 @@ const systemEventsHoisted = vi.hoisted(() => ({ })); export const enqueueSystemEventSpy: AnyMock = systemEventsHoisted.enqueueSystemEventSpy; -vi.mock("../infra/system-events.js", () => ({ +vi.mock("../../../src/infra/system-events.js", () => ({ enqueueSystemEvent: enqueueSystemEventSpy, })); @@ -201,7 +201,7 @@ export const replySpy: MockFn< return undefined; }); -vi.mock("../auto-reply/reply.js", () => ({ +vi.mock("../../../src/auto-reply/reply.js", () => ({ getReplyFromConfig: replySpy, __replySpy: replySpy, })); diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts similarity index 99% rename from src/telegram/bot.create-telegram-bot.test.ts rename to extensions/telegram/src/bot.create-telegram-bot.test.ts index 378c1eb1065..71b4d489dfc 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -2,9 +2,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import { withEnvAsync } from "../test-utils/env.js"; -import { useFrozenTime, useRealTime } from "../test-utils/frozen-time.js"; +import { withEnvAsync } from "../../../src/test-utils/env.js"; +import { useFrozenTime, useRealTime } from "../../../src/test-utils/frozen-time.js"; +import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; import { answerCallbackQuerySpy, botCtorSpy, diff --git a/extensions/telegram/src/bot.fetch-abort.test.ts b/extensions/telegram/src/bot.fetch-abort.test.ts new file mode 100644 index 00000000000..258215d4c6d --- /dev/null +++ b/extensions/telegram/src/bot.fetch-abort.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import { botCtorSpy } from "./bot.create-telegram-bot.test-harness.js"; +import { createTelegramBot } from "./bot.js"; +import { getTelegramNetworkErrorOrigin } from "./network-errors.js"; + +function createWrappedTelegramClientFetch(proxyFetch: typeof fetch) { + const shutdown = new AbortController(); + botCtorSpy.mockClear(); + createTelegramBot({ + token: "tok", + fetchAbortSignal: shutdown.signal, + proxyFetch, + }); + const clientFetch = (botCtorSpy.mock.calls.at(-1)?.[1] as { client?: { fetch?: unknown } }) + ?.client?.fetch as (input: RequestInfo | URL, init?: RequestInit) => Promise; + expect(clientFetch).toBeTypeOf("function"); + return { clientFetch, shutdown }; +} + +describe("createTelegramBot fetch abort", () => { + it("aborts wrapped client fetch when fetchAbortSignal aborts", async () => { + const fetchSpy = vi.fn( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((resolve) => { + const signal = init?.signal as AbortSignal; + signal.addEventListener("abort", () => resolve(signal), { once: true }); + }), + ); + const { clientFetch, shutdown } = createWrappedTelegramClientFetch( + fetchSpy as unknown as typeof fetch, + ); + + const observedSignalPromise = clientFetch("https://example.test"); + shutdown.abort(new Error("shutdown")); + const observedSignal = (await observedSignalPromise) as AbortSignal; + + expect(observedSignal).toBeInstanceOf(AbortSignal); + expect(observedSignal.aborted).toBe(true); + }); + + it("tags wrapped Telegram fetch failures with the Bot API method", async () => { + const fetchError = Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect timeout"), { + code: "UND_ERR_CONNECT_TIMEOUT", + }), + }); + const fetchSpy = vi.fn(async () => { + throw fetchError; + }); + const { clientFetch } = createWrappedTelegramClientFetch(fetchSpy as unknown as typeof fetch); + + await expect(clientFetch("https://api.telegram.org/bot123456:ABC/getUpdates")).rejects.toBe( + fetchError, + ); + expect(getTelegramNetworkErrorOrigin(fetchError)).toEqual({ + method: "getupdates", + url: "https://api.telegram.org/bot123456:ABC/getUpdates", + }); + }); + + it("preserves the original fetch error when tagging cannot attach metadata", async () => { + const frozenError = Object.freeze( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect timeout"), { + code: "UND_ERR_CONNECT_TIMEOUT", + }), + }), + ); + const fetchSpy = vi.fn(async () => { + throw frozenError; + }); + const { clientFetch } = createWrappedTelegramClientFetch(fetchSpy as unknown as typeof fetch); + + await expect(clientFetch("https://api.telegram.org/bot123456:ABC/getUpdates")).rejects.toBe( + frozenError, + ); + expect(getTelegramNetworkErrorOrigin(frozenError)).toBeNull(); + }); +}); diff --git a/src/telegram/bot.helpers.test.ts b/extensions/telegram/src/bot.helpers.test.ts similarity index 100% rename from src/telegram/bot.helpers.test.ts rename to extensions/telegram/src/bot.helpers.test.ts diff --git a/src/telegram/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts b/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts similarity index 100% rename from src/telegram/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts rename to extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts diff --git a/src/telegram/bot.media.e2e-harness.ts b/extensions/telegram/src/bot.media.e2e-harness.ts similarity index 83% rename from src/telegram/bot.media.e2e-harness.ts rename to extensions/telegram/src/bot.media.e2e-harness.ts index d26eff44fb6..a91362702dd 100644 --- a/src/telegram/bot.media.e2e-harness.ts +++ b/extensions/telegram/src/bot.media.e2e-harness.ts @@ -1,5 +1,5 @@ import { beforeEach, vi, type Mock } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; +import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js"; export const useSpy: Mock = vi.fn(); export const middlewareUseSpy: Mock = vi.fn(); @@ -92,8 +92,8 @@ vi.mock("undici", async (importOriginal) => { }; }); -vi.mock("../media/store.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/media/store.js", async (importOriginal) => { + const actual = await importOriginal(); const mockModule = Object.create(null) as Record; Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual)); Object.defineProperty(mockModule, "saveMediaBuffer", { @@ -105,8 +105,8 @@ vi.mock("../media/store.js", async (importOriginal) => { return mockModule; }); -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: () => ({ @@ -115,15 +115,15 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -vi.mock("../config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, updateLastRoute: vi.fn(async () => undefined), }; }); -vi.mock("../pairing/pairing-store.js", () => ({ +vi.mock("../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: vi.fn(async () => [] as string[]), upsertChannelPairingRequest: vi.fn(async () => ({ code: "PAIRCODE", @@ -131,7 +131,7 @@ vi.mock("../pairing/pairing-store.js", () => ({ })), })); -vi.mock("../auto-reply/reply.js", () => { +vi.mock("../../../src/auto-reply/reply.js", () => { const replySpy = vi.fn(async (_ctx, opts) => { await opts?.onReplyStart?.(); return undefined; diff --git a/src/telegram/bot.media.stickers-and-fragments.e2e.test.ts b/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts similarity index 100% rename from src/telegram/bot.media.stickers-and-fragments.e2e.test.ts rename to extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts diff --git a/src/telegram/bot.media.test-utils.ts b/extensions/telegram/src/bot.media.test-utils.ts similarity index 96% rename from src/telegram/bot.media.test-utils.ts rename to extensions/telegram/src/bot.media.test-utils.ts index 94084bad31c..fde76f34e23 100644 --- a/src/telegram/bot.media.test-utils.ts +++ b/extensions/telegram/src/bot.media.test-utils.ts @@ -1,5 +1,5 @@ import { afterEach, beforeAll, beforeEach, expect, vi, type Mock } from "vitest"; -import * as ssrf from "../infra/net/ssrf.js"; +import * as ssrf from "../../../src/infra/net/ssrf.js"; import { onSpy, sendChatActionSpy } from "./bot.media.e2e-harness.js"; type StickerSpy = Mock<(...args: unknown[]) => unknown>; @@ -103,7 +103,7 @@ afterEach(() => { beforeAll(async () => { ({ createTelegramBot: createTelegramBotRef } = await import("./bot.js")); - const replyModule = await import("../auto-reply/reply.js"); + const replyModule = await import("../../../src/auto-reply/reply.js"); replySpyRef = (replyModule as unknown as { __replySpy: ReturnType }).__replySpy; }, TELEGRAM_BOT_IMPORT_TIMEOUT_MS); diff --git a/src/telegram/bot.test.ts b/extensions/telegram/src/bot.test.ts similarity index 99% rename from src/telegram/bot.test.ts rename to extensions/telegram/src/bot.test.ts index d8c8bc14ade..f713b98cbe7 100644 --- a/src/telegram/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1,13 +1,13 @@ import { rm } from "node:fs/promises"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js"; import { listNativeCommandSpecs, listNativeCommandSpecsForConfig, -} from "../auto-reply/commands-registry.js"; -import { loadSessionStore } from "../config/sessions.js"; -import { normalizeTelegramCommandName } from "../config/telegram-custom-commands.js"; +} from "../../../src/auto-reply/commands-registry.js"; +import { loadSessionStore } from "../../../src/config/sessions.js"; +import { normalizeTelegramCommandName } from "../../../src/config/telegram-custom-commands.js"; +import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; +import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; import { answerCallbackQuerySpy, commandSpy, diff --git a/extensions/telegram/src/bot.ts b/extensions/telegram/src/bot.ts new file mode 100644 index 00000000000..a817e10cbac --- /dev/null +++ b/extensions/telegram/src/bot.ts @@ -0,0 +1,521 @@ +import { sequentialize } from "@grammyjs/runner"; +import { apiThrottler } from "@grammyjs/transformer-throttler"; +import type { ApiClientOptions } from "grammy"; +import { Bot } from "grammy"; +import { resolveDefaultAgentId } from "../../../src/agents/agent-scope.js"; +import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; +import { + DEFAULT_GROUP_HISTORY_LIMIT, + type HistoryEntry, +} from "../../../src/auto-reply/reply/history.js"; +import { + resolveThreadBindingIdleTimeoutMsForChannel, + resolveThreadBindingMaxAgeMsForChannel, + resolveThreadBindingSpawnPolicy, +} from "../../../src/channels/thread-bindings-policy.js"; +import { + isNativeCommandsExplicitlyDisabled, + resolveNativeCommandsEnabled, + resolveNativeSkillsEnabled, +} from "../../../src/config/commands.js"; +import type { OpenClawConfig, ReplyToMode } from "../../../src/config/config.js"; +import { loadConfig } from "../../../src/config/config.js"; +import { + resolveChannelGroupPolicy, + resolveChannelGroupRequireMention, +} from "../../../src/config/group-policy.js"; +import { loadSessionStore, resolveStorePath } from "../../../src/config/sessions.js"; +import { danger, logVerbose, shouldLogVerbose } from "../../../src/globals.js"; +import { formatUncaughtError } from "../../../src/infra/errors.js"; +import { getChildLogger } from "../../../src/logging.js"; +import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; +import { createNonExitingRuntime, type RuntimeEnv } from "../../../src/runtime.js"; +import { resolveTelegramAccount } from "./accounts.js"; +import { registerTelegramHandlers } from "./bot-handlers.js"; +import { createTelegramMessageProcessor } from "./bot-message.js"; +import { registerTelegramNativeCommands } from "./bot-native-commands.js"; +import { + buildTelegramUpdateKey, + createTelegramUpdateDedupe, + resolveTelegramUpdateId, + type TelegramUpdateKeyContext, +} from "./bot-updates.js"; +import { buildTelegramGroupPeerId, resolveTelegramStreamMode } from "./bot/helpers.js"; +import { resolveTelegramTransport } from "./fetch.js"; +import { tagTelegramNetworkError } from "./network-errors.js"; +import { createTelegramSendChatActionHandler } from "./sendchataction-401-backoff.js"; +import { getTelegramSequentialKey } from "./sequential-key.js"; +import { createTelegramThreadBindingManager } from "./thread-bindings.js"; + +export type TelegramBotOptions = { + token: string; + accountId?: string; + runtime?: RuntimeEnv; + requireMention?: boolean; + allowFrom?: Array; + groupAllowFrom?: Array; + mediaMaxMb?: number; + replyToMode?: ReplyToMode; + proxyFetch?: typeof fetch; + config?: OpenClawConfig; + /** Signal to abort in-flight Telegram API fetch requests (e.g. getUpdates) on shutdown. */ + fetchAbortSignal?: AbortSignal; + updateOffset?: { + lastUpdateId?: number | null; + onUpdateId?: (updateId: number) => void | Promise; + }; + testTimings?: { + mediaGroupFlushMs?: number; + textFragmentGapMs?: number; + }; +}; + +export { getTelegramSequentialKey }; + +type TelegramFetchInput = Parameters>[0]; +type TelegramFetchInit = Parameters>[1]; +type GlobalFetchInput = Parameters[0]; +type GlobalFetchInit = Parameters[1]; + +function readRequestUrl(input: TelegramFetchInput): string | null { + if (typeof input === "string") { + return input; + } + if (input instanceof URL) { + return input.toString(); + } + if (typeof input === "object" && input !== null && "url" in input) { + const url = (input as { url?: unknown }).url; + return typeof url === "string" ? url : null; + } + return null; +} + +function extractTelegramApiMethod(input: TelegramFetchInput): string | null { + const url = readRequestUrl(input); + if (!url) { + return null; + } + try { + const pathname = new URL(url).pathname; + const segments = pathname.split("/").filter(Boolean); + return segments.length > 0 ? (segments.at(-1) ?? null) : null; + } catch { + return null; + } +} + +export function createTelegramBot(opts: TelegramBotOptions) { + const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime(); + const cfg = opts.config ?? loadConfig(); + const account = resolveTelegramAccount({ + cfg, + accountId: opts.accountId, + }); + const threadBindingPolicy = resolveThreadBindingSpawnPolicy({ + cfg, + channel: "telegram", + accountId: account.accountId, + kind: "subagent", + }); + const threadBindingManager = threadBindingPolicy.enabled + ? createTelegramThreadBindingManager({ + accountId: account.accountId, + idleTimeoutMs: resolveThreadBindingIdleTimeoutMsForChannel({ + cfg, + channel: "telegram", + accountId: account.accountId, + }), + maxAgeMs: resolveThreadBindingMaxAgeMsForChannel({ + cfg, + channel: "telegram", + accountId: account.accountId, + }), + }) + : null; + const telegramCfg = account.config; + + const telegramTransport = resolveTelegramTransport(opts.proxyFetch, { + network: telegramCfg.network, + }); + const shouldProvideFetch = Boolean(telegramTransport.fetch); + // grammY's ApiClientOptions types still track `node-fetch` types; Node 22+ global fetch + // (undici) is structurally compatible at runtime but not assignable in TS. + const fetchForClient = telegramTransport.fetch as unknown as NonNullable< + ApiClientOptions["fetch"] + >; + + // When a shutdown abort signal is provided, wrap fetch so every Telegram API request + // (especially long-polling getUpdates) aborts immediately on shutdown. Without this, + // the in-flight getUpdates hangs for up to 30s, and a new gateway instance starting + // its own poll triggers a 409 Conflict from Telegram. + let finalFetch = shouldProvideFetch ? fetchForClient : undefined; + if (opts.fetchAbortSignal) { + const baseFetch = + finalFetch ?? (globalThis.fetch as unknown as NonNullable); + const shutdownSignal = opts.fetchAbortSignal; + // Cast baseFetch to global fetch to avoid node-fetch ↔ global-fetch type divergence; + // they are runtime-compatible (the codebase already casts at every fetch boundary). + const callFetch = baseFetch as unknown as typeof globalThis.fetch; + // Use manual event forwarding instead of AbortSignal.any() to avoid the cross-realm + // AbortSignal issue in Node.js (grammY's signal may come from a different module context, + // causing "signals[0] must be an instance of AbortSignal" errors). + finalFetch = ((input: TelegramFetchInput, init?: TelegramFetchInit) => { + const controller = new AbortController(); + const abortWith = (signal: AbortSignal) => controller.abort(signal.reason); + const onShutdown = () => abortWith(shutdownSignal); + let onRequestAbort: (() => void) | undefined; + if (shutdownSignal.aborted) { + abortWith(shutdownSignal); + } else { + shutdownSignal.addEventListener("abort", onShutdown, { once: true }); + } + if (init?.signal) { + if (init.signal.aborted) { + abortWith(init.signal as unknown as AbortSignal); + } else { + onRequestAbort = () => abortWith(init.signal as AbortSignal); + init.signal.addEventListener("abort", onRequestAbort); + } + } + return callFetch(input as GlobalFetchInput, { + ...(init as GlobalFetchInit), + signal: controller.signal, + }).finally(() => { + shutdownSignal.removeEventListener("abort", onShutdown); + if (init?.signal && onRequestAbort) { + init.signal.removeEventListener("abort", onRequestAbort); + } + }); + }) as unknown as NonNullable; + } + if (finalFetch) { + const baseFetch = finalFetch; + finalFetch = ((input: TelegramFetchInput, init?: TelegramFetchInit) => { + return Promise.resolve(baseFetch(input, init)).catch((err: unknown) => { + try { + tagTelegramNetworkError(err, { + method: extractTelegramApiMethod(input), + url: readRequestUrl(input), + }); + } catch { + // Tagging is best-effort; preserve the original fetch failure if the + // error object cannot accept extra metadata. + } + throw err; + }); + }) as unknown as NonNullable; + } + + const timeoutSeconds = + typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds) + ? Math.max(1, Math.floor(telegramCfg.timeoutSeconds)) + : undefined; + const client: ApiClientOptions | undefined = + finalFetch || timeoutSeconds + ? { + ...(finalFetch ? { fetch: finalFetch } : {}), + ...(timeoutSeconds ? { timeoutSeconds } : {}), + } + : undefined; + + const bot = new Bot(opts.token, client ? { client } : undefined); + bot.api.config.use(apiThrottler()); + // Catch all errors from bot middleware to prevent unhandled rejections + bot.catch((err) => { + runtime.error?.(danger(`telegram bot error: ${formatUncaughtError(err)}`)); + }); + + const recentUpdates = createTelegramUpdateDedupe(); + const initialUpdateId = + typeof opts.updateOffset?.lastUpdateId === "number" ? opts.updateOffset.lastUpdateId : null; + + // Track update_ids that have entered the middleware pipeline but have not completed yet. + // This includes updates that are "queued" behind sequentialize(...) for a chat/topic key. + // We only persist a watermark that is strictly less than the smallest pending update_id, + // so we never write an offset that would skip an update still waiting to run. + const pendingUpdateIds = new Set(); + let highestCompletedUpdateId: number | null = initialUpdateId; + let highestPersistedUpdateId: number | null = initialUpdateId; + const maybePersistSafeWatermark = () => { + if (typeof opts.updateOffset?.onUpdateId !== "function") { + return; + } + if (highestCompletedUpdateId === null) { + return; + } + let safe = highestCompletedUpdateId; + if (pendingUpdateIds.size > 0) { + let minPending: number | null = null; + for (const id of pendingUpdateIds) { + if (minPending === null || id < minPending) { + minPending = id; + } + } + if (minPending !== null) { + safe = Math.min(safe, minPending - 1); + } + } + if (highestPersistedUpdateId !== null && safe <= highestPersistedUpdateId) { + return; + } + highestPersistedUpdateId = safe; + void opts.updateOffset.onUpdateId(safe); + }; + + const shouldSkipUpdate = (ctx: TelegramUpdateKeyContext) => { + const updateId = resolveTelegramUpdateId(ctx); + const skipCutoff = highestPersistedUpdateId ?? initialUpdateId; + if (typeof updateId === "number" && skipCutoff !== null && updateId <= skipCutoff) { + return true; + } + const key = buildTelegramUpdateKey(ctx); + const skipped = recentUpdates.check(key); + if (skipped && key && shouldLogVerbose()) { + logVerbose(`telegram dedupe: skipped ${key}`); + } + return skipped; + }; + + bot.use(async (ctx, next) => { + const updateId = resolveTelegramUpdateId(ctx); + if (typeof updateId === "number") { + pendingUpdateIds.add(updateId); + } + try { + await next(); + } finally { + if (typeof updateId === "number") { + pendingUpdateIds.delete(updateId); + if (highestCompletedUpdateId === null || updateId > highestCompletedUpdateId) { + highestCompletedUpdateId = updateId; + } + maybePersistSafeWatermark(); + } + } + }); + + bot.use(sequentialize(getTelegramSequentialKey)); + + const rawUpdateLogger = createSubsystemLogger("gateway/channels/telegram/raw-update"); + const MAX_RAW_UPDATE_CHARS = 8000; + const MAX_RAW_UPDATE_STRING = 500; + const MAX_RAW_UPDATE_ARRAY = 20; + const stringifyUpdate = (update: unknown) => { + const seen = new WeakSet(); + return JSON.stringify(update ?? null, (key, value) => { + if (typeof value === "string" && value.length > MAX_RAW_UPDATE_STRING) { + return `${value.slice(0, MAX_RAW_UPDATE_STRING)}...`; + } + if (Array.isArray(value) && value.length > MAX_RAW_UPDATE_ARRAY) { + return [ + ...value.slice(0, MAX_RAW_UPDATE_ARRAY), + `...(${value.length - MAX_RAW_UPDATE_ARRAY} more)`, + ]; + } + if (value && typeof value === "object") { + if (seen.has(value)) { + return "[Circular]"; + } + seen.add(value); + } + return value; + }); + }; + + bot.use(async (ctx, next) => { + if (shouldLogVerbose()) { + try { + const raw = stringifyUpdate(ctx.update); + const preview = + raw.length > MAX_RAW_UPDATE_CHARS ? `${raw.slice(0, MAX_RAW_UPDATE_CHARS)}...` : raw; + rawUpdateLogger.debug(`telegram update: ${preview}`); + } catch (err) { + rawUpdateLogger.debug(`telegram update log failed: ${String(err)}`); + } + } + await next(); + }); + + const historyLimit = Math.max( + 0, + telegramCfg.historyLimit ?? + cfg.messages?.groupChat?.historyLimit ?? + DEFAULT_GROUP_HISTORY_LIMIT, + ); + const groupHistories = new Map(); + const textLimit = resolveTextChunkLimit(cfg, "telegram", account.accountId); + const dmPolicy = telegramCfg.dmPolicy ?? "pairing"; + const allowFrom = opts.allowFrom ?? telegramCfg.allowFrom; + const groupAllowFrom = + opts.groupAllowFrom ?? telegramCfg.groupAllowFrom ?? telegramCfg.allowFrom ?? allowFrom; + const replyToMode = opts.replyToMode ?? telegramCfg.replyToMode ?? "off"; + const nativeEnabled = resolveNativeCommandsEnabled({ + providerId: "telegram", + providerSetting: telegramCfg.commands?.native, + globalSetting: cfg.commands?.native, + }); + const nativeSkillsEnabled = resolveNativeSkillsEnabled({ + providerId: "telegram", + providerSetting: telegramCfg.commands?.nativeSkills, + globalSetting: cfg.commands?.nativeSkills, + }); + const nativeDisabledExplicit = isNativeCommandsExplicitlyDisabled({ + providerSetting: telegramCfg.commands?.native, + globalSetting: cfg.commands?.native, + }); + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const mediaMaxBytes = (opts.mediaMaxMb ?? telegramCfg.mediaMaxMb ?? 100) * 1024 * 1024; + const logger = getChildLogger({ module: "telegram-auto-reply" }); + const streamMode = resolveTelegramStreamMode(telegramCfg); + const resolveGroupPolicy = (chatId: string | number) => + resolveChannelGroupPolicy({ + cfg, + channel: "telegram", + accountId: account.accountId, + groupId: String(chatId), + }); + const resolveGroupActivation = (params: { + chatId: string | number; + agentId?: string; + messageThreadId?: number; + sessionKey?: string; + }) => { + const agentId = params.agentId ?? resolveDefaultAgentId(cfg); + const sessionKey = + params.sessionKey ?? + `agent:${agentId}:telegram:group:${buildTelegramGroupPeerId(params.chatId, params.messageThreadId)}`; + const storePath = resolveStorePath(cfg.session?.store, { agentId }); + try { + const store = loadSessionStore(storePath); + const entry = store[sessionKey]; + if (entry?.groupActivation === "always") { + return false; + } + if (entry?.groupActivation === "mention") { + return true; + } + } catch (err) { + logVerbose(`Failed to load session for activation check: ${String(err)}`); + } + return undefined; + }; + const resolveGroupRequireMention = (chatId: string | number) => + resolveChannelGroupRequireMention({ + cfg, + channel: "telegram", + accountId: account.accountId, + groupId: String(chatId), + requireMentionOverride: opts.requireMention, + overrideOrder: "after-config", + }); + const resolveTelegramGroupConfig = (chatId: string | number, messageThreadId?: number) => { + const groups = telegramCfg.groups; + const direct = telegramCfg.direct; + const chatIdStr = String(chatId); + const isDm = !chatIdStr.startsWith("-"); + + if (isDm) { + const directConfig = direct?.[chatIdStr] ?? direct?.["*"]; + if (directConfig) { + const topicConfig = + messageThreadId != null ? directConfig.topics?.[String(messageThreadId)] : undefined; + return { groupConfig: directConfig, topicConfig }; + } + // DMs without direct config: don't fall through to groups lookup + return { groupConfig: undefined, topicConfig: undefined }; + } + + if (!groups) { + return { groupConfig: undefined, topicConfig: undefined }; + } + const groupConfig = groups[chatIdStr] ?? groups["*"]; + const topicConfig = + messageThreadId != null ? groupConfig?.topics?.[String(messageThreadId)] : undefined; + return { groupConfig, topicConfig }; + }; + + // Global sendChatAction handler with 401 backoff / circuit breaker (issue #27092). + // Created BEFORE the message processor so it can be injected into every message context. + // Shared across all message contexts for this account so that consecutive 401s + // from ANY chat are tracked together — prevents infinite retry storms. + const sendChatActionHandler = createTelegramSendChatActionHandler({ + sendChatActionFn: (chatId, action, threadParams) => + bot.api.sendChatAction( + chatId, + action, + threadParams as Parameters[2], + ), + logger: (message) => logVerbose(`telegram: ${message}`), + }); + + const processMessage = createTelegramMessageProcessor({ + bot, + cfg, + account, + telegramCfg, + historyLimit, + groupHistories, + dmPolicy, + allowFrom, + groupAllowFrom, + ackReactionScope, + logger, + resolveGroupActivation, + resolveGroupRequireMention, + resolveTelegramGroupConfig, + sendChatActionHandler, + runtime, + replyToMode, + streamMode, + textLimit, + opts, + }); + + registerTelegramNativeCommands({ + bot, + cfg, + runtime, + accountId: account.accountId, + telegramCfg, + allowFrom, + groupAllowFrom, + replyToMode, + textLimit, + useAccessGroups, + nativeEnabled, + nativeSkillsEnabled, + nativeDisabledExplicit, + resolveGroupPolicy, + resolveTelegramGroupConfig, + shouldSkipUpdate, + opts, + }); + + registerTelegramHandlers({ + cfg, + accountId: account.accountId, + bot, + opts, + telegramTransport, + runtime, + mediaMaxBytes, + telegramCfg, + allowFrom, + groupAllowFrom, + resolveGroupPolicy, + resolveTelegramGroupConfig, + shouldSkipUpdate, + processMessage, + logger, + }); + + const originalStop = bot.stop.bind(bot); + bot.stop = ((...args: Parameters) => { + threadBindingManager?.stop(); + return originalStop(...args); + }) as typeof bot.stop; + + return bot; +} diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts new file mode 100644 index 00000000000..19eddfc2866 --- /dev/null +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -0,0 +1,702 @@ +import { type Bot, GrammyError, InputFile } from "grammy"; +import { chunkMarkdownTextWithMode, type ChunkMode } from "../../../../src/auto-reply/chunk.js"; +import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; +import type { ReplyToMode } from "../../../../src/config/config.js"; +import type { MarkdownTableMode } from "../../../../src/config/types.base.js"; +import { danger, logVerbose } from "../../../../src/globals.js"; +import { fireAndForgetHook } from "../../../../src/hooks/fire-and-forget.js"; +import { + createInternalHookEvent, + triggerInternalHook, +} from "../../../../src/hooks/internal-hooks.js"; +import { + buildCanonicalSentMessageHookContext, + toInternalMessageSentContext, + toPluginMessageContext, + toPluginMessageSentEvent, +} from "../../../../src/hooks/message-hook-mappers.js"; +import { formatErrorMessage } from "../../../../src/infra/errors.js"; +import { buildOutboundMediaLoadOptions } from "../../../../src/media/load-options.js"; +import { isGifMedia, kindFromMime } from "../../../../src/media/mime.js"; +import { getGlobalHookRunner } from "../../../../src/plugins/hook-runner-global.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; +import { loadWebMedia } from "../../../../src/web/media.js"; +import type { TelegramInlineButtons } from "../button-types.js"; +import { splitTelegramCaption } from "../caption.js"; +import { + markdownToTelegramChunks, + markdownToTelegramHtml, + renderTelegramHtmlText, + wrapFileReferencesInHtml, +} from "../format.js"; +import { buildInlineKeyboard } from "../send.js"; +import { resolveTelegramVoiceSend } from "../voice.js"; +import { + buildTelegramSendParams, + sendTelegramText, + sendTelegramWithThreadFallback, +} from "./delivery.send.js"; +import { resolveTelegramReplyId, type TelegramThreadSpec } from "./helpers.js"; +import { + markReplyApplied, + resolveReplyToForSend, + sendChunkedTelegramReplyText, + type DeliveryProgress as ReplyThreadDeliveryProgress, +} from "./reply-threading.js"; + +const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/; +const CAPTION_TOO_LONG_RE = /caption is too long/i; + +type DeliveryProgress = ReplyThreadDeliveryProgress & { + deliveredCount: number; +}; + +type TelegramReplyChannelData = { + buttons?: TelegramInlineButtons; + pin?: boolean; +}; + +type ChunkTextFn = (markdown: string) => ReturnType; + +function buildChunkTextResolver(params: { + textLimit: number; + chunkMode: ChunkMode; + tableMode?: MarkdownTableMode; +}): ChunkTextFn { + return (markdown: string) => { + const markdownChunks = + params.chunkMode === "newline" + ? chunkMarkdownTextWithMode(markdown, params.textLimit, params.chunkMode) + : [markdown]; + const chunks: ReturnType = []; + for (const chunk of markdownChunks) { + const nested = markdownToTelegramChunks(chunk, params.textLimit, { + tableMode: params.tableMode, + }); + if (!nested.length && chunk) { + chunks.push({ + html: wrapFileReferencesInHtml( + markdownToTelegramHtml(chunk, { tableMode: params.tableMode, wrapFileRefs: false }), + ), + text: chunk, + }); + continue; + } + chunks.push(...nested); + } + return chunks; + }; +} + +function markDelivered(progress: DeliveryProgress): void { + progress.hasDelivered = true; + progress.deliveredCount += 1; +} + +async function deliverTextReply(params: { + bot: Bot; + chatId: string; + runtime: RuntimeEnv; + thread?: TelegramThreadSpec | null; + chunkText: ChunkTextFn; + replyText: string; + replyMarkup?: ReturnType; + replyQuoteText?: string; + linkPreview?: boolean; + replyToId?: number; + replyToMode: ReplyToMode; + progress: DeliveryProgress; +}): Promise { + let firstDeliveredMessageId: number | undefined; + await sendChunkedTelegramReplyText({ + chunks: params.chunkText(params.replyText), + progress: params.progress, + replyToId: params.replyToId, + replyToMode: params.replyToMode, + replyMarkup: params.replyMarkup, + replyQuoteText: params.replyQuoteText, + markDelivered, + sendChunk: async ({ chunk, replyToMessageId, replyMarkup, replyQuoteText }) => { + const messageId = await sendTelegramText( + params.bot, + params.chatId, + chunk.html, + params.runtime, + { + replyToMessageId, + replyQuoteText, + thread: params.thread, + textMode: "html", + plainText: chunk.text, + linkPreview: params.linkPreview, + replyMarkup, + }, + ); + if (firstDeliveredMessageId == null) { + firstDeliveredMessageId = messageId; + } + }, + }); + return firstDeliveredMessageId; +} + +async function sendPendingFollowUpText(params: { + bot: Bot; + chatId: string; + runtime: RuntimeEnv; + thread?: TelegramThreadSpec | null; + chunkText: ChunkTextFn; + text: string; + replyMarkup?: ReturnType; + linkPreview?: boolean; + replyToId?: number; + replyToMode: ReplyToMode; + progress: DeliveryProgress; +}): Promise { + await sendChunkedTelegramReplyText({ + chunks: params.chunkText(params.text), + progress: params.progress, + replyToId: params.replyToId, + replyToMode: params.replyToMode, + replyMarkup: params.replyMarkup, + markDelivered, + sendChunk: async ({ chunk, replyToMessageId, replyMarkup }) => { + await sendTelegramText(params.bot, params.chatId, chunk.html, params.runtime, { + replyToMessageId, + thread: params.thread, + textMode: "html", + plainText: chunk.text, + linkPreview: params.linkPreview, + replyMarkup, + }); + }, + }); +} + +function isVoiceMessagesForbidden(err: unknown): boolean { + if (err instanceof GrammyError) { + return VOICE_FORBIDDEN_RE.test(err.description); + } + return VOICE_FORBIDDEN_RE.test(formatErrorMessage(err)); +} + +function isCaptionTooLong(err: unknown): boolean { + if (err instanceof GrammyError) { + return CAPTION_TOO_LONG_RE.test(err.description); + } + return CAPTION_TOO_LONG_RE.test(formatErrorMessage(err)); +} + +async function sendTelegramVoiceFallbackText(opts: { + bot: Bot; + chatId: string; + runtime: RuntimeEnv; + text: string; + chunkText: (markdown: string) => ReturnType; + replyToId?: number; + thread?: TelegramThreadSpec | null; + linkPreview?: boolean; + replyMarkup?: ReturnType; + replyQuoteText?: string; +}): Promise { + let firstDeliveredMessageId: number | undefined; + const chunks = opts.chunkText(opts.text); + let appliedReplyTo = false; + for (let i = 0; i < chunks.length; i += 1) { + const chunk = chunks[i]; + // Only apply reply reference, quote text, and buttons to the first chunk. + const replyToForChunk = !appliedReplyTo ? opts.replyToId : undefined; + const messageId = await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, { + replyToMessageId: replyToForChunk, + replyQuoteText: !appliedReplyTo ? opts.replyQuoteText : undefined, + thread: opts.thread, + textMode: "html", + plainText: chunk.text, + linkPreview: opts.linkPreview, + replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined, + }); + if (firstDeliveredMessageId == null) { + firstDeliveredMessageId = messageId; + } + if (replyToForChunk) { + appliedReplyTo = true; + } + } + return firstDeliveredMessageId; +} + +async function deliverMediaReply(params: { + reply: ReplyPayload; + mediaList: string[]; + bot: Bot; + chatId: string; + runtime: RuntimeEnv; + thread?: TelegramThreadSpec | null; + tableMode?: MarkdownTableMode; + mediaLocalRoots?: readonly string[]; + chunkText: ChunkTextFn; + onVoiceRecording?: () => Promise | void; + linkPreview?: boolean; + replyQuoteText?: string; + replyMarkup?: ReturnType; + replyToId?: number; + replyToMode: ReplyToMode; + progress: DeliveryProgress; +}): Promise { + let firstDeliveredMessageId: number | undefined; + let first = true; + let pendingFollowUpText: string | undefined; + for (const mediaUrl of params.mediaList) { + const isFirstMedia = first; + const media = await loadWebMedia( + mediaUrl, + buildOutboundMediaLoadOptions({ mediaLocalRoots: params.mediaLocalRoots }), + ); + const kind = kindFromMime(media.contentType ?? undefined); + const isGif = isGifMedia({ + contentType: media.contentType, + fileName: media.fileName, + }); + const fileName = media.fileName ?? (isGif ? "animation.gif" : "file"); + const file = new InputFile(media.buffer, fileName); + const { caption, followUpText } = splitTelegramCaption( + isFirstMedia ? (params.reply.text ?? undefined) : undefined, + ); + const htmlCaption = caption + ? renderTelegramHtmlText(caption, { tableMode: params.tableMode }) + : undefined; + if (followUpText) { + pendingFollowUpText = followUpText; + } + first = false; + const replyToMessageId = resolveReplyToForSend({ + replyToId: params.replyToId, + replyToMode: params.replyToMode, + progress: params.progress, + }); + const shouldAttachButtonsToMedia = isFirstMedia && params.replyMarkup && !followUpText; + const mediaParams: Record = { + caption: htmlCaption, + ...(htmlCaption ? { parse_mode: "HTML" } : {}), + ...(shouldAttachButtonsToMedia ? { reply_markup: params.replyMarkup } : {}), + ...buildTelegramSendParams({ + replyToMessageId, + thread: params.thread, + }), + }; + if (isGif) { + const result = await sendTelegramWithThreadFallback({ + operation: "sendAnimation", + runtime: params.runtime, + thread: params.thread, + requestParams: mediaParams, + send: (effectiveParams) => + params.bot.api.sendAnimation(params.chatId, file, { ...effectiveParams }), + }); + if (firstDeliveredMessageId == null) { + firstDeliveredMessageId = result.message_id; + } + markDelivered(params.progress); + } else if (kind === "image") { + const result = await sendTelegramWithThreadFallback({ + operation: "sendPhoto", + runtime: params.runtime, + thread: params.thread, + requestParams: mediaParams, + send: (effectiveParams) => + params.bot.api.sendPhoto(params.chatId, file, { ...effectiveParams }), + }); + if (firstDeliveredMessageId == null) { + firstDeliveredMessageId = result.message_id; + } + markDelivered(params.progress); + } else if (kind === "video") { + const result = await sendTelegramWithThreadFallback({ + operation: "sendVideo", + runtime: params.runtime, + thread: params.thread, + requestParams: mediaParams, + send: (effectiveParams) => + params.bot.api.sendVideo(params.chatId, file, { ...effectiveParams }), + }); + if (firstDeliveredMessageId == null) { + firstDeliveredMessageId = result.message_id; + } + markDelivered(params.progress); + } else if (kind === "audio") { + const { useVoice } = resolveTelegramVoiceSend({ + wantsVoice: params.reply.audioAsVoice === true, + contentType: media.contentType, + fileName, + logFallback: logVerbose, + }); + if (useVoice) { + const sendVoiceMedia = async ( + requestParams: typeof mediaParams, + shouldLog?: (err: unknown) => boolean, + ) => { + const result = await sendTelegramWithThreadFallback({ + operation: "sendVoice", + runtime: params.runtime, + thread: params.thread, + requestParams, + shouldLog, + send: (effectiveParams) => + params.bot.api.sendVoice(params.chatId, file, { ...effectiveParams }), + }); + if (firstDeliveredMessageId == null) { + firstDeliveredMessageId = result.message_id; + } + markDelivered(params.progress); + }; + await params.onVoiceRecording?.(); + try { + await sendVoiceMedia(mediaParams, (err) => !isVoiceMessagesForbidden(err)); + } catch (voiceErr) { + if (isVoiceMessagesForbidden(voiceErr)) { + const fallbackText = params.reply.text; + if (!fallbackText || !fallbackText.trim()) { + throw voiceErr; + } + logVerbose( + "telegram sendVoice forbidden (recipient has voice messages blocked in privacy settings); falling back to text", + ); + const voiceFallbackReplyTo = resolveReplyToForSend({ + replyToId: params.replyToId, + replyToMode: params.replyToMode, + progress: params.progress, + }); + const fallbackMessageId = await sendTelegramVoiceFallbackText({ + bot: params.bot, + chatId: params.chatId, + runtime: params.runtime, + text: fallbackText, + chunkText: params.chunkText, + replyToId: voiceFallbackReplyTo, + thread: params.thread, + linkPreview: params.linkPreview, + replyMarkup: params.replyMarkup, + replyQuoteText: params.replyQuoteText, + }); + if (firstDeliveredMessageId == null) { + firstDeliveredMessageId = fallbackMessageId; + } + markReplyApplied(params.progress, voiceFallbackReplyTo); + markDelivered(params.progress); + continue; + } + if (isCaptionTooLong(voiceErr)) { + logVerbose( + "telegram sendVoice caption too long; resending voice without caption + text separately", + ); + const noCaptionParams = { ...mediaParams }; + delete noCaptionParams.caption; + delete noCaptionParams.parse_mode; + await sendVoiceMedia(noCaptionParams); + const fallbackText = params.reply.text; + if (fallbackText?.trim()) { + await sendTelegramVoiceFallbackText({ + bot: params.bot, + chatId: params.chatId, + runtime: params.runtime, + text: fallbackText, + chunkText: params.chunkText, + replyToId: undefined, + thread: params.thread, + linkPreview: params.linkPreview, + replyMarkup: params.replyMarkup, + }); + } + markReplyApplied(params.progress, replyToMessageId); + continue; + } + throw voiceErr; + } + } else { + const result = await sendTelegramWithThreadFallback({ + operation: "sendAudio", + runtime: params.runtime, + thread: params.thread, + requestParams: mediaParams, + send: (effectiveParams) => + params.bot.api.sendAudio(params.chatId, file, { ...effectiveParams }), + }); + if (firstDeliveredMessageId == null) { + firstDeliveredMessageId = result.message_id; + } + markDelivered(params.progress); + } + } else { + const result = await sendTelegramWithThreadFallback({ + operation: "sendDocument", + runtime: params.runtime, + thread: params.thread, + requestParams: mediaParams, + send: (effectiveParams) => + params.bot.api.sendDocument(params.chatId, file, { ...effectiveParams }), + }); + if (firstDeliveredMessageId == null) { + firstDeliveredMessageId = result.message_id; + } + markDelivered(params.progress); + } + markReplyApplied(params.progress, replyToMessageId); + if (pendingFollowUpText && isFirstMedia) { + await sendPendingFollowUpText({ + bot: params.bot, + chatId: params.chatId, + runtime: params.runtime, + thread: params.thread, + chunkText: params.chunkText, + text: pendingFollowUpText, + replyMarkup: params.replyMarkup, + linkPreview: params.linkPreview, + replyToId: params.replyToId, + replyToMode: params.replyToMode, + progress: params.progress, + }); + pendingFollowUpText = undefined; + } + } + return firstDeliveredMessageId; +} + +async function maybePinFirstDeliveredMessage(params: { + shouldPin: boolean; + bot: Bot; + chatId: string; + runtime: RuntimeEnv; + firstDeliveredMessageId?: number; +}): Promise { + if (!params.shouldPin || typeof params.firstDeliveredMessageId !== "number") { + return; + } + try { + await params.bot.api.pinChatMessage(params.chatId, params.firstDeliveredMessageId, { + disable_notification: true, + }); + } catch (err) { + logVerbose( + `telegram pinChatMessage failed chat=${params.chatId} message=${params.firstDeliveredMessageId}: ${formatErrorMessage(err)}`, + ); + } +} + +function emitMessageSentHooks(params: { + hookRunner: ReturnType; + enabled: boolean; + sessionKeyForInternalHooks?: string; + chatId: string; + accountId?: string; + content: string; + success: boolean; + error?: string; + messageId?: number; + isGroup?: boolean; + groupId?: string; +}): void { + if (!params.enabled && !params.sessionKeyForInternalHooks) { + return; + } + const canonical = buildCanonicalSentMessageHookContext({ + to: params.chatId, + content: params.content, + success: params.success, + error: params.error, + channelId: "telegram", + accountId: params.accountId, + conversationId: params.chatId, + messageId: typeof params.messageId === "number" ? String(params.messageId) : undefined, + isGroup: params.isGroup, + groupId: params.groupId, + }); + if (params.enabled) { + fireAndForgetHook( + Promise.resolve( + params.hookRunner!.runMessageSent( + toPluginMessageSentEvent(canonical), + toPluginMessageContext(canonical), + ), + ), + "telegram: message_sent plugin hook failed", + ); + } + if (!params.sessionKeyForInternalHooks) { + return; + } + fireAndForgetHook( + triggerInternalHook( + createInternalHookEvent( + "message", + "sent", + params.sessionKeyForInternalHooks, + toInternalMessageSentContext(canonical), + ), + ), + "telegram: message:sent internal hook failed", + ); +} + +export async function deliverReplies(params: { + replies: ReplyPayload[]; + chatId: string; + accountId?: string; + sessionKeyForInternalHooks?: string; + mirrorIsGroup?: boolean; + mirrorGroupId?: string; + token: string; + runtime: RuntimeEnv; + bot: Bot; + mediaLocalRoots?: readonly string[]; + replyToMode: ReplyToMode; + textLimit: number; + thread?: TelegramThreadSpec | null; + tableMode?: MarkdownTableMode; + chunkMode?: ChunkMode; + /** Callback invoked before sending a voice message to switch typing indicator. */ + onVoiceRecording?: () => Promise | void; + /** Controls whether link previews are shown. Default: true (previews enabled). */ + linkPreview?: boolean; + /** Optional quote text for Telegram reply_parameters. */ + replyQuoteText?: string; +}): Promise<{ delivered: boolean }> { + const progress: DeliveryProgress = { + hasReplied: false, + hasDelivered: false, + deliveredCount: 0, + }; + const hookRunner = getGlobalHookRunner(); + const hasMessageSendingHooks = hookRunner?.hasHooks("message_sending") ?? false; + const hasMessageSentHooks = hookRunner?.hasHooks("message_sent") ?? false; + const chunkText = buildChunkTextResolver({ + textLimit: params.textLimit, + chunkMode: params.chunkMode ?? "length", + tableMode: params.tableMode, + }); + for (const originalReply of params.replies) { + let reply = originalReply; + const mediaList = reply?.mediaUrls?.length + ? reply.mediaUrls + : reply?.mediaUrl + ? [reply.mediaUrl] + : []; + const hasMedia = mediaList.length > 0; + if (!reply?.text && !hasMedia) { + if (reply?.audioAsVoice) { + logVerbose("telegram reply has audioAsVoice without media/text; skipping"); + continue; + } + params.runtime.error?.(danger("reply missing text/media")); + continue; + } + + const rawContent = reply.text || ""; + if (hasMessageSendingHooks) { + const hookResult = await hookRunner?.runMessageSending( + { + to: params.chatId, + content: rawContent, + metadata: { + channel: "telegram", + mediaUrls: mediaList, + threadId: params.thread?.id, + }, + }, + { + channelId: "telegram", + accountId: params.accountId, + conversationId: params.chatId, + }, + ); + if (hookResult?.cancel) { + continue; + } + if (typeof hookResult?.content === "string" && hookResult.content !== rawContent) { + reply = { ...reply, text: hookResult.content }; + } + } + + const contentForSentHook = reply.text || ""; + + try { + const deliveredCountBeforeReply = progress.deliveredCount; + const replyToId = + params.replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId); + const telegramData = reply.channelData?.telegram as TelegramReplyChannelData | undefined; + const shouldPinFirstMessage = telegramData?.pin === true; + const replyMarkup = buildInlineKeyboard(telegramData?.buttons); + let firstDeliveredMessageId: number | undefined; + if (mediaList.length === 0) { + firstDeliveredMessageId = await deliverTextReply({ + bot: params.bot, + chatId: params.chatId, + runtime: params.runtime, + thread: params.thread, + chunkText, + replyText: reply.text || "", + replyMarkup, + replyQuoteText: params.replyQuoteText, + linkPreview: params.linkPreview, + replyToId, + replyToMode: params.replyToMode, + progress, + }); + } else { + firstDeliveredMessageId = await deliverMediaReply({ + reply, + mediaList, + bot: params.bot, + chatId: params.chatId, + runtime: params.runtime, + thread: params.thread, + tableMode: params.tableMode, + mediaLocalRoots: params.mediaLocalRoots, + chunkText, + onVoiceRecording: params.onVoiceRecording, + linkPreview: params.linkPreview, + replyQuoteText: params.replyQuoteText, + replyMarkup, + replyToId, + replyToMode: params.replyToMode, + progress, + }); + } + await maybePinFirstDeliveredMessage({ + shouldPin: shouldPinFirstMessage, + bot: params.bot, + chatId: params.chatId, + runtime: params.runtime, + firstDeliveredMessageId, + }); + + emitMessageSentHooks({ + hookRunner, + enabled: hasMessageSentHooks, + sessionKeyForInternalHooks: params.sessionKeyForInternalHooks, + chatId: params.chatId, + accountId: params.accountId, + content: contentForSentHook, + success: progress.deliveredCount > deliveredCountBeforeReply, + messageId: firstDeliveredMessageId, + isGroup: params.mirrorIsGroup, + groupId: params.mirrorGroupId, + }); + } catch (error) { + emitMessageSentHooks({ + hookRunner, + enabled: hasMessageSentHooks, + sessionKeyForInternalHooks: params.sessionKeyForInternalHooks, + chatId: params.chatId, + accountId: params.accountId, + content: contentForSentHook, + success: false, + error: error instanceof Error ? error.message : String(error), + isGroup: params.mirrorIsGroup, + groupId: params.mirrorGroupId, + }); + throw error; + } + } + + return { delivered: progress.hasDelivered }; +} diff --git a/src/telegram/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts similarity index 98% rename from src/telegram/bot/delivery.resolve-media-retry.test.ts rename to extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index 05d5c5f8b3e..55fec660a82 100644 --- a/src/telegram/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -6,19 +6,19 @@ import type { TelegramContext } from "./types.js"; const saveMediaBuffer = vi.fn(); const fetchRemoteMedia = vi.fn(); -vi.mock("../../media/store.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../../src/media/store.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args), }; }); -vi.mock("../../media/fetch.js", () => ({ +vi.mock("../../../../src/media/fetch.js", () => ({ fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args), })); -vi.mock("../../globals.js", () => ({ +vi.mock("../../../../src/globals.js", () => ({ danger: (s: string) => s, warn: (s: string) => s, logVerbose: () => {}, diff --git a/extensions/telegram/src/bot/delivery.resolve-media.ts b/extensions/telegram/src/bot/delivery.resolve-media.ts new file mode 100644 index 00000000000..e42dd11aa1b --- /dev/null +++ b/extensions/telegram/src/bot/delivery.resolve-media.ts @@ -0,0 +1,290 @@ +import { GrammyError } from "grammy"; +import { logVerbose, warn } from "../../../../src/globals.js"; +import { formatErrorMessage } from "../../../../src/infra/errors.js"; +import { retryAsync } from "../../../../src/infra/retry.js"; +import { fetchRemoteMedia } from "../../../../src/media/fetch.js"; +import { saveMediaBuffer } from "../../../../src/media/store.js"; +import { shouldRetryTelegramIpv4Fallback, type TelegramTransport } from "../fetch.js"; +import { cacheSticker, getCachedSticker } from "../sticker-cache.js"; +import { resolveTelegramMediaPlaceholder } from "./helpers.js"; +import type { StickerMetadata, TelegramContext } from "./types.js"; + +const FILE_TOO_BIG_RE = /file is too big/i; +const TELEGRAM_MEDIA_SSRF_POLICY = { + // Telegram file downloads should trust api.telegram.org even when DNS/proxy + // resolution maps to private/internal ranges in restricted networks. + allowedHostnames: ["api.telegram.org"], + allowRfc2544BenchmarkRange: true, +}; + +/** + * Returns true if the error is Telegram's "file is too big" error. + * This happens when trying to download files >20MB via the Bot API. + * Unlike network errors, this is a permanent error and should not be retried. + */ +function isFileTooBigError(err: unknown): boolean { + if (err instanceof GrammyError) { + return FILE_TOO_BIG_RE.test(err.description); + } + return FILE_TOO_BIG_RE.test(formatErrorMessage(err)); +} + +/** + * Returns true if the error is a transient network error that should be retried. + * Returns false for permanent errors like "file is too big" (400 Bad Request). + */ +function isRetryableGetFileError(err: unknown): boolean { + // Don't retry "file is too big" - it's a permanent 400 error + if (isFileTooBigError(err)) { + return false; + } + // Retry all other errors (network issues, timeouts, etc.) + return true; +} + +function resolveMediaFileRef(msg: TelegramContext["message"]) { + return ( + msg.photo?.[msg.photo.length - 1] ?? + msg.video ?? + msg.video_note ?? + msg.document ?? + msg.audio ?? + msg.voice + ); +} + +function resolveTelegramFileName(msg: TelegramContext["message"]): string | undefined { + return ( + msg.document?.file_name ?? + msg.audio?.file_name ?? + msg.video?.file_name ?? + msg.animation?.file_name + ); +} + +async function resolveTelegramFileWithRetry( + ctx: TelegramContext, +): Promise<{ file_path?: string } | null> { + try { + return await retryAsync(() => ctx.getFile(), { + attempts: 3, + minDelayMs: 1000, + maxDelayMs: 4000, + jitter: 0.2, + label: "telegram:getFile", + shouldRetry: isRetryableGetFileError, + onRetry: ({ attempt, maxAttempts }) => + logVerbose(`telegram: getFile retry ${attempt}/${maxAttempts}`), + }); + } catch (err) { + // Handle "file is too big" separately - Telegram Bot API has a 20MB download limit + if (isFileTooBigError(err)) { + logVerbose( + warn( + "telegram: getFile failed - file exceeds Telegram Bot API 20MB limit; skipping attachment", + ), + ); + return null; + } + // All retries exhausted — return null so the message still reaches the agent + // with a type-based placeholder (e.g. ) instead of being dropped. + logVerbose(`telegram: getFile failed after retries: ${String(err)}`); + return null; + } +} + +function resolveRequiredTelegramTransport(transport?: TelegramTransport): TelegramTransport { + if (transport) { + return transport; + } + const resolvedFetch = globalThis.fetch; + if (!resolvedFetch) { + throw new Error("fetch is not available; set channels.telegram.proxy in config"); + } + return { + fetch: resolvedFetch, + sourceFetch: resolvedFetch, + }; +} + +function resolveOptionalTelegramTransport(transport?: TelegramTransport): TelegramTransport | null { + try { + return resolveRequiredTelegramTransport(transport); + } catch { + return null; + } +} + +/** Default idle timeout for Telegram media downloads (30 seconds). */ +const TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS = 30_000; + +async function downloadAndSaveTelegramFile(params: { + filePath: string; + token: string; + transport: TelegramTransport; + maxBytes: number; + telegramFileName?: string; +}) { + const url = `https://api.telegram.org/file/bot${params.token}/${params.filePath}`; + const fetched = await fetchRemoteMedia({ + url, + fetchImpl: params.transport.sourceFetch, + dispatcherPolicy: params.transport.pinnedDispatcherPolicy, + fallbackDispatcherPolicy: params.transport.fallbackPinnedDispatcherPolicy, + shouldRetryFetchError: shouldRetryTelegramIpv4Fallback, + filePathHint: params.filePath, + maxBytes: params.maxBytes, + readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS, + ssrfPolicy: TELEGRAM_MEDIA_SSRF_POLICY, + }); + const originalName = params.telegramFileName ?? fetched.fileName ?? params.filePath; + return saveMediaBuffer( + fetched.buffer, + fetched.contentType, + "inbound", + params.maxBytes, + originalName, + ); +} + +async function resolveStickerMedia(params: { + msg: TelegramContext["message"]; + ctx: TelegramContext; + maxBytes: number; + token: string; + transport?: TelegramTransport; +}): Promise< + | { + path: string; + contentType?: string; + placeholder: string; + stickerMetadata?: StickerMetadata; + } + | null + | undefined +> { + const { msg, ctx, maxBytes, token, transport } = params; + if (!msg.sticker) { + return undefined; + } + const sticker = msg.sticker; + // Skip animated (TGS) and video (WEBM) stickers - only static WEBP supported + if (sticker.is_animated || sticker.is_video) { + logVerbose("telegram: skipping animated/video sticker (only static stickers supported)"); + return null; + } + if (!sticker.file_id) { + return null; + } + + try { + const file = await resolveTelegramFileWithRetry(ctx); + if (!file?.file_path) { + logVerbose("telegram: getFile returned no file_path for sticker"); + return null; + } + const resolvedTransport = resolveOptionalTelegramTransport(transport); + if (!resolvedTransport) { + logVerbose("telegram: fetch not available for sticker download"); + return null; + } + const saved = await downloadAndSaveTelegramFile({ + filePath: file.file_path, + token, + transport: resolvedTransport, + maxBytes, + }); + + // Check sticker cache for existing description + const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null; + if (cached) { + logVerbose(`telegram: sticker cache hit for ${sticker.file_unique_id}`); + const fileId = sticker.file_id ?? cached.fileId; + const emoji = sticker.emoji ?? cached.emoji; + const setName = sticker.set_name ?? cached.setName; + if (fileId !== cached.fileId || emoji !== cached.emoji || setName !== cached.setName) { + // Refresh cached sticker metadata on hits so sends/searches use latest file_id. + cacheSticker({ + ...cached, + fileId, + emoji, + setName, + }); + } + return { + path: saved.path, + contentType: saved.contentType, + placeholder: "", + stickerMetadata: { + emoji, + setName, + fileId, + fileUniqueId: sticker.file_unique_id, + cachedDescription: cached.description, + }, + }; + } + + // Cache miss - return metadata for vision processing + return { + path: saved.path, + contentType: saved.contentType, + placeholder: "", + stickerMetadata: { + emoji: sticker.emoji ?? undefined, + setName: sticker.set_name ?? undefined, + fileId: sticker.file_id, + fileUniqueId: sticker.file_unique_id, + }, + }; + } catch (err) { + logVerbose(`telegram: failed to process sticker: ${String(err)}`); + return null; + } +} + +export async function resolveMedia( + ctx: TelegramContext, + maxBytes: number, + token: string, + transport?: TelegramTransport, +): Promise<{ + path: string; + contentType?: string; + placeholder: string; + stickerMetadata?: StickerMetadata; +} | null> { + const msg = ctx.message; + const stickerResolved = await resolveStickerMedia({ + msg, + ctx, + maxBytes, + token, + transport, + }); + if (stickerResolved !== undefined) { + return stickerResolved; + } + + const m = resolveMediaFileRef(msg); + if (!m?.file_id) { + return null; + } + + const file = await resolveTelegramFileWithRetry(ctx); + if (!file) { + return null; + } + if (!file.file_path) { + throw new Error("Telegram getFile returned no file_path"); + } + const saved = await downloadAndSaveTelegramFile({ + filePath: file.file_path, + token, + transport: resolveRequiredTelegramTransport(transport), + maxBytes, + telegramFileName: resolveTelegramFileName(msg), + }); + const placeholder = resolveTelegramMediaPlaceholder(msg) ?? ""; + return { path: saved.path, contentType: saved.contentType, placeholder }; +} diff --git a/extensions/telegram/src/bot/delivery.send.ts b/extensions/telegram/src/bot/delivery.send.ts new file mode 100644 index 00000000000..f541495aa76 --- /dev/null +++ b/extensions/telegram/src/bot/delivery.send.ts @@ -0,0 +1,172 @@ +import { type Bot, GrammyError } from "grammy"; +import { formatErrorMessage } from "../../../../src/infra/errors.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; +import { withTelegramApiErrorLogging } from "../api-logging.js"; +import { markdownToTelegramHtml } from "../format.js"; +import { buildInlineKeyboard } from "../send.js"; +import { buildTelegramThreadParams, type TelegramThreadSpec } from "./helpers.js"; + +const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; +const EMPTY_TEXT_ERR_RE = /message text is empty/i; +const THREAD_NOT_FOUND_RE = /message thread not found/i; + +function isTelegramThreadNotFoundError(err: unknown): boolean { + if (err instanceof GrammyError) { + return THREAD_NOT_FOUND_RE.test(err.description); + } + return THREAD_NOT_FOUND_RE.test(formatErrorMessage(err)); +} + +function hasMessageThreadIdParam(params: Record | undefined): boolean { + if (!params) { + return false; + } + return typeof params.message_thread_id === "number"; +} + +function removeMessageThreadIdParam( + params: Record | undefined, +): Record { + if (!params) { + return {}; + } + const { message_thread_id: _ignored, ...rest } = params; + return rest; +} + +export async function sendTelegramWithThreadFallback(params: { + operation: string; + runtime: RuntimeEnv; + thread?: TelegramThreadSpec | null; + requestParams: Record; + send: (effectiveParams: Record) => Promise; + shouldLog?: (err: unknown) => boolean; +}): Promise { + const allowThreadlessRetry = params.thread?.scope === "dm"; + const hasThreadId = hasMessageThreadIdParam(params.requestParams); + const shouldSuppressFirstErrorLog = (err: unknown) => + allowThreadlessRetry && hasThreadId && isTelegramThreadNotFoundError(err); + const mergedShouldLog = params.shouldLog + ? (err: unknown) => params.shouldLog!(err) && !shouldSuppressFirstErrorLog(err) + : (err: unknown) => !shouldSuppressFirstErrorLog(err); + + try { + return await withTelegramApiErrorLogging({ + operation: params.operation, + runtime: params.runtime, + shouldLog: mergedShouldLog, + fn: () => params.send(params.requestParams), + }); + } catch (err) { + if (!allowThreadlessRetry || !hasThreadId || !isTelegramThreadNotFoundError(err)) { + throw err; + } + const retryParams = removeMessageThreadIdParam(params.requestParams); + params.runtime.log?.( + `telegram ${params.operation}: message thread not found; retrying without message_thread_id`, + ); + return await withTelegramApiErrorLogging({ + operation: `${params.operation} (threadless retry)`, + runtime: params.runtime, + fn: () => params.send(retryParams), + }); + } +} + +export function buildTelegramSendParams(opts?: { + replyToMessageId?: number; + thread?: TelegramThreadSpec | null; +}): Record { + const threadParams = buildTelegramThreadParams(opts?.thread); + const params: Record = {}; + if (opts?.replyToMessageId) { + params.reply_to_message_id = opts.replyToMessageId; + } + if (threadParams) { + params.message_thread_id = threadParams.message_thread_id; + } + return params; +} + +export async function sendTelegramText( + bot: Bot, + chatId: string, + text: string, + runtime: RuntimeEnv, + opts?: { + replyToMessageId?: number; + replyQuoteText?: string; + thread?: TelegramThreadSpec | null; + textMode?: "markdown" | "html"; + plainText?: string; + linkPreview?: boolean; + replyMarkup?: ReturnType; + }, +): Promise { + const baseParams = buildTelegramSendParams({ + replyToMessageId: opts?.replyToMessageId, + thread: opts?.thread, + }); + // Add link_preview_options when link preview is disabled. + const linkPreviewEnabled = opts?.linkPreview ?? true; + const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true }; + const textMode = opts?.textMode ?? "markdown"; + const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text); + const fallbackText = opts?.plainText ?? text; + const hasFallbackText = fallbackText.trim().length > 0; + const sendPlainFallback = async () => { + const res = await sendTelegramWithThreadFallback({ + operation: "sendMessage", + runtime, + thread: opts?.thread, + requestParams: baseParams, + send: (effectiveParams) => + bot.api.sendMessage(chatId, fallbackText, { + ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}), + ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}), + ...effectiveParams, + }), + }); + runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id} (plain)`); + return res.message_id; + }; + + // Markdown can render to empty HTML for syntax-only chunks; recover with plain text. + if (!htmlText.trim()) { + if (!hasFallbackText) { + throw new Error("telegram sendMessage failed: empty formatted text and empty plain fallback"); + } + return await sendPlainFallback(); + } + try { + const res = await sendTelegramWithThreadFallback({ + operation: "sendMessage", + runtime, + thread: opts?.thread, + requestParams: baseParams, + shouldLog: (err) => { + const errText = formatErrorMessage(err); + return !PARSE_ERR_RE.test(errText) && !EMPTY_TEXT_ERR_RE.test(errText); + }, + send: (effectiveParams) => + bot.api.sendMessage(chatId, htmlText, { + parse_mode: "HTML", + ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}), + ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}), + ...effectiveParams, + }), + }); + runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id}`); + return res.message_id; + } catch (err) { + const errText = formatErrorMessage(err); + if (PARSE_ERR_RE.test(errText) || EMPTY_TEXT_ERR_RE.test(errText)) { + if (!hasFallbackText) { + throw err; + } + runtime.log?.(`telegram formatted send failed; retrying without formatting: ${errText}`); + return await sendPlainFallback(); + } + throw err; + } +} diff --git a/src/telegram/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts similarity index 98% rename from src/telegram/bot/delivery.test.ts rename to extensions/telegram/src/bot/delivery.test.ts index 0352c687175..a1dce34dceb 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -1,6 +1,6 @@ import type { Bot } from "grammy"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { RuntimeEnv } from "../../runtime.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; import { deliverReplies } from "./delivery.js"; const loadWebMedia = vi.fn(); @@ -24,17 +24,17 @@ type DeliverWithParams = Omit< Partial>; type RuntimeStub = Pick; -vi.mock("../../../extensions/whatsapp/src/media.js", () => ({ +vi.mock("../../../whatsapp/src/media.js", () => ({ loadWebMedia: (...args: unknown[]) => loadWebMedia(...args), })); -vi.mock("../../plugins/hook-runner-global.js", () => ({ +vi.mock("../../../../src/plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => messageHookRunner, })); -vi.mock("../../hooks/internal-hooks.js", async () => { - const actual = await vi.importActual( - "../../hooks/internal-hooks.js", +vi.mock("../../../../src/hooks/internal-hooks.js", async () => { + const actual = await vi.importActual( + "../../../../src/hooks/internal-hooks.js", ); return { ...actual, diff --git a/extensions/telegram/src/bot/delivery.ts b/extensions/telegram/src/bot/delivery.ts new file mode 100644 index 00000000000..bbe599f46b0 --- /dev/null +++ b/extensions/telegram/src/bot/delivery.ts @@ -0,0 +1,2 @@ +export { deliverReplies } from "./delivery.replies.js"; +export { resolveMedia } from "./delivery.resolve-media.js"; diff --git a/src/telegram/bot/helpers.test.ts b/extensions/telegram/src/bot/helpers.test.ts similarity index 100% rename from src/telegram/bot/helpers.test.ts rename to extensions/telegram/src/bot/helpers.test.ts diff --git a/extensions/telegram/src/bot/helpers.ts b/extensions/telegram/src/bot/helpers.ts new file mode 100644 index 00000000000..3575da81efb --- /dev/null +++ b/extensions/telegram/src/bot/helpers.ts @@ -0,0 +1,607 @@ +import type { Chat, Message, MessageOrigin, User } from "@grammyjs/types"; +import { formatLocationText, type NormalizedLocation } from "../../../../src/channels/location.js"; +import { resolveTelegramPreviewStreamMode } from "../../../../src/config/discord-preview-streaming.js"; +import type { + TelegramDirectConfig, + TelegramGroupConfig, + TelegramTopicConfig, +} from "../../../../src/config/types.js"; +import { readChannelAllowFromStore } from "../../../../src/pairing/pairing-store.js"; +import { normalizeAccountId } from "../../../../src/routing/session-key.js"; +import { firstDefined, normalizeAllowFrom, type NormalizedAllowFrom } from "../bot-access.js"; +import type { TelegramStreamMode } from "./types.js"; + +const TELEGRAM_GENERAL_TOPIC_ID = 1; + +export type TelegramThreadSpec = { + id?: number; + scope: "dm" | "forum" | "none"; +}; + +export async function resolveTelegramGroupAllowFromContext(params: { + chatId: string | number; + accountId?: string; + isGroup?: boolean; + isForum?: boolean; + messageThreadId?: number | null; + groupAllowFrom?: Array; + resolveTelegramGroupConfig: ( + chatId: string | number, + messageThreadId?: number, + ) => { + groupConfig?: TelegramGroupConfig | TelegramDirectConfig; + topicConfig?: TelegramTopicConfig; + }; +}): Promise<{ + resolvedThreadId?: number; + dmThreadId?: number; + storeAllowFrom: string[]; + groupConfig?: TelegramGroupConfig | TelegramDirectConfig; + topicConfig?: TelegramTopicConfig; + groupAllowOverride?: Array; + effectiveGroupAllow: NormalizedAllowFrom; + hasGroupAllowOverride: boolean; +}> { + const accountId = normalizeAccountId(params.accountId); + // Use resolveTelegramThreadSpec to handle both forum groups AND DM topics + const threadSpec = resolveTelegramThreadSpec({ + isGroup: params.isGroup ?? false, + isForum: params.isForum, + messageThreadId: params.messageThreadId, + }); + const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined; + const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined; + const threadIdForConfig = resolvedThreadId ?? dmThreadId; + const storeAllowFrom = await readChannelAllowFromStore("telegram", process.env, accountId).catch( + () => [], + ); + const { groupConfig, topicConfig } = params.resolveTelegramGroupConfig( + params.chatId, + threadIdForConfig, + ); + const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom); + // Group sender access must remain explicit (groupAllowFrom/per-group allowFrom only). + // DM pairing store entries are not a group authorization source. + const effectiveGroupAllow = normalizeAllowFrom(groupAllowOverride ?? params.groupAllowFrom); + const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined"; + return { + resolvedThreadId, + dmThreadId, + storeAllowFrom, + groupConfig, + topicConfig, + groupAllowOverride, + effectiveGroupAllow, + hasGroupAllowOverride, + }; +} + +/** + * Resolve the thread ID for Telegram forum topics. + * For non-forum groups, returns undefined even if messageThreadId is present + * (reply threads in regular groups should not create separate sessions). + * For forum groups, returns the topic ID (or General topic ID=1 if unspecified). + */ +export function resolveTelegramForumThreadId(params: { + isForum?: boolean; + messageThreadId?: number | null; +}) { + // Non-forum groups: ignore message_thread_id (reply threads are not real topics) + if (!params.isForum) { + return undefined; + } + // Forum groups: use the topic ID, defaulting to General topic + if (params.messageThreadId == null) { + return TELEGRAM_GENERAL_TOPIC_ID; + } + return params.messageThreadId; +} + +export function resolveTelegramThreadSpec(params: { + isGroup: boolean; + isForum?: boolean; + messageThreadId?: number | null; +}): TelegramThreadSpec { + if (params.isGroup) { + const id = resolveTelegramForumThreadId({ + isForum: params.isForum, + messageThreadId: params.messageThreadId, + }); + return { + id, + scope: params.isForum ? "forum" : "none", + }; + } + if (params.messageThreadId == null) { + return { scope: "dm" }; + } + return { + id: params.messageThreadId, + scope: "dm", + }; +} + +/** + * Build thread params for Telegram API calls (messages, media). + * + * IMPORTANT: Thread IDs behave differently based on chat type: + * - DMs (private chats): Include message_thread_id when present (DM topics) + * - Forum topics: Skip thread_id=1 (General topic), include others + * - Regular groups: Thread IDs are ignored by Telegram + * + * General forum topic (id=1) must be treated like a regular supergroup send: + * Telegram rejects sendMessage/sendMedia with message_thread_id=1 ("thread not found"). + * + * @param thread - Thread specification with ID and scope + * @returns API params object or undefined if thread_id should be omitted + */ +export function buildTelegramThreadParams(thread?: TelegramThreadSpec | null) { + if (thread?.id == null) { + return undefined; + } + const normalized = Math.trunc(thread.id); + + if (thread.scope === "dm") { + return normalized > 0 ? { message_thread_id: normalized } : undefined; + } + + // Telegram rejects message_thread_id=1 for General forum topic + if (normalized === TELEGRAM_GENERAL_TOPIC_ID) { + return undefined; + } + + return { message_thread_id: normalized }; +} + +/** + * Build thread params for typing indicators (sendChatAction). + * Empirically, General topic (id=1) needs message_thread_id for typing to appear. + */ +export function buildTypingThreadParams(messageThreadId?: number) { + if (messageThreadId == null) { + return undefined; + } + return { message_thread_id: Math.trunc(messageThreadId) }; +} + +export function resolveTelegramStreamMode(telegramCfg?: { + streaming?: unknown; + streamMode?: unknown; +}): TelegramStreamMode { + return resolveTelegramPreviewStreamMode(telegramCfg); +} + +export function buildTelegramGroupPeerId(chatId: number | string, messageThreadId?: number) { + return messageThreadId != null ? `${chatId}:topic:${messageThreadId}` : String(chatId); +} + +/** + * Resolve the direct-message peer identifier for Telegram routing/session keys. + * + * In some Telegram DM deliveries (for example certain business/chat bridge flows), + * `chat.id` can differ from the actual sender user id. Prefer sender id when present + * so per-peer DM scopes isolate users correctly. + */ +export function resolveTelegramDirectPeerId(params: { + chatId: number | string; + senderId?: number | string | null; +}) { + const senderId = params.senderId != null ? String(params.senderId).trim() : ""; + if (senderId) { + return senderId; + } + return String(params.chatId); +} + +export function buildTelegramGroupFrom(chatId: number | string, messageThreadId?: number) { + return `telegram:group:${buildTelegramGroupPeerId(chatId, messageThreadId)}`; +} + +/** + * Build parentPeer for forum topic binding inheritance. + * When a message comes from a forum topic, the peer ID includes the topic suffix + * (e.g., `-1001234567890:topic:99`). To allow bindings configured for the base + * group ID to match, we provide the parent group as `parentPeer` so the routing + * layer can fall back to it when the exact peer doesn't match. + */ +export function buildTelegramParentPeer(params: { + isGroup: boolean; + resolvedThreadId?: number; + chatId: number | string; +}): { kind: "group"; id: string } | undefined { + if (!params.isGroup || params.resolvedThreadId == null) { + return undefined; + } + return { kind: "group", id: String(params.chatId) }; +} + +export function buildSenderName(msg: Message) { + const name = + [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ").trim() || + msg.from?.username; + return name || undefined; +} + +export function resolveTelegramMediaPlaceholder( + msg: + | Pick + | undefined + | null, +): string | undefined { + if (!msg) { + return undefined; + } + if (msg.photo) { + return ""; + } + if (msg.video || msg.video_note) { + return ""; + } + if (msg.audio || msg.voice) { + return ""; + } + if (msg.document) { + return ""; + } + if (msg.sticker) { + return ""; + } + return undefined; +} + +export function buildSenderLabel(msg: Message, senderId?: number | string) { + const name = buildSenderName(msg); + const username = msg.from?.username ? `@${msg.from.username}` : undefined; + let label = name; + if (name && username) { + label = `${name} (${username})`; + } else if (!name && username) { + label = username; + } + const normalizedSenderId = + senderId != null && `${senderId}`.trim() ? `${senderId}`.trim() : undefined; + const fallbackId = normalizedSenderId ?? (msg.from?.id != null ? String(msg.from.id) : undefined); + const idPart = fallbackId ? `id:${fallbackId}` : undefined; + if (label && idPart) { + return `${label} ${idPart}`; + } + if (label) { + return label; + } + return idPart ?? "id:unknown"; +} + +export function buildGroupLabel(msg: Message, chatId: number | string, messageThreadId?: number) { + const title = msg.chat?.title; + const topicSuffix = messageThreadId != null ? ` topic:${messageThreadId}` : ""; + if (title) { + return `${title} id:${chatId}${topicSuffix}`; + } + return `group:${chatId}${topicSuffix}`; +} + +export type TelegramTextEntity = NonNullable[number]; + +export function getTelegramTextParts( + msg: Pick, +): { + text: string; + entities: TelegramTextEntity[]; +} { + const text = msg.text ?? msg.caption ?? ""; + const entities = msg.entities ?? msg.caption_entities ?? []; + return { text, entities }; +} + +function isTelegramMentionWordChar(char: string | undefined): boolean { + return char != null && /[a-z0-9_]/i.test(char); +} + +function hasStandaloneTelegramMention(text: string, mention: string): boolean { + let startIndex = 0; + while (startIndex < text.length) { + const idx = text.indexOf(mention, startIndex); + if (idx === -1) { + return false; + } + const prev = idx > 0 ? text[idx - 1] : undefined; + const next = text[idx + mention.length]; + if (!isTelegramMentionWordChar(prev) && !isTelegramMentionWordChar(next)) { + return true; + } + startIndex = idx + 1; + } + return false; +} + +export function hasBotMention(msg: Message, botUsername: string) { + const { text, entities } = getTelegramTextParts(msg); + const mention = `@${botUsername}`.toLowerCase(); + if (hasStandaloneTelegramMention(text.toLowerCase(), mention)) { + return true; + } + for (const ent of entities) { + if (ent.type !== "mention") { + continue; + } + const slice = text.slice(ent.offset, ent.offset + ent.length); + if (slice.toLowerCase() === mention) { + return true; + } + } + return false; +} + +type TelegramTextLinkEntity = { + type: string; + offset: number; + length: number; + url?: string; +}; + +export function expandTextLinks(text: string, entities?: TelegramTextLinkEntity[] | null): string { + if (!text || !entities?.length) { + return text; + } + + const textLinks = entities + .filter( + (entity): entity is TelegramTextLinkEntity & { url: string } => + entity.type === "text_link" && Boolean(entity.url), + ) + .toSorted((a, b) => b.offset - a.offset); + + if (textLinks.length === 0) { + return text; + } + + let result = text; + for (const entity of textLinks) { + const linkText = text.slice(entity.offset, entity.offset + entity.length); + const markdown = `[${linkText}](${entity.url})`; + result = + result.slice(0, entity.offset) + markdown + result.slice(entity.offset + entity.length); + } + return result; +} + +export function resolveTelegramReplyId(raw?: string): number | undefined { + if (!raw) { + return undefined; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed)) { + return undefined; + } + return parsed; +} + +export type TelegramReplyTarget = { + id?: string; + sender: string; + body: string; + kind: "reply" | "quote"; + /** Forward context if the reply target was itself a forwarded message (issue #9619). */ + forwardedFrom?: TelegramForwardedContext; +}; + +export function describeReplyTarget(msg: Message): TelegramReplyTarget | null { + const reply = msg.reply_to_message; + const externalReply = (msg as Message & { external_reply?: Message }).external_reply; + const quoteText = + msg.quote?.text ?? + (externalReply as (Message & { quote?: { text?: string } }) | undefined)?.quote?.text; + let body = ""; + let kind: TelegramReplyTarget["kind"] = "reply"; + + if (typeof quoteText === "string") { + body = quoteText.trim(); + if (body) { + kind = "quote"; + } + } + + const replyLike = reply ?? externalReply; + if (!body && replyLike) { + const replyBody = (replyLike.text ?? replyLike.caption ?? "").trim(); + body = replyBody; + if (!body) { + body = resolveTelegramMediaPlaceholder(replyLike) ?? ""; + if (!body) { + const locationData = extractTelegramLocation(replyLike); + if (locationData) { + body = formatLocationText(locationData); + } + } + } + } + if (!body) { + return null; + } + const sender = replyLike ? buildSenderName(replyLike) : undefined; + const senderLabel = sender ?? "unknown sender"; + + // Extract forward context from the resolved reply target (reply_to_message or external_reply). + const forwardedFrom = replyLike?.forward_origin + ? (resolveForwardOrigin(replyLike.forward_origin) ?? undefined) + : undefined; + + return { + id: replyLike?.message_id ? String(replyLike.message_id) : undefined, + sender: senderLabel, + body, + kind, + forwardedFrom, + }; +} + +export type TelegramForwardedContext = { + from: string; + date?: number; + fromType: string; + fromId?: string; + fromUsername?: string; + fromTitle?: string; + fromSignature?: string; + /** Original chat type from forward_from_chat (e.g. "channel", "supergroup", "group"). */ + fromChatType?: Chat["type"]; + /** Original message ID in the source chat (channel forwards). */ + fromMessageId?: number; +}; + +function normalizeForwardedUserLabel(user: User) { + const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim(); + const username = user.username?.trim() || undefined; + const id = String(user.id); + const display = + (name && username + ? `${name} (@${username})` + : name || (username ? `@${username}` : undefined)) || `user:${id}`; + return { display, name: name || undefined, username, id }; +} + +function normalizeForwardedChatLabel(chat: Chat, fallbackKind: "chat" | "channel") { + const title = chat.title?.trim() || undefined; + const username = chat.username?.trim() || undefined; + const id = String(chat.id); + const display = title || (username ? `@${username}` : undefined) || `${fallbackKind}:${id}`; + return { display, title, username, id }; +} + +function buildForwardedContextFromUser(params: { + user: User; + date?: number; + type: string; +}): TelegramForwardedContext | null { + const { display, name, username, id } = normalizeForwardedUserLabel(params.user); + if (!display) { + return null; + } + return { + from: display, + date: params.date, + fromType: params.type, + fromId: id, + fromUsername: username, + fromTitle: name, + }; +} + +function buildForwardedContextFromHiddenName(params: { + name?: string; + date?: number; + type: string; +}): TelegramForwardedContext | null { + const trimmed = params.name?.trim(); + if (!trimmed) { + return null; + } + return { + from: trimmed, + date: params.date, + fromType: params.type, + fromTitle: trimmed, + }; +} + +function buildForwardedContextFromChat(params: { + chat: Chat; + date?: number; + type: string; + signature?: string; + messageId?: number; +}): TelegramForwardedContext | null { + const fallbackKind = params.type === "channel" ? "channel" : "chat"; + const { display, title, username, id } = normalizeForwardedChatLabel(params.chat, fallbackKind); + if (!display) { + return null; + } + const signature = params.signature?.trim() || undefined; + const from = signature ? `${display} (${signature})` : display; + const chatType = (params.chat.type?.trim() || undefined) as Chat["type"] | undefined; + return { + from, + date: params.date, + fromType: params.type, + fromId: id, + fromUsername: username, + fromTitle: title, + fromSignature: signature, + fromChatType: chatType, + fromMessageId: params.messageId, + }; +} + +function resolveForwardOrigin(origin: MessageOrigin): TelegramForwardedContext | null { + switch (origin.type) { + case "user": + return buildForwardedContextFromUser({ + user: origin.sender_user, + date: origin.date, + type: "user", + }); + case "hidden_user": + return buildForwardedContextFromHiddenName({ + name: origin.sender_user_name, + date: origin.date, + type: "hidden_user", + }); + case "chat": + return buildForwardedContextFromChat({ + chat: origin.sender_chat, + date: origin.date, + type: "chat", + signature: origin.author_signature, + }); + case "channel": + return buildForwardedContextFromChat({ + chat: origin.chat, + date: origin.date, + type: "channel", + signature: origin.author_signature, + messageId: origin.message_id, + }); + default: + // Exhaustiveness guard: if Grammy adds a new MessageOrigin variant, + // TypeScript will flag this assignment as an error. + origin satisfies never; + return null; + } +} + +/** Extract forwarded message origin info from Telegram message. */ +export function normalizeForwardedContext(msg: Message): TelegramForwardedContext | null { + if (!msg.forward_origin) { + return null; + } + return resolveForwardOrigin(msg.forward_origin); +} + +export function extractTelegramLocation(msg: Message): NormalizedLocation | null { + const { venue, location } = msg; + + if (venue) { + return { + latitude: venue.location.latitude, + longitude: venue.location.longitude, + accuracy: venue.location.horizontal_accuracy, + name: venue.title, + address: venue.address, + source: "place", + isLive: false, + }; + } + + if (location) { + const isLive = typeof location.live_period === "number" && location.live_period > 0; + return { + latitude: location.latitude, + longitude: location.longitude, + accuracy: location.horizontal_accuracy, + source: isLive ? "live" : "pin", + isLive, + }; + } + + return null; +} diff --git a/extensions/telegram/src/bot/reply-threading.ts b/extensions/telegram/src/bot/reply-threading.ts new file mode 100644 index 00000000000..cdeeba7151b --- /dev/null +++ b/extensions/telegram/src/bot/reply-threading.ts @@ -0,0 +1,82 @@ +import type { ReplyToMode } from "../../../../src/config/config.js"; + +export type DeliveryProgress = { + hasReplied: boolean; + hasDelivered: boolean; +}; + +export function createDeliveryProgress(): DeliveryProgress { + return { + hasReplied: false, + hasDelivered: false, + }; +} + +export function resolveReplyToForSend(params: { + replyToId?: number; + replyToMode: ReplyToMode; + progress: DeliveryProgress; +}): number | undefined { + return params.replyToId && (params.replyToMode === "all" || !params.progress.hasReplied) + ? params.replyToId + : undefined; +} + +export function markReplyApplied(progress: DeliveryProgress, replyToId?: number): void { + if (replyToId && !progress.hasReplied) { + progress.hasReplied = true; + } +} + +export function markDelivered(progress: DeliveryProgress): void { + progress.hasDelivered = true; +} + +export async function sendChunkedTelegramReplyText< + TChunk, + TReplyMarkup = unknown, + TProgress extends DeliveryProgress = DeliveryProgress, +>(params: { + chunks: readonly TChunk[]; + progress: TProgress; + replyToId?: number; + replyToMode: ReplyToMode; + replyMarkup?: TReplyMarkup; + replyQuoteText?: string; + quoteOnlyOnFirstChunk?: boolean; + markDelivered?: (progress: TProgress) => void; + sendChunk: (opts: { + chunk: TChunk; + isFirstChunk: boolean; + replyToMessageId?: number; + replyMarkup?: TReplyMarkup; + replyQuoteText?: string; + }) => Promise; +}): Promise { + const applyDelivered = params.markDelivered ?? markDelivered; + for (let i = 0; i < params.chunks.length; i += 1) { + const chunk = params.chunks[i]; + if (!chunk) { + continue; + } + const isFirstChunk = i === 0; + const replyToMessageId = resolveReplyToForSend({ + replyToId: params.replyToId, + replyToMode: params.replyToMode, + progress: params.progress, + }); + const shouldAttachQuote = + Boolean(replyToMessageId) && + Boolean(params.replyQuoteText) && + (params.quoteOnlyOnFirstChunk !== true || isFirstChunk); + await params.sendChunk({ + chunk, + isFirstChunk, + replyToMessageId, + replyMarkup: isFirstChunk ? params.replyMarkup : undefined, + replyQuoteText: shouldAttachQuote ? params.replyQuoteText : undefined, + }); + markReplyApplied(params.progress, replyToMessageId); + applyDelivered(params.progress); + } +} diff --git a/extensions/telegram/src/bot/types.ts b/extensions/telegram/src/bot/types.ts new file mode 100644 index 00000000000..c529c61c458 --- /dev/null +++ b/extensions/telegram/src/bot/types.ts @@ -0,0 +1,29 @@ +import type { Message, UserFromGetMe } from "@grammyjs/types"; + +/** App-specific stream mode for Telegram stream previews. */ +export type TelegramStreamMode = "off" | "partial" | "block"; + +/** + * Minimal context projection from Grammy's Context class. + * Decouples the message processing pipeline from Grammy's full Context, + * and allows constructing synthetic contexts for debounced/combined messages. + */ +export type TelegramContext = { + message: Message; + me?: UserFromGetMe; + getFile: () => Promise<{ file_path?: string }>; +}; + +/** Telegram sticker metadata for context enrichment and caching. */ +export interface StickerMetadata { + /** Emoji associated with the sticker. */ + emoji?: string; + /** Name of the sticker set the sticker belongs to. */ + setName?: string; + /** Telegram file_id for sending the sticker back. */ + fileId?: string; + /** Stable file_unique_id for cache deduplication. */ + fileUniqueId?: string; + /** Cached description from previous vision processing (skip re-processing if present). */ + cachedDescription?: string; +} diff --git a/extensions/telegram/src/button-types.ts b/extensions/telegram/src/button-types.ts new file mode 100644 index 00000000000..922b72acd9f --- /dev/null +++ b/extensions/telegram/src/button-types.ts @@ -0,0 +1,9 @@ +export type TelegramButtonStyle = "danger" | "success" | "primary"; + +export type TelegramInlineButton = { + text: string; + callback_data: string; + style?: TelegramButtonStyle; +}; + +export type TelegramInlineButtons = ReadonlyArray>; diff --git a/extensions/telegram/src/caption.ts b/extensions/telegram/src/caption.ts new file mode 100644 index 00000000000..e9981c8c425 --- /dev/null +++ b/extensions/telegram/src/caption.ts @@ -0,0 +1,15 @@ +export const TELEGRAM_MAX_CAPTION_LENGTH = 1024; + +export function splitTelegramCaption(text?: string): { + caption?: string; + followUpText?: string; +} { + const trimmed = text?.trim() ?? ""; + if (!trimmed) { + return { caption: undefined, followUpText: undefined }; + } + if (trimmed.length > TELEGRAM_MAX_CAPTION_LENGTH) { + return { caption: undefined, followUpText: trimmed }; + } + return { caption: trimmed, followUpText: undefined }; +} diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts new file mode 100644 index 00000000000..998e0b5d266 --- /dev/null +++ b/extensions/telegram/src/channel-actions.ts @@ -0,0 +1,293 @@ +import { + readNumberParam, + readStringArrayParam, + readStringOrNumberParam, + readStringParam, +} from "../../../src/agents/tools/common.js"; +import { handleTelegramAction } from "../../../src/agents/tools/telegram-actions.js"; +import { resolveReactionMessageId } from "../../../src/channels/plugins/actions/reaction-message-id.js"; +import { + createUnionActionGate, + listTokenSourcedAccounts, +} from "../../../src/channels/plugins/actions/shared.js"; +import type { + ChannelMessageActionAdapter, + ChannelMessageActionName, +} from "../../../src/channels/plugins/types.js"; +import type { TelegramActionConfig } from "../../../src/config/types.telegram.js"; +import { readBooleanParam } from "../../../src/plugin-sdk/boolean-param.js"; +import { extractToolSend } from "../../../src/plugin-sdk/tool-send.js"; +import { resolveTelegramPollVisibility } from "../../../src/poll-params.js"; +import { + createTelegramActionGate, + listEnabledTelegramAccounts, + resolveTelegramPollActionGateState, +} from "./accounts.js"; +import { isTelegramInlineButtonsEnabled } from "./inline-buttons.js"; + +const providerId = "telegram"; + +function readTelegramSendParams(params: Record) { + const to = readStringParam(params, "to", { required: true }); + const mediaUrl = readStringParam(params, "media", { trim: false }); + const message = readStringParam(params, "message", { required: !mediaUrl, allowEmpty: true }); + const caption = readStringParam(params, "caption", { allowEmpty: true }); + const content = message || caption || ""; + const replyTo = readStringParam(params, "replyTo"); + const threadId = readStringParam(params, "threadId"); + const buttons = params.buttons; + const asVoice = readBooleanParam(params, "asVoice"); + const silent = readBooleanParam(params, "silent"); + const quoteText = readStringParam(params, "quoteText"); + return { + to, + content, + mediaUrl: mediaUrl ?? undefined, + replyToMessageId: replyTo ?? undefined, + messageThreadId: threadId ?? undefined, + buttons, + asVoice, + silent, + quoteText: quoteText ?? undefined, + }; +} + +function readTelegramChatIdParam(params: Record): string | number { + return ( + readStringOrNumberParam(params, "chatId") ?? + readStringOrNumberParam(params, "channelId") ?? + readStringParam(params, "to", { required: true }) + ); +} + +function readTelegramMessageIdParam(params: Record): number { + const messageId = readNumberParam(params, "messageId", { + required: true, + integer: true, + }); + if (typeof messageId !== "number") { + throw new Error("messageId is required."); + } + return messageId; +} + +export const telegramMessageActions: ChannelMessageActionAdapter = { + listActions: ({ cfg }) => { + const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg)); + if (accounts.length === 0) { + return []; + } + // Union of all accounts' action gates (any account enabling an action makes it available) + const gate = createUnionActionGate(accounts, (account) => + createTelegramActionGate({ + cfg, + accountId: account.accountId, + }), + ); + const isEnabled = (key: keyof TelegramActionConfig, defaultValue = true) => + gate(key, defaultValue); + const actions = new Set(["send"]); + const pollEnabledForAnyAccount = accounts.some((account) => { + const accountGate = createTelegramActionGate({ + cfg, + accountId: account.accountId, + }); + return resolveTelegramPollActionGateState(accountGate).enabled; + }); + if (pollEnabledForAnyAccount) { + actions.add("poll"); + } + if (isEnabled("reactions")) { + actions.add("react"); + } + if (isEnabled("deleteMessage")) { + actions.add("delete"); + } + if (isEnabled("editMessage")) { + actions.add("edit"); + } + if (isEnabled("sticker", false)) { + actions.add("sticker"); + actions.add("sticker-search"); + } + if (isEnabled("createForumTopic")) { + actions.add("topic-create"); + } + return Array.from(actions); + }, + supportsButtons: ({ cfg }) => { + const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg)); + if (accounts.length === 0) { + return false; + } + return accounts.some((account) => + isTelegramInlineButtonsEnabled({ cfg, accountId: account.accountId }), + ); + }, + extractToolSend: ({ args }) => { + return extractToolSend(args, "sendMessage"); + }, + handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots, toolContext }) => { + if (action === "send") { + const sendParams = readTelegramSendParams(params); + return await handleTelegramAction( + { + action: "sendMessage", + ...sendParams, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + + if (action === "react") { + const messageId = resolveReactionMessageId({ args: params, toolContext }); + const emoji = readStringParam(params, "emoji", { allowEmpty: true }); + const remove = readBooleanParam(params, "remove"); + return await handleTelegramAction( + { + action: "react", + chatId: readTelegramChatIdParam(params), + messageId, + emoji, + remove, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + + if (action === "poll") { + const to = readStringParam(params, "to", { required: true }); + const question = readStringParam(params, "pollQuestion", { required: true }); + const answers = readStringArrayParam(params, "pollOption", { required: true }); + const durationHours = readNumberParam(params, "pollDurationHours", { + integer: true, + strict: true, + }); + const durationSeconds = readNumberParam(params, "pollDurationSeconds", { + integer: true, + strict: true, + }); + const replyToMessageId = readNumberParam(params, "replyTo", { integer: true }); + const messageThreadId = readNumberParam(params, "threadId", { integer: true }); + const allowMultiselect = readBooleanParam(params, "pollMulti"); + const pollAnonymous = readBooleanParam(params, "pollAnonymous"); + const pollPublic = readBooleanParam(params, "pollPublic"); + const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic }); + const silent = readBooleanParam(params, "silent"); + return await handleTelegramAction( + { + action: "poll", + to, + question, + answers, + allowMultiselect, + durationHours: durationHours ?? undefined, + durationSeconds: durationSeconds ?? undefined, + replyToMessageId: replyToMessageId ?? undefined, + messageThreadId: messageThreadId ?? undefined, + isAnonymous, + silent, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + + if (action === "delete") { + const chatId = readTelegramChatIdParam(params); + const messageId = readTelegramMessageIdParam(params); + return await handleTelegramAction( + { + action: "deleteMessage", + chatId, + messageId, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + + if (action === "edit") { + const chatId = readTelegramChatIdParam(params); + const messageId = readTelegramMessageIdParam(params); + const message = readStringParam(params, "message", { required: true, allowEmpty: false }); + const buttons = params.buttons; + return await handleTelegramAction( + { + action: "editMessage", + chatId, + messageId, + content: message, + buttons, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + + if (action === "sticker") { + const to = + readStringParam(params, "to") ?? readStringParam(params, "target", { required: true }); + // Accept stickerId (array from shared schema) and use first element as fileId + const stickerIds = readStringArrayParam(params, "stickerId"); + const fileId = stickerIds?.[0] ?? readStringParam(params, "fileId", { required: true }); + const replyToMessageId = readNumberParam(params, "replyTo", { integer: true }); + const messageThreadId = readNumberParam(params, "threadId", { integer: true }); + return await handleTelegramAction( + { + action: "sendSticker", + to, + fileId, + replyToMessageId: replyToMessageId ?? undefined, + messageThreadId: messageThreadId ?? undefined, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + + if (action === "sticker-search") { + const query = readStringParam(params, "query", { required: true }); + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleTelegramAction( + { + action: "searchSticker", + query, + limit: limit ?? undefined, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + + if (action === "topic-create") { + const chatId = readTelegramChatIdParam(params); + const name = readStringParam(params, "name", { required: true }); + const iconColor = readNumberParam(params, "iconColor", { integer: true }); + const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId"); + return await handleTelegramAction( + { + action: "createForumTopic", + chatId, + name, + iconColor: iconColor ?? undefined, + iconCustomEmojiId: iconCustomEmojiId ?? undefined, + accountId: accountId ?? undefined, + }, + cfg, + { mediaLocalRoots }, + ); + } + + throw new Error(`Action ${action} is not supported for provider ${providerId}.`); + }, +}; diff --git a/extensions/telegram/src/conversation-route.ts b/extensions/telegram/src/conversation-route.ts new file mode 100644 index 00000000000..20137468486 --- /dev/null +++ b/extensions/telegram/src/conversation-route.ts @@ -0,0 +1,143 @@ +import { resolveConfiguredAcpRoute } from "../../../src/acp/persistent-bindings.route.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { logVerbose } from "../../../src/globals.js"; +import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js"; +import { + buildAgentSessionKey, + deriveLastRoutePolicy, + pickFirstExistingAgentId, + resolveAgentRoute, +} from "../../../src/routing/resolve-route.js"; +import { + buildAgentMainSessionKey, + resolveAgentIdFromSessionKey, +} from "../../../src/routing/session-key.js"; +import { + buildTelegramGroupPeerId, + buildTelegramParentPeer, + resolveTelegramDirectPeerId, +} from "./bot/helpers.js"; + +export function resolveTelegramConversationRoute(params: { + cfg: OpenClawConfig; + accountId: string; + chatId: number | string; + isGroup: boolean; + resolvedThreadId?: number; + replyThreadId?: number; + senderId?: string | number | null; + topicAgentId?: string | null; +}): { + route: ReturnType; + configuredBinding: ReturnType["configuredBinding"]; + configuredBindingSessionKey: string; +} { + const peerId = params.isGroup + ? buildTelegramGroupPeerId(params.chatId, params.resolvedThreadId) + : resolveTelegramDirectPeerId({ + chatId: params.chatId, + senderId: params.senderId, + }); + const parentPeer = buildTelegramParentPeer({ + isGroup: params.isGroup, + resolvedThreadId: params.resolvedThreadId, + chatId: params.chatId, + }); + let route = resolveAgentRoute({ + cfg: params.cfg, + channel: "telegram", + accountId: params.accountId, + peer: { + kind: params.isGroup ? "group" : "direct", + id: peerId, + }, + parentPeer, + }); + + const rawTopicAgentId = params.topicAgentId?.trim(); + if (rawTopicAgentId) { + const topicAgentId = pickFirstExistingAgentId(params.cfg, rawTopicAgentId); + route = { + ...route, + agentId: topicAgentId, + sessionKey: buildAgentSessionKey({ + agentId: topicAgentId, + channel: "telegram", + accountId: params.accountId, + peer: { kind: params.isGroup ? "group" : "direct", id: peerId }, + dmScope: params.cfg.session?.dmScope, + identityLinks: params.cfg.session?.identityLinks, + }).toLowerCase(), + mainSessionKey: buildAgentMainSessionKey({ + agentId: topicAgentId, + }).toLowerCase(), + lastRoutePolicy: deriveLastRoutePolicy({ + sessionKey: buildAgentSessionKey({ + agentId: topicAgentId, + channel: "telegram", + accountId: params.accountId, + peer: { kind: params.isGroup ? "group" : "direct", id: peerId }, + dmScope: params.cfg.session?.dmScope, + identityLinks: params.cfg.session?.identityLinks, + }).toLowerCase(), + mainSessionKey: buildAgentMainSessionKey({ + agentId: topicAgentId, + }).toLowerCase(), + }), + }; + logVerbose( + `telegram: topic route override: topic=${params.resolvedThreadId ?? params.replyThreadId} agent=${topicAgentId} sessionKey=${route.sessionKey}`, + ); + } + + const configuredRoute = resolveConfiguredAcpRoute({ + cfg: params.cfg, + route, + channel: "telegram", + accountId: params.accountId, + conversationId: peerId, + parentConversationId: params.isGroup ? String(params.chatId) : undefined, + }); + let configuredBinding = configuredRoute.configuredBinding; + let configuredBindingSessionKey = configuredRoute.boundSessionKey ?? ""; + route = configuredRoute.route; + + const threadBindingConversationId = + params.replyThreadId != null + ? `${params.chatId}:topic:${params.replyThreadId}` + : !params.isGroup + ? String(params.chatId) + : undefined; + if (threadBindingConversationId) { + const threadBinding = getSessionBindingService().resolveByConversation({ + channel: "telegram", + accountId: params.accountId, + conversationId: threadBindingConversationId, + }); + const boundSessionKey = threadBinding?.targetSessionKey?.trim(); + if (threadBinding && boundSessionKey) { + route = { + ...route, + sessionKey: boundSessionKey, + agentId: resolveAgentIdFromSessionKey(boundSessionKey), + lastRoutePolicy: deriveLastRoutePolicy({ + sessionKey: boundSessionKey, + mainSessionKey: route.mainSessionKey, + }), + matchedBy: "binding.channel", + }; + configuredBinding = null; + configuredBindingSessionKey = ""; + getSessionBindingService().touch(threadBinding.bindingId); + logVerbose( + `telegram: routed via bound conversation ${threadBindingConversationId} -> ${boundSessionKey}`, + ); + } + } + + return { + route, + configuredBinding, + configuredBindingSessionKey, + }; +} diff --git a/extensions/telegram/src/dm-access.ts b/extensions/telegram/src/dm-access.ts new file mode 100644 index 00000000000..db8cc419c6a --- /dev/null +++ b/extensions/telegram/src/dm-access.ts @@ -0,0 +1,123 @@ +import type { Message } from "@grammyjs/types"; +import type { Bot } from "grammy"; +import type { DmPolicy } from "../../../src/config/types.js"; +import { logVerbose } from "../../../src/globals.js"; +import { issuePairingChallenge } from "../../../src/pairing/pairing-challenge.js"; +import { upsertChannelPairingRequest } from "../../../src/pairing/pairing-store.js"; +import { withTelegramApiErrorLogging } from "./api-logging.js"; +import { resolveSenderAllowMatch, type NormalizedAllowFrom } from "./bot-access.js"; + +type TelegramDmAccessLogger = { + info: (obj: Record, msg: string) => void; +}; + +type TelegramSenderIdentity = { + username: string; + userId: string | null; + candidateId: string; + firstName?: string; + lastName?: string; +}; + +function resolveTelegramSenderIdentity(msg: Message, chatId: number): TelegramSenderIdentity { + const from = msg.from; + const userId = from?.id != null ? String(from.id) : null; + return { + username: from?.username ?? "", + userId, + candidateId: userId ?? String(chatId), + firstName: from?.first_name, + lastName: from?.last_name, + }; +} + +export async function enforceTelegramDmAccess(params: { + isGroup: boolean; + dmPolicy: DmPolicy; + msg: Message; + chatId: number; + effectiveDmAllow: NormalizedAllowFrom; + accountId: string; + bot: Bot; + logger: TelegramDmAccessLogger; +}): Promise { + const { isGroup, dmPolicy, msg, chatId, effectiveDmAllow, accountId, bot, logger } = params; + if (isGroup) { + return true; + } + if (dmPolicy === "disabled") { + return false; + } + if (dmPolicy === "open") { + return true; + } + + const sender = resolveTelegramSenderIdentity(msg, chatId); + const allowMatch = resolveSenderAllowMatch({ + allow: effectiveDmAllow, + senderId: sender.candidateId, + senderUsername: sender.username, + }); + const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${ + allowMatch.matchSource ?? "none" + }`; + const allowed = + effectiveDmAllow.hasWildcard || (effectiveDmAllow.hasEntries && allowMatch.allowed); + if (allowed) { + return true; + } + + if (dmPolicy === "pairing") { + try { + const telegramUserId = sender.userId ?? sender.candidateId; + await issuePairingChallenge({ + channel: "telegram", + senderId: telegramUserId, + senderIdLine: `Your Telegram user id: ${telegramUserId}`, + meta: { + username: sender.username || undefined, + firstName: sender.firstName, + lastName: sender.lastName, + }, + upsertPairingRequest: async ({ id, meta }) => + await upsertChannelPairingRequest({ + channel: "telegram", + id, + accountId, + meta, + }), + onCreated: () => { + logger.info( + { + chatId: String(chatId), + senderUserId: sender.userId ?? undefined, + username: sender.username || undefined, + firstName: sender.firstName, + lastName: sender.lastName, + matchKey: allowMatch.matchKey ?? "none", + matchSource: allowMatch.matchSource ?? "none", + }, + "telegram pairing request", + ); + }, + sendPairingReply: async (text) => { + await withTelegramApiErrorLogging({ + operation: "sendMessage", + fn: () => bot.api.sendMessage(chatId, text), + }); + }, + onReplyError: (err) => { + logVerbose(`telegram pairing reply failed for chat ${chatId}: ${String(err)}`); + }, + }); + } catch (err) { + logVerbose(`telegram pairing reply failed for chat ${chatId}: ${String(err)}`); + } + return false; + } + + logVerbose( + `Blocked unauthorized telegram sender ${sender.candidateId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, + ); + return false; +} diff --git a/src/telegram/draft-chunking.test.ts b/extensions/telegram/src/draft-chunking.test.ts similarity index 95% rename from src/telegram/draft-chunking.test.ts rename to extensions/telegram/src/draft-chunking.test.ts index cc24f069624..0243715a18d 100644 --- a/src/telegram/draft-chunking.test.ts +++ b/extensions/telegram/src/draft-chunking.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js"; describe("resolveTelegramDraftStreamingChunking", () => { diff --git a/extensions/telegram/src/draft-chunking.ts b/extensions/telegram/src/draft-chunking.ts new file mode 100644 index 00000000000..f907faf02f8 --- /dev/null +++ b/extensions/telegram/src/draft-chunking.ts @@ -0,0 +1,41 @@ +import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; +import { getChannelDock } from "../../../src/channels/dock.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; + +const DEFAULT_TELEGRAM_DRAFT_STREAM_MIN = 200; +const DEFAULT_TELEGRAM_DRAFT_STREAM_MAX = 800; + +export function resolveTelegramDraftStreamingChunking( + cfg: OpenClawConfig | undefined, + accountId?: string | null, +): { + minChars: number; + maxChars: number; + breakPreference: "paragraph" | "newline" | "sentence"; +} { + const providerChunkLimit = getChannelDock("telegram")?.outbound?.textChunkLimit; + const textLimit = resolveTextChunkLimit(cfg, "telegram", accountId, { + fallbackLimit: providerChunkLimit, + }); + const normalizedAccountId = normalizeAccountId(accountId); + const accountCfg = resolveAccountEntry(cfg?.channels?.telegram?.accounts, normalizedAccountId); + const draftCfg = accountCfg?.draftChunk ?? cfg?.channels?.telegram?.draftChunk; + + const maxRequested = Math.max( + 1, + Math.floor(draftCfg?.maxChars ?? DEFAULT_TELEGRAM_DRAFT_STREAM_MAX), + ); + const maxChars = Math.max(1, Math.min(maxRequested, textLimit)); + const minRequested = Math.max( + 1, + Math.floor(draftCfg?.minChars ?? DEFAULT_TELEGRAM_DRAFT_STREAM_MIN), + ); + const minChars = Math.min(minRequested, maxChars); + const breakPreference = + draftCfg?.breakPreference === "newline" || draftCfg?.breakPreference === "sentence" + ? draftCfg.breakPreference + : "paragraph"; + return { minChars, maxChars, breakPreference }; +} diff --git a/src/telegram/draft-stream.test-helpers.ts b/extensions/telegram/src/draft-stream.test-helpers.ts similarity index 100% rename from src/telegram/draft-stream.test-helpers.ts rename to extensions/telegram/src/draft-stream.test-helpers.ts diff --git a/src/telegram/draft-stream.test.ts b/extensions/telegram/src/draft-stream.test.ts similarity index 99% rename from src/telegram/draft-stream.test.ts rename to extensions/telegram/src/draft-stream.test.ts index 7fe7a1713cb..8f10e552406 100644 --- a/src/telegram/draft-stream.test.ts +++ b/extensions/telegram/src/draft-stream.test.ts @@ -1,6 +1,6 @@ import type { Bot } from "grammy"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { importFreshModule } from "../../test/helpers/import-fresh.js"; +import { importFreshModule } from "../../../test/helpers/import-fresh.js"; import { __testing, createTelegramDraftStream } from "./draft-stream.js"; type TelegramDraftStreamParams = Parameters[0]; diff --git a/extensions/telegram/src/draft-stream.ts b/extensions/telegram/src/draft-stream.ts new file mode 100644 index 00000000000..5641b042d30 --- /dev/null +++ b/extensions/telegram/src/draft-stream.ts @@ -0,0 +1,459 @@ +import type { Bot } from "grammy"; +import { createFinalizableDraftLifecycle } from "../../../src/channels/draft-stream-controls.js"; +import { resolveGlobalSingleton } from "../../../src/shared/global-singleton.js"; +import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js"; +import { isSafeToRetrySendError, isTelegramClientRejection } from "./network-errors.js"; + +const TELEGRAM_STREAM_MAX_CHARS = 4096; +const DEFAULT_THROTTLE_MS = 1000; +const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647; +const THREAD_NOT_FOUND_RE = /400:\s*Bad Request:\s*message thread not found/i; +const DRAFT_METHOD_UNAVAILABLE_RE = + /(unknown method|method .*not (found|available|supported)|unsupported)/i; +const DRAFT_CHAT_UNSUPPORTED_RE = /(can't be used|can be used only)/i; + +type TelegramSendMessageDraft = ( + chatId: number, + draftId: number, + text: string, + params?: { + message_thread_id?: number; + parse_mode?: "HTML"; + }, +) => Promise; + +/** + * Keep draft-id allocation shared across bundled chunks so concurrent preview + * lanes do not accidentally reuse draft ids when code-split entries coexist. + */ +const TELEGRAM_DRAFT_STREAM_STATE_KEY = Symbol.for("openclaw.telegramDraftStreamState"); + +const draftStreamState = resolveGlobalSingleton(TELEGRAM_DRAFT_STREAM_STATE_KEY, () => ({ + nextDraftId: 0, +})); + +function allocateTelegramDraftId(): number { + draftStreamState.nextDraftId = + draftStreamState.nextDraftId >= TELEGRAM_DRAFT_ID_MAX ? 1 : draftStreamState.nextDraftId + 1; + return draftStreamState.nextDraftId; +} + +function resolveSendMessageDraftApi(api: Bot["api"]): TelegramSendMessageDraft | undefined { + const sendMessageDraft = (api as Bot["api"] & { sendMessageDraft?: TelegramSendMessageDraft }) + .sendMessageDraft; + if (typeof sendMessageDraft !== "function") { + return undefined; + } + return sendMessageDraft.bind(api as object); +} + +function shouldFallbackFromDraftTransport(err: unknown): boolean { + const text = + typeof err === "string" + ? err + : err instanceof Error + ? err.message + : typeof err === "object" && err && "description" in err + ? typeof err.description === "string" + ? err.description + : "" + : ""; + if (!/sendMessageDraft/i.test(text)) { + return false; + } + return DRAFT_METHOD_UNAVAILABLE_RE.test(text) || DRAFT_CHAT_UNSUPPORTED_RE.test(text); +} + +export type TelegramDraftStream = { + update: (text: string) => void; + flush: () => Promise; + messageId: () => number | undefined; + previewMode?: () => "message" | "draft"; + previewRevision?: () => number; + lastDeliveredText?: () => string; + clear: () => Promise; + stop: () => Promise; + /** Convert the current draft preview into a permanent message (sendMessage). */ + materialize?: () => Promise; + /** Reset internal state so the next update creates a new message instead of editing. */ + forceNewMessage: () => void; + /** True when a preview sendMessage was attempted but the response was lost. */ + sendMayHaveLanded?: () => boolean; +}; + +type TelegramDraftPreview = { + text: string; + parseMode?: "HTML"; +}; + +type SupersededTelegramPreview = { + messageId: number; + textSnapshot: string; + parseMode?: "HTML"; +}; + +export function createTelegramDraftStream(params: { + api: Bot["api"]; + chatId: number; + maxChars?: number; + thread?: TelegramThreadSpec | null; + previewTransport?: "auto" | "message" | "draft"; + replyToMessageId?: number; + throttleMs?: number; + /** Minimum chars before sending first message (debounce for push notifications) */ + minInitialChars?: number; + /** Optional preview renderer (e.g. markdown -> HTML + parse mode). */ + renderText?: (text: string) => TelegramDraftPreview; + /** Called when a late send resolves after forceNewMessage() switched generations. */ + onSupersededPreview?: (preview: SupersededTelegramPreview) => void; + log?: (message: string) => void; + warn?: (message: string) => void; +}): TelegramDraftStream { + const maxChars = Math.min( + params.maxChars ?? TELEGRAM_STREAM_MAX_CHARS, + TELEGRAM_STREAM_MAX_CHARS, + ); + const throttleMs = Math.max(250, params.throttleMs ?? DEFAULT_THROTTLE_MS); + const minInitialChars = params.minInitialChars; + const chatId = params.chatId; + const requestedPreviewTransport = params.previewTransport ?? "auto"; + const prefersDraftTransport = + requestedPreviewTransport === "draft" + ? true + : requestedPreviewTransport === "message" + ? false + : params.thread?.scope === "dm"; + const threadParams = buildTelegramThreadParams(params.thread); + const replyParams = + params.replyToMessageId != null + ? { ...threadParams, reply_to_message_id: params.replyToMessageId } + : threadParams; + const resolvedDraftApi = prefersDraftTransport + ? resolveSendMessageDraftApi(params.api) + : undefined; + const usesDraftTransport = Boolean(prefersDraftTransport && resolvedDraftApi); + if (prefersDraftTransport && !usesDraftTransport) { + params.warn?.( + "telegram stream preview: sendMessageDraft unavailable; falling back to sendMessage/editMessageText", + ); + } + + const streamState = { stopped: false, final: false }; + let messageSendAttempted = false; + let streamMessageId: number | undefined; + let streamDraftId = usesDraftTransport ? allocateTelegramDraftId() : undefined; + let previewTransport: "message" | "draft" = usesDraftTransport ? "draft" : "message"; + let lastSentText = ""; + let lastDeliveredText = ""; + let lastSentParseMode: "HTML" | undefined; + let previewRevision = 0; + let generation = 0; + type PreviewSendParams = { + renderedText: string; + renderedParseMode: "HTML" | undefined; + sendGeneration: number; + }; + const sendRenderedMessageWithThreadFallback = async (sendArgs: { + renderedText: string; + renderedParseMode: "HTML" | undefined; + fallbackWarnMessage: string; + }) => { + const sendParams = sendArgs.renderedParseMode + ? { + ...replyParams, + parse_mode: sendArgs.renderedParseMode, + } + : replyParams; + const usedThreadParams = + "message_thread_id" in (sendParams ?? {}) && + typeof (sendParams as { message_thread_id?: unknown }).message_thread_id === "number"; + try { + return { + sent: await params.api.sendMessage(chatId, sendArgs.renderedText, sendParams), + usedThreadParams, + }; + } catch (err) { + if (!usedThreadParams || !THREAD_NOT_FOUND_RE.test(String(err))) { + throw err; + } + const threadlessParams = { + ...(sendParams as Record), + }; + delete threadlessParams.message_thread_id; + params.warn?.(sendArgs.fallbackWarnMessage); + return { + sent: await params.api.sendMessage( + chatId, + sendArgs.renderedText, + Object.keys(threadlessParams).length > 0 ? threadlessParams : undefined, + ), + usedThreadParams: false, + }; + } + }; + const sendMessageTransportPreview = async ({ + renderedText, + renderedParseMode, + sendGeneration, + }: PreviewSendParams): Promise => { + if (typeof streamMessageId === "number") { + if (renderedParseMode) { + await params.api.editMessageText(chatId, streamMessageId, renderedText, { + parse_mode: renderedParseMode, + }); + } else { + await params.api.editMessageText(chatId, streamMessageId, renderedText); + } + return true; + } + messageSendAttempted = true; + let sent: Awaited>["sent"]; + try { + ({ sent } = await sendRenderedMessageWithThreadFallback({ + renderedText, + renderedParseMode, + fallbackWarnMessage: + "telegram stream preview send failed with message_thread_id, retrying without thread", + })); + } catch (err) { + // Pre-connect failures (DNS, refused) and explicit Telegram rejections (4xx) + // guarantee the message was never delivered — clear the flag so + // sendMayHaveLanded() doesn't suppress fallback. + if (isSafeToRetrySendError(err) || isTelegramClientRejection(err)) { + messageSendAttempted = false; + } + throw err; + } + const sentMessageId = sent?.message_id; + if (typeof sentMessageId !== "number" || !Number.isFinite(sentMessageId)) { + streamState.stopped = true; + params.warn?.("telegram stream preview stopped (missing message id from sendMessage)"); + return false; + } + const normalizedMessageId = Math.trunc(sentMessageId); + if (sendGeneration !== generation) { + params.onSupersededPreview?.({ + messageId: normalizedMessageId, + textSnapshot: renderedText, + parseMode: renderedParseMode, + }); + return true; + } + streamMessageId = normalizedMessageId; + return true; + }; + const sendDraftTransportPreview = async ({ + renderedText, + renderedParseMode, + }: PreviewSendParams): Promise => { + const draftId = streamDraftId ?? allocateTelegramDraftId(); + streamDraftId = draftId; + const draftParams = { + ...(threadParams?.message_thread_id != null + ? { message_thread_id: threadParams.message_thread_id } + : {}), + ...(renderedParseMode ? { parse_mode: renderedParseMode } : {}), + }; + await resolvedDraftApi!( + chatId, + draftId, + renderedText, + Object.keys(draftParams).length > 0 ? draftParams : undefined, + ); + return true; + }; + + const sendOrEditStreamMessage = async (text: string): Promise => { + // Allow final flush even if stopped (e.g., after clear()). + if (streamState.stopped && !streamState.final) { + return false; + } + const trimmed = text.trimEnd(); + if (!trimmed) { + return false; + } + const rendered = params.renderText?.(trimmed) ?? { text: trimmed }; + const renderedText = rendered.text.trimEnd(); + const renderedParseMode = rendered.parseMode; + if (!renderedText) { + return false; + } + if (renderedText.length > maxChars) { + // Telegram text messages/edits cap at 4096 chars. + // Stop streaming once we exceed the cap to avoid repeated API failures. + streamState.stopped = true; + params.warn?.( + `telegram stream preview stopped (text length ${renderedText.length} > ${maxChars})`, + ); + return false; + } + if (renderedText === lastSentText && renderedParseMode === lastSentParseMode) { + return true; + } + const sendGeneration = generation; + + // Debounce first preview send for better push notification quality. + if (typeof streamMessageId !== "number" && minInitialChars != null && !streamState.final) { + if (renderedText.length < minInitialChars) { + return false; + } + } + + lastSentText = renderedText; + lastSentParseMode = renderedParseMode; + try { + let sent = false; + if (previewTransport === "draft") { + try { + sent = await sendDraftTransportPreview({ + renderedText, + renderedParseMode, + sendGeneration, + }); + } catch (err) { + if (!shouldFallbackFromDraftTransport(err)) { + throw err; + } + previewTransport = "message"; + streamDraftId = undefined; + params.warn?.( + "telegram stream preview: sendMessageDraft rejected by API; falling back to sendMessage/editMessageText", + ); + sent = await sendMessageTransportPreview({ + renderedText, + renderedParseMode, + sendGeneration, + }); + } + } else { + sent = await sendMessageTransportPreview({ + renderedText, + renderedParseMode, + sendGeneration, + }); + } + if (sent) { + previewRevision += 1; + lastDeliveredText = trimmed; + } + return sent; + } catch (err) { + streamState.stopped = true; + params.warn?.( + `telegram stream preview failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return false; + } + }; + + const { loop, update, stop, clear } = createFinalizableDraftLifecycle({ + throttleMs, + state: streamState, + sendOrEditStreamMessage, + readMessageId: () => streamMessageId, + clearMessageId: () => { + streamMessageId = undefined; + }, + isValidMessageId: (value): value is number => + typeof value === "number" && Number.isFinite(value), + deleteMessage: async (messageId) => { + await params.api.deleteMessage(chatId, messageId); + }, + onDeleteSuccess: (messageId) => { + params.log?.(`telegram stream preview deleted (chat=${chatId}, message=${messageId})`); + }, + warn: params.warn, + warnPrefix: "telegram stream preview cleanup failed", + }); + + const forceNewMessage = () => { + // Boundary rotation may call stop() to finalize the previous draft. + // Re-open the stream lifecycle for the next assistant segment. + streamState.final = false; + generation += 1; + messageSendAttempted = false; + streamMessageId = undefined; + if (previewTransport === "draft") { + streamDraftId = allocateTelegramDraftId(); + } + lastSentText = ""; + lastSentParseMode = undefined; + loop.resetPending(); + loop.resetThrottleWindow(); + }; + + /** + * Materialize the current draft into a permanent message. + * For draft transport: sends the accumulated text as a real sendMessage. + * For message transport: the message is already permanent (noop). + * Returns the permanent message id, or undefined if nothing to materialize. + */ + const materialize = async (): Promise => { + await stop(); + // If using message transport, the streamMessageId is already a real message. + if (previewTransport === "message" && typeof streamMessageId === "number") { + return streamMessageId; + } + // For draft transport, use the rendered snapshot first so parse_mode stays + // aligned with the text being materialized. + const renderedText = lastSentText || lastDeliveredText; + if (!renderedText) { + return undefined; + } + const renderedParseMode = lastSentText ? lastSentParseMode : undefined; + try { + const { sent, usedThreadParams } = await sendRenderedMessageWithThreadFallback({ + renderedText, + renderedParseMode, + fallbackWarnMessage: + "telegram stream preview materialize send failed with message_thread_id, retrying without thread", + }); + const sentId = sent?.message_id; + if (typeof sentId === "number" && Number.isFinite(sentId)) { + streamMessageId = Math.trunc(sentId); + // Clear the draft so Telegram's input area doesn't briefly show a + // stale copy alongside the newly materialized real message. + if (resolvedDraftApi != null && streamDraftId != null) { + const clearDraftId = streamDraftId; + const clearThreadParams = + usedThreadParams && threadParams?.message_thread_id != null + ? { message_thread_id: threadParams.message_thread_id } + : undefined; + try { + await resolvedDraftApi(chatId, clearDraftId, "", clearThreadParams); + } catch { + // Best-effort cleanup; draft clear failure is cosmetic. + } + } + return streamMessageId; + } + } catch (err) { + params.warn?.( + `telegram stream preview materialize failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + return undefined; + }; + + params.log?.(`telegram stream preview ready (maxChars=${maxChars}, throttleMs=${throttleMs})`); + + return { + update, + flush: loop.flush, + messageId: () => streamMessageId, + previewMode: () => previewTransport, + previewRevision: () => previewRevision, + lastDeliveredText: () => lastDeliveredText, + clear, + stop, + materialize, + forceNewMessage, + sendMayHaveLanded: () => messageSendAttempted && typeof streamMessageId !== "number", + }; +} + +export const __testing = { + resetTelegramDraftStreamForTests() { + draftStreamState.nextDraftId = 0; + }, +}; diff --git a/extensions/telegram/src/exec-approvals-handler.test.ts b/extensions/telegram/src/exec-approvals-handler.test.ts new file mode 100644 index 00000000000..80ecca833d2 --- /dev/null +++ b/extensions/telegram/src/exec-approvals-handler.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js"; + +const baseRequest = { + id: "9f1c7d5d-b1fb-46ef-ac45-662723b65bb7", + request: { + command: "npm view diver name version description", + agentId: "main", + sessionKey: "agent:main:telegram:group:-1003841603622:topic:928", + turnSourceChannel: "telegram", + turnSourceTo: "-1003841603622", + turnSourceThreadId: "928", + turnSourceAccountId: "default", + }, + createdAtMs: 1000, + expiresAtMs: 61_000, +}; + +function createHandler(cfg: OpenClawConfig) { + const sendTyping = vi.fn().mockResolvedValue({ ok: true }); + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ messageId: "m1", chatId: "-1003841603622" }) + .mockResolvedValue({ messageId: "m2", chatId: "8460800771" }); + const editReplyMarkup = vi.fn().mockResolvedValue({ ok: true }); + const handler = new TelegramExecApprovalHandler( + { + token: "tg-token", + accountId: "default", + cfg, + }, + { + nowMs: () => 1000, + sendTyping, + sendMessage, + editReplyMarkup, + }, + ); + return { handler, sendTyping, sendMessage, editReplyMarkup }; +} + +describe("TelegramExecApprovalHandler", () => { + it("sends approval prompts to the originating telegram topic when target=channel", async () => { + const cfg = { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["8460800771"], + target: "channel", + }, + }, + }, + } as OpenClawConfig; + const { handler, sendTyping, sendMessage } = createHandler(cfg); + + await handler.handleRequested(baseRequest); + + expect(sendTyping).toHaveBeenCalledWith( + "-1003841603622", + expect.objectContaining({ + accountId: "default", + messageThreadId: 928, + }), + ); + expect(sendMessage).toHaveBeenCalledWith( + "-1003841603622", + expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"), + expect.objectContaining({ + accountId: "default", + messageThreadId: 928, + buttons: [ + [ + { + text: "Allow Once", + callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once", + }, + { + text: "Allow Always", + callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-always", + }, + ], + [ + { + text: "Deny", + callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 deny", + }, + ], + ], + }), + ); + }); + + it("falls back to approver DMs when channel routing is unavailable", async () => { + const cfg = { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["111", "222"], + target: "channel", + }, + }, + }, + } as OpenClawConfig; + const { handler, sendMessage } = createHandler(cfg); + + await handler.handleRequested({ + ...baseRequest, + request: { + ...baseRequest.request, + turnSourceChannel: "slack", + turnSourceTo: "U1", + turnSourceAccountId: null, + turnSourceThreadId: null, + }, + }); + + expect(sendMessage).toHaveBeenCalledTimes(2); + expect(sendMessage.mock.calls.map((call) => call[0])).toEqual(["111", "222"]); + }); + + it("clears buttons from tracked approval messages when resolved", async () => { + const cfg = { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["8460800771"], + target: "both", + }, + }, + }, + } as OpenClawConfig; + const { handler, editReplyMarkup } = createHandler(cfg); + + await handler.handleRequested(baseRequest); + await handler.handleResolved({ + id: baseRequest.id, + decision: "allow-once", + resolvedBy: "telegram:8460800771", + ts: 2000, + }); + + expect(editReplyMarkup).toHaveBeenCalled(); + expect(editReplyMarkup).toHaveBeenCalledWith( + "-1003841603622", + "m1", + [], + expect.objectContaining({ + accountId: "default", + }), + ); + }); +}); diff --git a/extensions/telegram/src/exec-approvals-handler.ts b/extensions/telegram/src/exec-approvals-handler.ts new file mode 100644 index 00000000000..a9d32d0887d --- /dev/null +++ b/extensions/telegram/src/exec-approvals-handler.ts @@ -0,0 +1,372 @@ +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { GatewayClient } from "../../../src/gateway/client.js"; +import { createOperatorApprovalsGatewayClient } from "../../../src/gateway/operator-approvals-client.js"; +import type { EventFrame } from "../../../src/gateway/protocol/index.js"; +import { resolveExecApprovalCommandDisplay } from "../../../src/infra/exec-approval-command-display.js"; +import { + buildExecApprovalPendingReplyPayload, + type ExecApprovalPendingReplyParams, +} from "../../../src/infra/exec-approval-reply.js"; +import { resolveExecApprovalSessionTarget } from "../../../src/infra/exec-approval-session-target.js"; +import type { + ExecApprovalRequest, + ExecApprovalResolved, +} from "../../../src/infra/exec-approvals.js"; +import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; +import { normalizeAccountId, parseAgentSessionKey } from "../../../src/routing/session-key.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; +import { compileSafeRegex, testRegexWithBoundedInput } from "../../../src/security/safe-regex.js"; +import { buildTelegramExecApprovalButtons } from "./approval-buttons.js"; +import { + getTelegramExecApprovalApprovers, + resolveTelegramExecApprovalConfig, + resolveTelegramExecApprovalTarget, +} from "./exec-approvals.js"; +import { editMessageReplyMarkupTelegram, sendMessageTelegram, sendTypingTelegram } from "./send.js"; + +const log = createSubsystemLogger("telegram/exec-approvals"); + +type PendingMessage = { + chatId: string; + messageId: string; +}; + +type PendingApproval = { + timeoutId: NodeJS.Timeout; + messages: PendingMessage[]; +}; + +type TelegramApprovalTarget = { + to: string; + threadId?: number; +}; + +export type TelegramExecApprovalHandlerOpts = { + token: string; + accountId: string; + cfg: OpenClawConfig; + gatewayUrl?: string; + runtime?: RuntimeEnv; +}; + +export type TelegramExecApprovalHandlerDeps = { + nowMs?: () => number; + sendTyping?: typeof sendTypingTelegram; + sendMessage?: typeof sendMessageTelegram; + editReplyMarkup?: typeof editMessageReplyMarkupTelegram; +}; + +function matchesFilters(params: { + cfg: OpenClawConfig; + accountId: string; + request: ExecApprovalRequest; +}): boolean { + const config = resolveTelegramExecApprovalConfig({ + cfg: params.cfg, + accountId: params.accountId, + }); + if (!config?.enabled) { + return false; + } + const approvers = getTelegramExecApprovalApprovers({ + cfg: params.cfg, + accountId: params.accountId, + }); + if (approvers.length === 0) { + return false; + } + if (config.agentFilter?.length) { + const agentId = + params.request.request.agentId ?? + parseAgentSessionKey(params.request.request.sessionKey)?.agentId; + if (!agentId || !config.agentFilter.includes(agentId)) { + return false; + } + } + if (config.sessionFilter?.length) { + const sessionKey = params.request.request.sessionKey; + if (!sessionKey) { + return false; + } + const matches = config.sessionFilter.some((pattern) => { + if (sessionKey.includes(pattern)) { + return true; + } + const regex = compileSafeRegex(pattern); + return regex ? testRegexWithBoundedInput(regex, sessionKey) : false; + }); + if (!matches) { + return false; + } + } + return true; +} + +function isHandlerConfigured(params: { cfg: OpenClawConfig; accountId: string }): boolean { + const config = resolveTelegramExecApprovalConfig({ + cfg: params.cfg, + accountId: params.accountId, + }); + if (!config?.enabled) { + return false; + } + return ( + getTelegramExecApprovalApprovers({ + cfg: params.cfg, + accountId: params.accountId, + }).length > 0 + ); +} + +function resolveRequestSessionTarget(params: { + cfg: OpenClawConfig; + request: ExecApprovalRequest; +}): { to: string; accountId?: string; threadId?: number; channel?: string } | null { + return resolveExecApprovalSessionTarget({ + cfg: params.cfg, + request: params.request, + turnSourceChannel: params.request.request.turnSourceChannel ?? undefined, + turnSourceTo: params.request.request.turnSourceTo ?? undefined, + turnSourceAccountId: params.request.request.turnSourceAccountId ?? undefined, + turnSourceThreadId: params.request.request.turnSourceThreadId ?? undefined, + }); +} + +function resolveTelegramSourceTarget(params: { + cfg: OpenClawConfig; + accountId: string; + request: ExecApprovalRequest; +}): TelegramApprovalTarget | null { + const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || ""; + const turnSourceTo = params.request.request.turnSourceTo?.trim() || ""; + const turnSourceAccountId = params.request.request.turnSourceAccountId?.trim() || ""; + if (turnSourceChannel === "telegram" && turnSourceTo) { + if ( + turnSourceAccountId && + normalizeAccountId(turnSourceAccountId) !== normalizeAccountId(params.accountId) + ) { + return null; + } + const threadId = + typeof params.request.request.turnSourceThreadId === "number" + ? params.request.request.turnSourceThreadId + : typeof params.request.request.turnSourceThreadId === "string" + ? Number.parseInt(params.request.request.turnSourceThreadId, 10) + : undefined; + return { to: turnSourceTo, threadId: Number.isFinite(threadId) ? threadId : undefined }; + } + + const sessionTarget = resolveRequestSessionTarget(params); + if (!sessionTarget || sessionTarget.channel !== "telegram") { + return null; + } + if ( + sessionTarget.accountId && + normalizeAccountId(sessionTarget.accountId) !== normalizeAccountId(params.accountId) + ) { + return null; + } + return { + to: sessionTarget.to, + threadId: sessionTarget.threadId, + }; +} + +function dedupeTargets(targets: TelegramApprovalTarget[]): TelegramApprovalTarget[] { + const seen = new Set(); + const deduped: TelegramApprovalTarget[] = []; + for (const target of targets) { + const key = `${target.to}:${target.threadId ?? ""}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(target); + } + return deduped; +} + +export class TelegramExecApprovalHandler { + private gatewayClient: GatewayClient | null = null; + private pending = new Map(); + private started = false; + private readonly nowMs: () => number; + private readonly sendTyping: typeof sendTypingTelegram; + private readonly sendMessage: typeof sendMessageTelegram; + private readonly editReplyMarkup: typeof editMessageReplyMarkupTelegram; + + constructor( + private readonly opts: TelegramExecApprovalHandlerOpts, + deps: TelegramExecApprovalHandlerDeps = {}, + ) { + this.nowMs = deps.nowMs ?? Date.now; + this.sendTyping = deps.sendTyping ?? sendTypingTelegram; + this.sendMessage = deps.sendMessage ?? sendMessageTelegram; + this.editReplyMarkup = deps.editReplyMarkup ?? editMessageReplyMarkupTelegram; + } + + shouldHandle(request: ExecApprovalRequest): boolean { + return matchesFilters({ + cfg: this.opts.cfg, + accountId: this.opts.accountId, + request, + }); + } + + async start(): Promise { + if (this.started) { + return; + } + this.started = true; + + if (!isHandlerConfigured({ cfg: this.opts.cfg, accountId: this.opts.accountId })) { + return; + } + + this.gatewayClient = await createOperatorApprovalsGatewayClient({ + config: this.opts.cfg, + gatewayUrl: this.opts.gatewayUrl, + clientDisplayName: `Telegram Exec Approvals (${this.opts.accountId})`, + onEvent: (evt) => this.handleGatewayEvent(evt), + onConnectError: (err) => { + log.error(`telegram exec approvals: connect error: ${err.message}`); + }, + }); + this.gatewayClient.start(); + } + + async stop(): Promise { + if (!this.started) { + return; + } + this.started = false; + for (const pending of this.pending.values()) { + clearTimeout(pending.timeoutId); + } + this.pending.clear(); + this.gatewayClient?.stop(); + this.gatewayClient = null; + } + + async handleRequested(request: ExecApprovalRequest): Promise { + if (!this.shouldHandle(request)) { + return; + } + + const targetMode = resolveTelegramExecApprovalTarget({ + cfg: this.opts.cfg, + accountId: this.opts.accountId, + }); + const targets: TelegramApprovalTarget[] = []; + const sourceTarget = resolveTelegramSourceTarget({ + cfg: this.opts.cfg, + accountId: this.opts.accountId, + request, + }); + let fallbackToDm = false; + if (targetMode === "channel" || targetMode === "both") { + if (sourceTarget) { + targets.push(sourceTarget); + } else { + fallbackToDm = true; + } + } + if (targetMode === "dm" || targetMode === "both" || fallbackToDm) { + for (const approver of getTelegramExecApprovalApprovers({ + cfg: this.opts.cfg, + accountId: this.opts.accountId, + })) { + targets.push({ to: approver }); + } + } + + const resolvedTargets = dedupeTargets(targets); + if (resolvedTargets.length === 0) { + return; + } + + const payloadParams: ExecApprovalPendingReplyParams = { + approvalId: request.id, + approvalSlug: request.id.slice(0, 8), + approvalCommandId: request.id, + command: resolveExecApprovalCommandDisplay(request.request).commandText, + cwd: request.request.cwd ?? undefined, + host: request.request.host === "node" ? "node" : "gateway", + nodeId: request.request.nodeId ?? undefined, + expiresAtMs: request.expiresAtMs, + nowMs: this.nowMs(), + }; + const payload = buildExecApprovalPendingReplyPayload(payloadParams); + const buttons = buildTelegramExecApprovalButtons(request.id); + const sentMessages: PendingMessage[] = []; + + for (const target of resolvedTargets) { + try { + await this.sendTyping(target.to, { + cfg: this.opts.cfg, + token: this.opts.token, + accountId: this.opts.accountId, + ...(typeof target.threadId === "number" ? { messageThreadId: target.threadId } : {}), + }).catch(() => {}); + + const result = await this.sendMessage(target.to, payload.text ?? "", { + cfg: this.opts.cfg, + token: this.opts.token, + accountId: this.opts.accountId, + buttons, + ...(typeof target.threadId === "number" ? { messageThreadId: target.threadId } : {}), + }); + sentMessages.push({ + chatId: result.chatId, + messageId: result.messageId, + }); + } catch (err) { + log.error(`telegram exec approvals: failed to send request ${request.id}: ${String(err)}`); + } + } + + if (sentMessages.length === 0) { + return; + } + + const timeoutMs = Math.max(0, request.expiresAtMs - this.nowMs()); + const timeoutId = setTimeout(() => { + void this.handleResolved({ id: request.id, decision: "deny", ts: Date.now() }); + }, timeoutMs); + timeoutId.unref?.(); + + this.pending.set(request.id, { + timeoutId, + messages: sentMessages, + }); + } + + async handleResolved(resolved: ExecApprovalResolved): Promise { + const pending = this.pending.get(resolved.id); + if (!pending) { + return; + } + clearTimeout(pending.timeoutId); + this.pending.delete(resolved.id); + + await Promise.allSettled( + pending.messages.map(async (message) => { + await this.editReplyMarkup(message.chatId, message.messageId, [], { + cfg: this.opts.cfg, + token: this.opts.token, + accountId: this.opts.accountId, + }); + }), + ); + } + + private handleGatewayEvent(evt: EventFrame): void { + if (evt.event === "exec.approval.requested") { + void this.handleRequested(evt.payload as ExecApprovalRequest); + return; + } + if (evt.event === "exec.approval.resolved") { + void this.handleResolved(evt.payload as ExecApprovalResolved); + } + } +} diff --git a/extensions/telegram/src/exec-approvals.test.ts b/extensions/telegram/src/exec-approvals.test.ts new file mode 100644 index 00000000000..f56279318ea --- /dev/null +++ b/extensions/telegram/src/exec-approvals.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { + isTelegramExecApprovalApprover, + isTelegramExecApprovalClientEnabled, + resolveTelegramExecApprovalTarget, + shouldEnableTelegramExecApprovalButtons, + shouldInjectTelegramExecApprovalButtons, +} from "./exec-approvals.js"; + +function buildConfig( + execApprovals?: NonNullable["telegram"]>["execApprovals"], +): OpenClawConfig { + return { + channels: { + telegram: { + botToken: "tok", + execApprovals, + }, + }, + } as OpenClawConfig; +} + +describe("telegram exec approvals", () => { + it("requires enablement and at least one approver", () => { + expect(isTelegramExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false); + expect( + isTelegramExecApprovalClientEnabled({ + cfg: buildConfig({ enabled: true }), + }), + ).toBe(false); + expect( + isTelegramExecApprovalClientEnabled({ + cfg: buildConfig({ enabled: true, approvers: ["123"] }), + }), + ).toBe(true); + }); + + it("matches approvers by normalized sender id", () => { + const cfg = buildConfig({ enabled: true, approvers: [123, "456"] }); + expect(isTelegramExecApprovalApprover({ cfg, senderId: "123" })).toBe(true); + expect(isTelegramExecApprovalApprover({ cfg, senderId: "456" })).toBe(true); + expect(isTelegramExecApprovalApprover({ cfg, senderId: "789" })).toBe(false); + }); + + it("defaults target to dm", () => { + expect( + resolveTelegramExecApprovalTarget({ cfg: buildConfig({ enabled: true, approvers: ["1"] }) }), + ).toBe("dm"); + }); + + it("only injects approval buttons on eligible telegram targets", () => { + const dmCfg = buildConfig({ enabled: true, approvers: ["123"], target: "dm" }); + const channelCfg = buildConfig({ enabled: true, approvers: ["123"], target: "channel" }); + const bothCfg = buildConfig({ enabled: true, approvers: ["123"], target: "both" }); + + expect(shouldInjectTelegramExecApprovalButtons({ cfg: dmCfg, to: "123" })).toBe(true); + expect(shouldInjectTelegramExecApprovalButtons({ cfg: dmCfg, to: "-100123" })).toBe(false); + expect(shouldInjectTelegramExecApprovalButtons({ cfg: channelCfg, to: "-100123" })).toBe(true); + expect(shouldInjectTelegramExecApprovalButtons({ cfg: channelCfg, to: "123" })).toBe(false); + expect(shouldInjectTelegramExecApprovalButtons({ cfg: bothCfg, to: "123" })).toBe(true); + expect(shouldInjectTelegramExecApprovalButtons({ cfg: bothCfg, to: "-100123" })).toBe(true); + }); + + it("does not require generic inlineButtons capability to enable exec approval buttons", () => { + const cfg = { + channels: { + telegram: { + botToken: "tok", + capabilities: ["vision"], + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + }, + }, + } as OpenClawConfig; + + expect(shouldEnableTelegramExecApprovalButtons({ cfg, to: "123" })).toBe(true); + }); + + it("still respects explicit inlineButtons off for exec approval buttons", () => { + const cfg = { + channels: { + telegram: { + botToken: "tok", + capabilities: { inlineButtons: "off" }, + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + }, + }, + } as OpenClawConfig; + + expect(shouldEnableTelegramExecApprovalButtons({ cfg, to: "123" })).toBe(false); + }); +}); diff --git a/extensions/telegram/src/exec-approvals.ts b/extensions/telegram/src/exec-approvals.ts new file mode 100644 index 00000000000..b1b0eed8d4f --- /dev/null +++ b/extensions/telegram/src/exec-approvals.ts @@ -0,0 +1,106 @@ +import type { ReplyPayload } from "../../../src/auto-reply/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramExecApprovalConfig } from "../../../src/config/types.telegram.js"; +import { getExecApprovalReplyMetadata } from "../../../src/infra/exec-approval-reply.js"; +import { resolveTelegramAccount } from "./accounts.js"; +import { resolveTelegramTargetChatType } from "./targets.js"; + +function normalizeApproverId(value: string | number): string { + return String(value).trim(); +} + +export function resolveTelegramExecApprovalConfig(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): TelegramExecApprovalConfig | undefined { + return resolveTelegramAccount(params).config.execApprovals; +} + +export function getTelegramExecApprovalApprovers(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string[] { + return (resolveTelegramExecApprovalConfig(params)?.approvers ?? []) + .map(normalizeApproverId) + .filter(Boolean); +} + +export function isTelegramExecApprovalClientEnabled(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): boolean { + const config = resolveTelegramExecApprovalConfig(params); + return Boolean(config?.enabled && getTelegramExecApprovalApprovers(params).length > 0); +} + +export function isTelegramExecApprovalApprover(params: { + cfg: OpenClawConfig; + accountId?: string | null; + senderId?: string | null; +}): boolean { + const senderId = params.senderId?.trim(); + if (!senderId) { + return false; + } + const approvers = getTelegramExecApprovalApprovers(params); + return approvers.includes(senderId); +} + +export function resolveTelegramExecApprovalTarget(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): "dm" | "channel" | "both" { + return resolveTelegramExecApprovalConfig(params)?.target ?? "dm"; +} + +export function shouldInjectTelegramExecApprovalButtons(params: { + cfg: OpenClawConfig; + accountId?: string | null; + to: string; +}): boolean { + if (!isTelegramExecApprovalClientEnabled(params)) { + return false; + } + const target = resolveTelegramExecApprovalTarget(params); + const chatType = resolveTelegramTargetChatType(params.to); + if (chatType === "direct") { + return target === "dm" || target === "both"; + } + if (chatType === "group") { + return target === "channel" || target === "both"; + } + return target === "both"; +} + +function resolveExecApprovalButtonsExplicitlyDisabled(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): boolean { + const capabilities = resolveTelegramAccount(params).config.capabilities; + if (!capabilities || Array.isArray(capabilities) || typeof capabilities !== "object") { + return false; + } + const inlineButtons = (capabilities as { inlineButtons?: unknown }).inlineButtons; + return typeof inlineButtons === "string" && inlineButtons.trim().toLowerCase() === "off"; +} + +export function shouldEnableTelegramExecApprovalButtons(params: { + cfg: OpenClawConfig; + accountId?: string | null; + to: string; +}): boolean { + if (!shouldInjectTelegramExecApprovalButtons(params)) { + return false; + } + return !resolveExecApprovalButtonsExplicitlyDisabled(params); +} + +export function shouldSuppressLocalTelegramExecApprovalPrompt(params: { + cfg: OpenClawConfig; + accountId?: string | null; + payload: ReplyPayload; +}): boolean { + void params.cfg; + void params.accountId; + return getExecApprovalReplyMetadata(params.payload) !== null; +} diff --git a/extensions/telegram/src/fetch.env-proxy-runtime.test.ts b/extensions/telegram/src/fetch.env-proxy-runtime.test.ts new file mode 100644 index 00000000000..0292f465747 --- /dev/null +++ b/extensions/telegram/src/fetch.env-proxy-runtime.test.ts @@ -0,0 +1,58 @@ +import { createRequire } from "node:module"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const require = createRequire(import.meta.url); +const EnvHttpProxyAgent = require("undici/lib/dispatcher/env-http-proxy-agent.js") as { + new (opts?: Record): Record; +}; +const { kHttpsProxyAgent, kNoProxyAgent } = require("undici/lib/core/symbols.js") as { + kHttpsProxyAgent: symbol; + kNoProxyAgent: symbol; +}; + +function getOwnSymbolValue( + target: Record, + description: string, +): Record | undefined { + const symbol = Object.getOwnPropertySymbols(target).find( + (entry) => entry.description === description, + ); + const value = symbol ? target[symbol] : undefined; + return value && typeof value === "object" ? (value as Record) : undefined; +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("undici env proxy semantics", () => { + it("uses proxyTls rather than connect for proxied HTTPS transport settings", () => { + vi.stubEnv("HTTPS_PROXY", "http://127.0.0.1:7890"); + const connect = { + family: 4, + autoSelectFamily: false, + }; + + const withoutProxyTls = new EnvHttpProxyAgent({ connect }); + const noProxyAgent = withoutProxyTls[kNoProxyAgent] as Record; + const httpsProxyAgent = withoutProxyTls[kHttpsProxyAgent] as Record; + + expect(getOwnSymbolValue(noProxyAgent, "options")?.connect).toEqual( + expect.objectContaining(connect), + ); + expect(getOwnSymbolValue(httpsProxyAgent, "proxy tls settings")).toBeUndefined(); + + const withProxyTls = new EnvHttpProxyAgent({ + connect, + proxyTls: connect, + }); + const httpsProxyAgentWithProxyTls = withProxyTls[kHttpsProxyAgent] as Record< + PropertyKey, + unknown + >; + + expect(getOwnSymbolValue(httpsProxyAgentWithProxyTls, "proxy tls settings")).toEqual( + expect.objectContaining(connect), + ); + }); +}); diff --git a/src/telegram/fetch.test.ts b/extensions/telegram/src/fetch.test.ts similarity index 99% rename from src/telegram/fetch.test.ts rename to extensions/telegram/src/fetch.test.ts index 730bc377309..7681d0c8701 100644 --- a/src/telegram/fetch.test.ts +++ b/extensions/telegram/src/fetch.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveFetch } from "../infra/fetch.js"; +import { resolveFetch } from "../../../src/infra/fetch.js"; import { resolveTelegramFetch, resolveTelegramTransport } from "./fetch.js"; const setDefaultResultOrder = vi.hoisted(() => vi.fn()); diff --git a/extensions/telegram/src/fetch.ts b/extensions/telegram/src/fetch.ts new file mode 100644 index 00000000000..4b234c8d107 --- /dev/null +++ b/extensions/telegram/src/fetch.ts @@ -0,0 +1,514 @@ +import * as dns from "node:dns"; +import { Agent, EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from "undici"; +import type { TelegramNetworkConfig } from "../../../src/config/types.telegram.js"; +import { resolveFetch } from "../../../src/infra/fetch.js"; +import { hasEnvHttpProxyConfigured } from "../../../src/infra/net/proxy-env.js"; +import type { PinnedDispatcherPolicy } from "../../../src/infra/net/ssrf.js"; +import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; +import { + resolveTelegramAutoSelectFamilyDecision, + resolveTelegramDnsResultOrderDecision, +} from "./network-config.js"; +import { getProxyUrlFromFetch } from "./proxy.js"; + +const log = createSubsystemLogger("telegram/network"); + +const TELEGRAM_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 300; +const TELEGRAM_API_HOSTNAME = "api.telegram.org"; + +type RequestInitWithDispatcher = RequestInit & { + dispatcher?: unknown; +}; + +type TelegramDispatcher = Agent | EnvHttpProxyAgent | ProxyAgent; + +type TelegramDispatcherMode = "direct" | "env-proxy" | "explicit-proxy"; + +type TelegramDnsResultOrder = "ipv4first" | "verbatim"; + +type LookupCallback = + | ((err: NodeJS.ErrnoException | null, address: string, family: number) => void) + | ((err: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => void); + +type LookupOptions = (dns.LookupOneOptions | dns.LookupAllOptions) & { + order?: TelegramDnsResultOrder; + verbatim?: boolean; +}; + +type LookupFunction = ( + hostname: string, + options: number | dns.LookupOneOptions | dns.LookupAllOptions | undefined, + callback: LookupCallback, +) => void; + +const FALLBACK_RETRY_ERROR_CODES = [ + "ETIMEDOUT", + "ENETUNREACH", + "EHOSTUNREACH", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_SOCKET", +] as const; + +type Ipv4FallbackContext = { + message: string; + codes: Set; +}; + +type Ipv4FallbackRule = { + name: string; + matches: (ctx: Ipv4FallbackContext) => boolean; +}; + +const IPV4_FALLBACK_RULES: readonly Ipv4FallbackRule[] = [ + { + name: "fetch-failed-envelope", + matches: ({ message }) => message.includes("fetch failed"), + }, + { + name: "known-network-code", + matches: ({ codes }) => FALLBACK_RETRY_ERROR_CODES.some((code) => codes.has(code)), + }, +]; + +function normalizeDnsResultOrder(value: string | null): TelegramDnsResultOrder | null { + if (value === "ipv4first" || value === "verbatim") { + return value; + } + return null; +} + +function createDnsResultOrderLookup( + order: TelegramDnsResultOrder | null, +): LookupFunction | undefined { + if (!order) { + return undefined; + } + const lookup = dns.lookup as unknown as ( + hostname: string, + options: LookupOptions, + callback: LookupCallback, + ) => void; + return (hostname, options, callback) => { + const baseOptions: LookupOptions = + typeof options === "number" + ? { family: options } + : options + ? { ...(options as LookupOptions) } + : {}; + const lookupOptions: LookupOptions = { + ...baseOptions, + order, + // Keep `verbatim` for compatibility with Node runtimes that ignore `order`. + verbatim: order === "verbatim", + }; + lookup(hostname, lookupOptions, callback); + }; +} + +function buildTelegramConnectOptions(params: { + autoSelectFamily: boolean | null; + dnsResultOrder: TelegramDnsResultOrder | null; + forceIpv4: boolean; +}): { + autoSelectFamily?: boolean; + autoSelectFamilyAttemptTimeout?: number; + family?: number; + lookup?: LookupFunction; +} | null { + const connect: { + autoSelectFamily?: boolean; + autoSelectFamilyAttemptTimeout?: number; + family?: number; + lookup?: LookupFunction; + } = {}; + + if (params.forceIpv4) { + connect.family = 4; + connect.autoSelectFamily = false; + } else if (typeof params.autoSelectFamily === "boolean") { + connect.autoSelectFamily = params.autoSelectFamily; + connect.autoSelectFamilyAttemptTimeout = TELEGRAM_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS; + } + + const lookup = createDnsResultOrderLookup(params.dnsResultOrder); + if (lookup) { + connect.lookup = lookup; + } + + return Object.keys(connect).length > 0 ? connect : null; +} + +function shouldBypassEnvProxyForTelegramApi(env: NodeJS.ProcessEnv = process.env): boolean { + // We need this classification before dispatch to decide whether sticky IPv4 fallback + // can safely arm. EnvHttpProxyAgent does not expose route decisions (proxy vs direct + // NO_PROXY bypass), so we mirror undici's parsing/matching behavior for this host. + // Match EnvHttpProxyAgent behavior (undici): + // - lower-case no_proxy takes precedence over NO_PROXY + // - entries split by comma or whitespace + // - wildcard handling is exact-string "*" only + // - leading "." and "*." are normalized the same way + const noProxyValue = env.no_proxy ?? env.NO_PROXY ?? ""; + if (!noProxyValue) { + return false; + } + if (noProxyValue === "*") { + return true; + } + const targetHostname = TELEGRAM_API_HOSTNAME.toLowerCase(); + const targetPort = 443; + const noProxyEntries = noProxyValue.split(/[,\s]/); + for (let i = 0; i < noProxyEntries.length; i++) { + const entry = noProxyEntries[i]; + if (!entry) { + continue; + } + const parsed = entry.match(/^(.+):(\d+)$/); + const entryHostname = (parsed ? parsed[1] : entry).replace(/^\*?\./, "").toLowerCase(); + const entryPort = parsed ? Number.parseInt(parsed[2], 10) : 0; + if (entryPort && entryPort !== targetPort) { + continue; + } + if ( + targetHostname === entryHostname || + targetHostname.slice(-(entryHostname.length + 1)) === `.${entryHostname}` + ) { + return true; + } + } + return false; +} + +function hasEnvHttpProxyForTelegramApi(env: NodeJS.ProcessEnv = process.env): boolean { + return hasEnvHttpProxyConfigured("https", env); +} + +function resolveTelegramDispatcherPolicy(params: { + autoSelectFamily: boolean | null; + dnsResultOrder: TelegramDnsResultOrder | null; + useEnvProxy: boolean; + forceIpv4: boolean; + proxyUrl?: string; +}): { policy: PinnedDispatcherPolicy; mode: TelegramDispatcherMode } { + const connect = buildTelegramConnectOptions({ + autoSelectFamily: params.autoSelectFamily, + dnsResultOrder: params.dnsResultOrder, + forceIpv4: params.forceIpv4, + }); + const explicitProxyUrl = params.proxyUrl?.trim(); + if (explicitProxyUrl) { + return { + policy: connect + ? { + mode: "explicit-proxy", + proxyUrl: explicitProxyUrl, + proxyTls: { ...connect }, + } + : { + mode: "explicit-proxy", + proxyUrl: explicitProxyUrl, + }, + mode: "explicit-proxy", + }; + } + if (params.useEnvProxy) { + return { + policy: { + mode: "env-proxy", + ...(connect ? { connect: { ...connect }, proxyTls: { ...connect } } : {}), + }, + mode: "env-proxy", + }; + } + return { + policy: { + mode: "direct", + ...(connect ? { connect: { ...connect } } : {}), + }, + mode: "direct", + }; +} + +function createTelegramDispatcher(policy: PinnedDispatcherPolicy): { + dispatcher: TelegramDispatcher; + mode: TelegramDispatcherMode; + effectivePolicy: PinnedDispatcherPolicy; +} { + if (policy.mode === "explicit-proxy") { + const proxyOptions = policy.proxyTls + ? ({ + uri: policy.proxyUrl, + proxyTls: { ...policy.proxyTls }, + } satisfies ConstructorParameters[0]) + : policy.proxyUrl; + try { + return { + dispatcher: new ProxyAgent(proxyOptions), + mode: "explicit-proxy", + effectivePolicy: policy, + }; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error(`explicit proxy dispatcher init failed: ${reason}`, { cause: err }); + } + } + + if (policy.mode === "env-proxy") { + const proxyOptions = + policy.connect || policy.proxyTls + ? ({ + ...(policy.connect ? { connect: { ...policy.connect } } : {}), + // undici's EnvHttpProxyAgent passes `connect` only to the no-proxy Agent. + // Real proxied HTTPS traffic reads transport settings from ProxyAgent.proxyTls. + ...(policy.proxyTls ? { proxyTls: { ...policy.proxyTls } } : {}), + } satisfies ConstructorParameters[0]) + : undefined; + try { + return { + dispatcher: new EnvHttpProxyAgent(proxyOptions), + mode: "env-proxy", + effectivePolicy: policy, + }; + } catch (err) { + log.warn( + `env proxy dispatcher init failed; falling back to direct dispatcher: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + const directPolicy: PinnedDispatcherPolicy = { + mode: "direct", + ...(policy.connect ? { connect: { ...policy.connect } } : {}), + }; + return { + dispatcher: new Agent( + directPolicy.connect + ? ({ + connect: { ...directPolicy.connect }, + } satisfies ConstructorParameters[0]) + : undefined, + ), + mode: "direct", + effectivePolicy: directPolicy, + }; + } + } + + return { + dispatcher: new Agent( + policy.connect + ? ({ + connect: { ...policy.connect }, + } satisfies ConstructorParameters[0]) + : undefined, + ), + mode: "direct", + effectivePolicy: policy, + }; +} + +function withDispatcherIfMissing( + init: RequestInit | undefined, + dispatcher: TelegramDispatcher, +): RequestInitWithDispatcher { + const withDispatcher = init as RequestInitWithDispatcher | undefined; + if (withDispatcher?.dispatcher) { + return init ?? {}; + } + return init ? { ...init, dispatcher } : { dispatcher }; +} + +function resolveWrappedFetch(fetchImpl: typeof fetch): typeof fetch { + return resolveFetch(fetchImpl) ?? fetchImpl; +} + +function logResolverNetworkDecisions(params: { + autoSelectDecision: ReturnType; + dnsDecision: ReturnType; +}): void { + if (params.autoSelectDecision.value !== null) { + const sourceLabel = params.autoSelectDecision.source + ? ` (${params.autoSelectDecision.source})` + : ""; + log.info(`autoSelectFamily=${params.autoSelectDecision.value}${sourceLabel}`); + } + if (params.dnsDecision.value !== null) { + const sourceLabel = params.dnsDecision.source ? ` (${params.dnsDecision.source})` : ""; + log.info(`dnsResultOrder=${params.dnsDecision.value}${sourceLabel}`); + } +} + +function collectErrorCodes(err: unknown): Set { + const codes = new Set(); + const queue: unknown[] = [err]; + const seen = new Set(); + + while (queue.length > 0) { + const current = queue.shift(); + if (!current || seen.has(current)) { + continue; + } + seen.add(current); + if (typeof current === "object") { + const code = (current as { code?: unknown }).code; + if (typeof code === "string" && code.trim()) { + codes.add(code.trim().toUpperCase()); + } + const cause = (current as { cause?: unknown }).cause; + if (cause && !seen.has(cause)) { + queue.push(cause); + } + const errors = (current as { errors?: unknown }).errors; + if (Array.isArray(errors)) { + for (const nested of errors) { + if (nested && !seen.has(nested)) { + queue.push(nested); + } + } + } + } + } + + return codes; +} + +function formatErrorCodes(err: unknown): string { + const codes = [...collectErrorCodes(err)]; + return codes.length > 0 ? codes.join(",") : "none"; +} + +function shouldRetryWithIpv4Fallback(err: unknown): boolean { + const ctx: Ipv4FallbackContext = { + message: + err && typeof err === "object" && "message" in err ? String(err.message).toLowerCase() : "", + codes: collectErrorCodes(err), + }; + for (const rule of IPV4_FALLBACK_RULES) { + if (!rule.matches(ctx)) { + return false; + } + } + return true; +} + +export function shouldRetryTelegramIpv4Fallback(err: unknown): boolean { + return shouldRetryWithIpv4Fallback(err); +} + +// Prefer wrapped fetch when available to normalize AbortSignal across runtimes. +export type TelegramTransport = { + fetch: typeof fetch; + sourceFetch: typeof fetch; + pinnedDispatcherPolicy?: PinnedDispatcherPolicy; + fallbackPinnedDispatcherPolicy?: PinnedDispatcherPolicy; +}; + +export function resolveTelegramTransport( + proxyFetch?: typeof fetch, + options?: { network?: TelegramNetworkConfig }, +): TelegramTransport { + const autoSelectDecision = resolveTelegramAutoSelectFamilyDecision({ + network: options?.network, + }); + const dnsDecision = resolveTelegramDnsResultOrderDecision({ + network: options?.network, + }); + logResolverNetworkDecisions({ + autoSelectDecision, + dnsDecision, + }); + + const explicitProxyUrl = proxyFetch ? getProxyUrlFromFetch(proxyFetch) : undefined; + const undiciSourceFetch = resolveWrappedFetch(undiciFetch as unknown as typeof fetch); + const sourceFetch = explicitProxyUrl + ? undiciSourceFetch + : proxyFetch + ? resolveWrappedFetch(proxyFetch) + : undiciSourceFetch; + const dnsResultOrder = normalizeDnsResultOrder(dnsDecision.value); + // Preserve fully caller-owned custom fetch implementations. + if (proxyFetch && !explicitProxyUrl) { + return { fetch: sourceFetch, sourceFetch }; + } + + const useEnvProxy = !explicitProxyUrl && hasEnvHttpProxyForTelegramApi(); + const defaultDispatcherResolution = resolveTelegramDispatcherPolicy({ + autoSelectFamily: autoSelectDecision.value, + dnsResultOrder, + useEnvProxy, + forceIpv4: false, + proxyUrl: explicitProxyUrl, + }); + const defaultDispatcher = createTelegramDispatcher(defaultDispatcherResolution.policy); + const shouldBypassEnvProxy = shouldBypassEnvProxyForTelegramApi(); + const allowStickyIpv4Fallback = + defaultDispatcher.mode === "direct" || + (defaultDispatcher.mode === "env-proxy" && shouldBypassEnvProxy); + const stickyShouldUseEnvProxy = defaultDispatcher.mode === "env-proxy"; + const fallbackPinnedDispatcherPolicy = allowStickyIpv4Fallback + ? resolveTelegramDispatcherPolicy({ + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + useEnvProxy: stickyShouldUseEnvProxy, + forceIpv4: true, + proxyUrl: explicitProxyUrl, + }).policy + : undefined; + + let stickyIpv4FallbackEnabled = false; + let stickyIpv4Dispatcher: TelegramDispatcher | null = null; + const resolveStickyIpv4Dispatcher = () => { + if (!stickyIpv4Dispatcher) { + if (!fallbackPinnedDispatcherPolicy) { + return defaultDispatcher.dispatcher; + } + stickyIpv4Dispatcher = createTelegramDispatcher(fallbackPinnedDispatcherPolicy).dispatcher; + } + return stickyIpv4Dispatcher; + }; + + const resolvedFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const callerProvidedDispatcher = Boolean( + (init as RequestInitWithDispatcher | undefined)?.dispatcher, + ); + const initialInit = withDispatcherIfMissing( + init, + stickyIpv4FallbackEnabled ? resolveStickyIpv4Dispatcher() : defaultDispatcher.dispatcher, + ); + try { + return await sourceFetch(input, initialInit); + } catch (err) { + if (shouldRetryWithIpv4Fallback(err)) { + // Preserve caller-owned dispatchers on retry. + if (callerProvidedDispatcher) { + return sourceFetch(input, init ?? {}); + } + // Proxy routes should not arm sticky IPv4 mode; `family=4` would constrain + // proxy-connect behavior instead of Telegram endpoint selection. + if (!allowStickyIpv4Fallback) { + throw err; + } + if (!stickyIpv4FallbackEnabled) { + stickyIpv4FallbackEnabled = true; + log.warn( + `fetch fallback: enabling sticky IPv4-only dispatcher (codes=${formatErrorCodes(err)})`, + ); + } + return sourceFetch(input, withDispatcherIfMissing(init, resolveStickyIpv4Dispatcher())); + } + throw err; + } + }) as typeof fetch; + + return { + fetch: resolvedFetch, + sourceFetch, + pinnedDispatcherPolicy: defaultDispatcher.effectivePolicy, + fallbackPinnedDispatcherPolicy, + }; +} + +export function resolveTelegramFetch( + proxyFetch?: typeof fetch, + options?: { network?: TelegramNetworkConfig }, +): typeof fetch { + return resolveTelegramTransport(proxyFetch, options).fetch; +} diff --git a/src/telegram/format.test.ts b/extensions/telegram/src/format.test.ts similarity index 100% rename from src/telegram/format.test.ts rename to extensions/telegram/src/format.test.ts diff --git a/extensions/telegram/src/format.ts b/extensions/telegram/src/format.ts new file mode 100644 index 00000000000..1ccd8f8299b --- /dev/null +++ b/extensions/telegram/src/format.ts @@ -0,0 +1,582 @@ +import type { MarkdownTableMode } from "../../../src/config/types.base.js"; +import { + chunkMarkdownIR, + markdownToIR, + type MarkdownLinkSpan, + type MarkdownIR, +} from "../../../src/markdown/ir.js"; +import { renderMarkdownWithMarkers } from "../../../src/markdown/render.js"; + +export type TelegramFormattedChunk = { + html: string; + text: string; +}; + +function escapeHtml(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">"); +} + +function escapeHtmlAttr(text: string): string { + return escapeHtml(text).replace(/"/g, """); +} + +/** + * File extensions that share TLDs and commonly appear in code/documentation. + * These are wrapped in tags to prevent Telegram from generating + * spurious domain registrar previews. + * + * Only includes extensions that are: + * 1. Commonly used as file extensions in code/docs + * 2. Rarely used as intentional domain references + * + * Excluded: .ai, .io, .tv, .fm (popular domain TLDs like x.ai, vercel.io, github.io) + */ +const FILE_EXTENSIONS_WITH_TLD = new Set([ + "md", // Markdown (Moldova) - very common in repos + "go", // Go language - common in Go projects + "py", // Python (Paraguay) - common in Python projects + "pl", // Perl (Poland) - common in Perl projects + "sh", // Shell (Saint Helena) - common for scripts + "am", // Automake files (Armenia) + "at", // Assembly (Austria) + "be", // Backend files (Belgium) + "cc", // C++ source (Cocos Islands) +]); + +/** Detects when markdown-it linkify auto-generated a link from a bare filename (e.g. README.md → http://README.md) */ +function isAutoLinkedFileRef(href: string, label: string): boolean { + const stripped = href.replace(/^https?:\/\//i, ""); + if (stripped !== label) { + return false; + } + const dotIndex = label.lastIndexOf("."); + if (dotIndex < 1) { + return false; + } + const ext = label.slice(dotIndex + 1).toLowerCase(); + if (!FILE_EXTENSIONS_WITH_TLD.has(ext)) { + return false; + } + // Reject if any path segment before the filename contains a dot (looks like a domain) + const segments = label.split("/"); + if (segments.length > 1) { + for (let i = 0; i < segments.length - 1; i++) { + if (segments[i].includes(".")) { + return false; + } + } + } + return true; +} + +function buildTelegramLink(link: MarkdownLinkSpan, text: string) { + const href = link.href.trim(); + if (!href) { + return null; + } + if (link.start === link.end) { + return null; + } + // Suppress auto-linkified file references (e.g. README.md → http://README.md) + const label = text.slice(link.start, link.end); + if (isAutoLinkedFileRef(href, label)) { + return null; + } + const safeHref = escapeHtmlAttr(href); + return { + start: link.start, + end: link.end, + open: ``, + close: "", + }; +} + +function renderTelegramHtml(ir: MarkdownIR): string { + return renderMarkdownWithMarkers(ir, { + styleMarkers: { + bold: { open: "", close: "" }, + italic: { open: "", close: "" }, + strikethrough: { open: "", close: "" }, + code: { open: "", close: "" }, + code_block: { open: "
", close: "
" }, + spoiler: { open: "", close: "" }, + blockquote: { open: "
", close: "
" }, + }, + escapeText: escapeHtml, + buildLink: buildTelegramLink, + }); +} + +export function markdownToTelegramHtml( + markdown: string, + options: { tableMode?: MarkdownTableMode; wrapFileRefs?: boolean } = {}, +): string { + const ir = markdownToIR(markdown ?? "", { + linkify: true, + enableSpoilers: true, + headingStyle: "none", + blockquotePrefix: "", + tableMode: options.tableMode, + }); + const html = renderTelegramHtml(ir); + // Apply file reference wrapping if requested (for chunked rendering) + if (options.wrapFileRefs !== false) { + return wrapFileReferencesInHtml(html); + } + return html; +} + +/** + * Wraps standalone file references (with TLD extensions) in tags. + * This prevents Telegram from treating them as URLs and generating + * irrelevant domain registrar previews. + * + * Runs AFTER markdown→HTML conversion to avoid modifying HTML attributes. + * Skips content inside ,
, and  tags to avoid nesting issues.
+ */
+/** Escape regex metacharacters in a string */
+function escapeRegex(str: string): string {
+  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+const FILE_EXTENSIONS_PATTERN = Array.from(FILE_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
+const AUTO_LINKED_ANCHOR_PATTERN = /]*>\1<\/a>/gi;
+const FILE_REFERENCE_PATTERN = new RegExp(
+  `(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=$|[^a-zA-Z0-9_\\-/])`,
+  "gi",
+);
+const ORPHANED_TLD_PATTERN = new RegExp(
+  `([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=[^a-zA-Z0-9/]|$)`,
+  "g",
+);
+const HTML_TAG_PATTERN = /(<\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?>/gi;
+
+function wrapStandaloneFileRef(match: string, prefix: string, filename: string): string {
+  if (filename.startsWith("//")) {
+    return match;
+  }
+  if (/https?:\/\/$/i.test(prefix)) {
+    return match;
+  }
+  return `${prefix}${escapeHtml(filename)}`;
+}
+
+function wrapSegmentFileRefs(
+  text: string,
+  codeDepth: number,
+  preDepth: number,
+  anchorDepth: number,
+): string {
+  if (!text || codeDepth > 0 || preDepth > 0 || anchorDepth > 0) {
+    return text;
+  }
+  const wrappedStandalone = text.replace(FILE_REFERENCE_PATTERN, wrapStandaloneFileRef);
+  return wrappedStandalone.replace(ORPHANED_TLD_PATTERN, (match, prefix: string, tld: string) =>
+    prefix === ">" ? match : `${prefix}${escapeHtml(tld)}`,
+  );
+}
+
+export function wrapFileReferencesInHtml(html: string): string {
+  // Safety-net: de-linkify auto-generated anchors where href="http://`,
-    close: "",
-  };
-}
-
-function renderTelegramHtml(ir: MarkdownIR): string {
-  return renderMarkdownWithMarkers(ir, {
-    styleMarkers: {
-      bold: { open: "", close: "" },
-      italic: { open: "", close: "" },
-      strikethrough: { open: "", close: "" },
-      code: { open: "", close: "" },
-      code_block: { open: "
", close: "
" }, - spoiler: { open: "", close: "" }, - blockquote: { open: "
", close: "
" }, - }, - escapeText: escapeHtml, - buildLink: buildTelegramLink, - }); -} - -export function markdownToTelegramHtml( - markdown: string, - options: { tableMode?: MarkdownTableMode; wrapFileRefs?: boolean } = {}, -): string { - const ir = markdownToIR(markdown ?? "", { - linkify: true, - enableSpoilers: true, - headingStyle: "none", - blockquotePrefix: "", - tableMode: options.tableMode, - }); - const html = renderTelegramHtml(ir); - // Apply file reference wrapping if requested (for chunked rendering) - if (options.wrapFileRefs !== false) { - return wrapFileReferencesInHtml(html); - } - return html; -} - -/** - * Wraps standalone file references (with TLD extensions) in tags. - * This prevents Telegram from treating them as URLs and generating - * irrelevant domain registrar previews. - * - * Runs AFTER markdown→HTML conversion to avoid modifying HTML attributes. - * Skips content inside ,
, and  tags to avoid nesting issues.
- */
-/** Escape regex metacharacters in a string */
-function escapeRegex(str: string): string {
-  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
-}
-
-const FILE_EXTENSIONS_PATTERN = Array.from(FILE_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
-const AUTO_LINKED_ANCHOR_PATTERN = /]*>\1<\/a>/gi;
-const FILE_REFERENCE_PATTERN = new RegExp(
-  `(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=$|[^a-zA-Z0-9_\\-/])`,
-  "gi",
-);
-const ORPHANED_TLD_PATTERN = new RegExp(
-  `([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=[^a-zA-Z0-9/]|$)`,
-  "g",
-);
-const HTML_TAG_PATTERN = /(<\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?>/gi;
-
-function wrapStandaloneFileRef(match: string, prefix: string, filename: string): string {
-  if (filename.startsWith("//")) {
-    return match;
-  }
-  if (/https?:\/\/$/i.test(prefix)) {
-    return match;
-  }
-  return `${prefix}${escapeHtml(filename)}`;
-}
-
-function wrapSegmentFileRefs(
-  text: string,
-  codeDepth: number,
-  preDepth: number,
-  anchorDepth: number,
-): string {
-  if (!text || codeDepth > 0 || preDepth > 0 || anchorDepth > 0) {
-    return text;
-  }
-  const wrappedStandalone = text.replace(FILE_REFERENCE_PATTERN, wrapStandaloneFileRef);
-  return wrappedStandalone.replace(ORPHANED_TLD_PATTERN, (match, prefix: string, tld: string) =>
-    prefix === ">" ? match : `${prefix}${escapeHtml(tld)}`,
-  );
-}
-
-export function wrapFileReferencesInHtml(html: string): string {
-  // Safety-net: de-linkify auto-generated anchors where href="http://