refactor: move Telegram channel implementation to extensions/ (#45635)

* refactor: move Telegram channel implementation to extensions/telegram/src/

Move all Telegram channel code (123 files + 10 bot/ files + 8 channel plugin
files) from src/telegram/ and src/channels/plugins/*/telegram.ts to
extensions/telegram/src/. Leave thin re-export shims at original locations so
cross-cutting src/ imports continue to resolve.

- Fix all relative import paths in moved files (../X/ -> ../../../src/X/)
- Fix vi.mock paths in 60 test files
- Fix inline typeof import() expressions
- Update tsconfig.plugin-sdk.dts.json rootDir to "." for cross-directory DTS
- Update write-plugin-sdk-entry-dts.ts for new rootDir structure
- Move channel plugin files with correct path remapping

* fix: support keyed telegram send deps

* fix: sync telegram extension copies with latest main

* fix: correct import paths and remove misplaced files in telegram extension

* fix: sync outbound-adapter with main (add sendTelegramPayloadMessages) and fix delivery.test import path
This commit is contained in:
scoootscooob
2026-03-14 02:50:17 -07:00
committed by GitHub
parent 8746362f5e
commit e5bca0832f
230 changed files with 19157 additions and 19204 deletions

View File

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

View File

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

View File

@@ -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,

View File

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

View File

@@ -0,0 +1,14 @@
import { API_CONSTANTS } from "grammy";
type TelegramUpdateType = (typeof API_CONSTANTS.ALL_UPDATE_TYPES)[number];
export function resolveTelegramAllowedUpdates(): ReadonlyArray<TelegramUpdateType> {
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;
}

View File

@@ -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<T> = {
operation: string;
fn: () => Promise<T>;
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<T>({
operation,
fn,
runtime,
logger,
shouldLog,
}: TelegramApiLoggingParams<T>): Promise<T> {
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;
}
}

View File

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

View File

@@ -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<Array<{ text: string; callback_data: string }>> = [primaryRow];
const deny = `/approve ${approvalId} deny`;
if (allowedDecisions.includes("deny") && fitsCallbackData(deny)) {
rows.push([{ text: "Deny", callback_data: deny }]);
}
return rows;
}

View File

@@ -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<T> = { ok: true; result: T };
type TelegramApiErr = { ok: false; description?: string };
type TelegramGroupMembershipAuditData = Omit<TelegramGroupMembershipAudit, "elapsedMs">;
export async function auditTelegramGroupMembershipImpl(
params: AuditTelegramGroupMembershipParams,
): Promise<TelegramGroupMembershipAuditData> {
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<unknown>).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,
};
}

View File

@@ -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<string, TelegramGroupConfig> | 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<typeof import("./audit-membership-runtime.js")> | null =
null;
function loadAuditMembershipRuntime() {
auditMembershipRuntimePromise ??= import("./audit-membership-runtime.js");
return auditMembershipRuntimePromise;
}
export async function auditTelegramGroupMembership(
params: AuditTelegramGroupMembershipParams,
): Promise<TelegramGroupMembershipAudit> {
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,
};
}

View File

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

View File

@@ -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<string>();
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<string | number>): 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<string | number>;
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 };
};

File diff suppressed because it is too large Load Diff

View File

@@ -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[]) =>

View File

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

View File

@@ -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<boolean> {
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<string, HistoryEntry[]>;
historyLimit: number;
logger: TelegramLogger;
}): Promise<TelegramInboundBodyResult | null> {
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 === "<media:audio>" && preflightTranscript) {
bodyText = preflightTranscript;
}
if (!bodyText && allMedia.length > 0) {
if (hasAudio) {
bodyText = preflightTranscript || "<media:audio>";
} else {
bodyText = `<media:image>${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,
};
}

View File

@@ -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", () => {

View File

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

View File

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

View File

@@ -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<string, HistoryEntry[]>;
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
stickerCacheHit: boolean;
effectiveWasMentioned: boolean;
commandAuthorized: boolean;
locationData?: import("../../../src/channels/location.js").NormalizedLocation;
options?: TelegramMessageContextOptions;
dmAllowFrom?: Array<string | number>;
}): Promise<{
ctxPayload: ReturnType<typeof finalizeInboundContext>;
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,
};
}

View File

@@ -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<typeof import("../infra/outbound/session-binding-service.js")>();
await importOriginal<typeof import("../../../src/infra/outbound/session-binding-service.js")>();
return {
...actual,
getSessionBindingService: () => ({

View File

@@ -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<typeof import("../config/config.js")>();
vi.mock("../../../src/config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
return {
...actual,
loadConfig: vi.fn(() => defaultRouteConfig),

View File

@@ -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<typeof resolveTelegramConversationRoute>["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<boolean> => {
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<void>;
getChat?: (chatId: number | string) => Promise<unknown>;
};
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<Set<string> | 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<ReturnType<typeof buildTelegramMessageContext>>
>;

View File

@@ -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<string, unknown>, 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<string, HistoryEntry[]>;
dmPolicy: DmPolicy;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
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;
};

View File

@@ -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<typeof import("../config/sessions.js")>();
vi.mock("../../../src/config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/config/sessions.js")>();
return {
...actual,
loadSessionStore,

View File

@@ -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<TelegramBotOptions, "token">;
};
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<LaneName, DraftLaneState> = {
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<LaneName, LanePreviewLifecycle> = {
answer: "transient",
reasoning: "transient",
};
const retainPreviewOnCleanupByLane: Record<LaneName, boolean> = {
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<void>): Promise<void> => {
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<DraftLaneState["stream"]>,
{ 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();
};

View File

@@ -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<TelegramBotOptions, "token">;
};
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.
}
}
};
};

View File

@@ -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<string>;
}): { commands: TelegramMenuCommand[]; issues: string[] } {
const { specs, existingCommands } = params;
const commands: TelegramMenuCommand[] = [];
const issues: string[] = [];
const pluginCommandNames = new Set<string>();
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 : "<unknown>";
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<string | null> {
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<void> {
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)}`);
});
}

View File

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

View File

@@ -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<typeof import("../plugins/commands.js").getPluginCommandSpecs>,
value: ReturnType<typeof import("../../../src/plugins/commands.js").getPluginCommandSpecs>,
) => unknown;
};
type MatchPluginCommandMock = {
mockReturnValue: (
value: ReturnType<typeof import("../plugins/commands.js").matchPluginCommand>,
value: ReturnType<typeof import("../../../src/plugins/commands.js").matchPluginCommand>,
) => unknown;
};
type ExecutePluginCommandMock = {
mockResolvedValue: (
value: Awaited<ReturnType<typeof import("../plugins/commands.js").executePluginCommand>>,
value: Awaited<
ReturnType<typeof import("../../../src/plugins/commands.js").executePluginCommand>
>,
) => unknown;
};

View File

@@ -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<typeof registerTelegramNa
// All mocks scoped to this file only — does not affect bot-native-commands.test.ts
type ResolveConfiguredAcpBindingRecordFn =
typeof import("../acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord;
typeof import("../../../src/acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord;
type EnsureConfiguredAcpBindingSessionFn =
typeof import("../acp/persistent-bindings.js").ensureConfiguredAcpBindingSession;
typeof import("../../../src/acp/persistent-bindings.js").ensureConfiguredAcpBindingSession;
type DispatchReplyWithBufferedBlockDispatcherFn =
typeof import("../auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
typeof import("../../../src/auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
type DispatchReplyWithBufferedBlockDispatcherParams =
Parameters<DispatchReplyWithBufferedBlockDispatcherFn>[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<typeof import("../acp/persistent-bindings.js")>();
vi.mock("../../../src/acp/persistent-bindings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/acp/persistent-bindings.js")>();
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<typeof import("../auto-reply/skill-commands.js")>();
vi.mock("../../../src/auto-reply/skill-commands.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/auto-reply/skill-commands.js")>();
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<typeof vi.fn>) {

View File

@@ -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,

View File

@@ -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<typeof registerTelegramNativeCommands>[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<unknown>>;
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 () => []),
}));

View File

@@ -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<typeof import("../auto-reply/skill-commands.js")>();
vi.mock("../../../src/auto-reply/skill-commands.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/auto-reply/skill-commands.js")>();
return {
...actual,
listSkillCommandsForAgents,
};
});
vi.mock("../plugins/commands.js", () => ({
vi.mock("../../../src/plugins/commands.js", () => ({
getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs,
matchPluginCommand: pluginCommandMocks.matchPluginCommand,
executePluginCommand: pluginCommandMocks.executePluginCommand,

View File

@@ -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<string | number>;
groupAllowFrom?: Array<string | number>;
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<void>;
logger: ReturnType<typeof getChildLogger>;
};
type RegisterTelegramNativeCommandsParams = {
bot: Bot;
cfg: OpenClawConfig;
runtime: RuntimeEnv;
accountId: string;
telegramCfg: TelegramAccountConfig;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
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<TelegramNativeCommandContext["message"]>;
bot: Bot;
cfg: OpenClawConfig;
accountId: string;
telegramCfg: TelegramAccountConfig;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
useAccessGroups: boolean;
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
resolveTelegramGroupConfig: (
chatId: string | number,
messageThreadId?: number,
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
requireAuth: boolean;
}): Promise<TelegramCommandAuthResult | null> {
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<TelegramNativeCommandContext["message"]>;
isGroup: boolean;
isForum: boolean;
resolvedThreadId?: number;
senderId?: string;
topicAgentId?: string;
}): Promise<{
chatId: number;
threadSpec: ReturnType<typeof resolveTelegramThreadSpec>;
route: ReturnType<typeof resolveTelegramConversationRoute>["route"];
mediaLocalRoots: readonly string[] | undefined;
tableMode: ReturnType<typeof resolveMarkdownTableMode>;
chunkMode: ReturnType<typeof resolveChunkMode>;
} | 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<typeof resolveTelegramThreadSpec>;
tableMode: ReturnType<typeof resolveMarkdownTableMode>;
chunkMode: ReturnType<typeof resolveChunkMode>;
}) => ({
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<Array<{ text: string; callback_data: string }>> = [];
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(() => {});
}
};

View File

@@ -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<typeof setTimeout>;
};
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 };

View File

@@ -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<unknown>>;
@@ -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<typeof import("../config/config.js")>();
vi.mock("../../../src/config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
return {
...actual,
loadConfig,
};
});
vi.mock("../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/sessions.js")>();
vi.mock("../../../src/config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/config/sessions.js")>();
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,
}));

View File

@@ -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,

View File

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

View File

@@ -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<typeof import("../media/store.js")>();
vi.mock("../../../src/media/store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/media/store.js")>();
const mockModule = Object.create(null) as Record<string, unknown>;
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<typeof import("../config/config.js")>();
vi.mock("../../../src/config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
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<typeof import("../config/sessions.js")>();
vi.mock("../../../src/config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/config/sessions.js")>();
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;

View File

@@ -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<typeof vi.fn> }).__replySpy;
}, TELEGRAM_BOT_IMPORT_TIMEOUT_MS);

View File

@@ -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,

View File

@@ -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<string | number>;
groupAllowFrom?: Array<string | number>;
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<void>;
};
testTimings?: {
mediaGroupFlushMs?: number;
textFragmentGapMs?: number;
};
};
export { getTelegramSequentialKey };
type TelegramFetchInput = Parameters<NonNullable<ApiClientOptions["fetch"]>>[0];
type TelegramFetchInit = Parameters<NonNullable<ApiClientOptions["fetch"]>>[1];
type GlobalFetchInput = Parameters<typeof globalThis.fetch>[0];
type GlobalFetchInit = Parameters<typeof globalThis.fetch>[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<ApiClientOptions["fetch"]>);
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<ApiClientOptions["fetch"]>;
}
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<ApiClientOptions["fetch"]>;
}
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<number>();
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<string, HistoryEntry[]>();
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<typeof bot.api.sendChatAction>[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<typeof originalStop>) => {
threadBindingManager?.stop();
return originalStop(...args);
}) as typeof bot.stop;
return bot;
}

View File

@@ -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<typeof markdownToTelegramChunks>;
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<typeof markdownToTelegramChunks> = [];
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<typeof buildInlineKeyboard>;
replyQuoteText?: string;
linkPreview?: boolean;
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
}): Promise<number | undefined> {
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<typeof buildInlineKeyboard>;
linkPreview?: boolean;
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
}): Promise<void> {
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<typeof markdownToTelegramChunks>;
replyToId?: number;
thread?: TelegramThreadSpec | null;
linkPreview?: boolean;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
replyQuoteText?: string;
}): Promise<number | undefined> {
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> | void;
linkPreview?: boolean;
replyQuoteText?: string;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
}): Promise<number | undefined> {
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<string, unknown> = {
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<void> {
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<typeof getGlobalHookRunner>;
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> | 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 };
}

View File

@@ -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<typeof import("../../media/store.js")>();
vi.mock("../../../../src/media/store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/media/store.js")>();
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: () => {},

View File

@@ -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. <media:audio>) 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: "<media:sticker>",
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: "<media:sticker>",
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) ?? "<media:document>";
return { path: saved.path, contentType: saved.contentType, placeholder };
}

View File

@@ -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<string, unknown> | undefined): boolean {
if (!params) {
return false;
}
return typeof params.message_thread_id === "number";
}
function removeMessageThreadIdParam(
params: Record<string, unknown> | undefined,
): Record<string, unknown> {
if (!params) {
return {};
}
const { message_thread_id: _ignored, ...rest } = params;
return rest;
}
export async function sendTelegramWithThreadFallback<T>(params: {
operation: string;
runtime: RuntimeEnv;
thread?: TelegramThreadSpec | null;
requestParams: Record<string, unknown>;
send: (effectiveParams: Record<string, unknown>) => Promise<T>;
shouldLog?: (err: unknown) => boolean;
}): Promise<T> {
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<string, unknown> {
const threadParams = buildTelegramThreadParams(opts?.thread);
const params: Record<string, unknown> = {};
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<typeof buildInlineKeyboard>;
},
): Promise<number> {
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;
}
}

View File

@@ -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<Pick<DeliverRepliesParams, "replyToMode" | "textLimit">>;
type RuntimeStub = Pick<RuntimeEnv, "error" | "log" | "exit">;
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<typeof import("../../hooks/internal-hooks.js")>(
"../../hooks/internal-hooks.js",
vi.mock("../../../../src/hooks/internal-hooks.js", async () => {
const actual = await vi.importActual<typeof import("../../../../src/hooks/internal-hooks.js")>(
"../../../../src/hooks/internal-hooks.js",
);
return {
...actual,

View File

@@ -0,0 +1,2 @@
export { deliverReplies } from "./delivery.replies.js";
export { resolveMedia } from "./delivery.resolve-media.js";

View File

@@ -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<string | number>;
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<string | number>;
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<Message, "photo" | "video" | "video_note" | "audio" | "voice" | "document" | "sticker">
| undefined
| null,
): string | undefined {
if (!msg) {
return undefined;
}
if (msg.photo) {
return "<media:image>";
}
if (msg.video || msg.video_note) {
return "<media:video>";
}
if (msg.audio || msg.voice) {
return "<media:audio>";
}
if (msg.document) {
return "<media:document>";
}
if (msg.sticker) {
return "<media:sticker>";
}
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<Message["entities"]>[number];
export function getTelegramTextParts(
msg: Pick<Message, "text" | "caption" | "entities" | "caption_entities">,
): {
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;
}

View File

@@ -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<void>;
}): Promise<void> {
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);
}
}

View File

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

View File

@@ -0,0 +1,9 @@
export type TelegramButtonStyle = "danger" | "success" | "primary";
export type TelegramInlineButton = {
text: string;
callback_data: string;
style?: TelegramButtonStyle;
};
export type TelegramInlineButtons = ReadonlyArray<ReadonlyArray<TelegramInlineButton>>;

View File

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

View File

@@ -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<string, unknown>) {
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, unknown>): string | number {
return (
readStringOrNumberParam(params, "chatId") ??
readStringOrNumberParam(params, "channelId") ??
readStringParam(params, "to", { required: true })
);
}
function readTelegramMessageIdParam(params: Record<string, unknown>): 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<ChannelMessageActionName>(["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}.`);
},
};

View File

@@ -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<typeof resolveAgentRoute>;
configuredBinding: ReturnType<typeof resolveConfiguredAcpRoute>["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,
};
}

View File

@@ -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<string, unknown>, 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<boolean> {
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;
}

View File

@@ -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", () => {

View File

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

View File

@@ -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<typeof createTelegramDraftStream>[0];

View File

@@ -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<unknown>;
/**
* 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<void>;
messageId: () => number | undefined;
previewMode?: () => "message" | "draft";
previewRevision?: () => number;
lastDeliveredText?: () => string;
clear: () => Promise<void>;
stop: () => Promise<void>;
/** Convert the current draft preview into a permanent message (sendMessage). */
materialize?: () => Promise<number | undefined>;
/** 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<string, unknown>),
};
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<boolean> => {
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<ReturnType<typeof sendRenderedMessageWithThreadFallback>>["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<boolean> => {
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<boolean> => {
// 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<number | undefined> => {
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;
},
};

View File

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

View File

@@ -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<string>();
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<string, PendingApproval>();
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
}

View File

@@ -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<NonNullable<OpenClawConfig["channels"]>["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);
});
});

View File

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

View File

@@ -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<string, unknown>): Record<PropertyKey, unknown>;
};
const { kHttpsProxyAgent, kNoProxyAgent } = require("undici/lib/core/symbols.js") as {
kHttpsProxyAgent: symbol;
kNoProxyAgent: symbol;
};
function getOwnSymbolValue(
target: Record<PropertyKey, unknown>,
description: string,
): Record<string, unknown> | 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<string, unknown>) : 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<PropertyKey, unknown>;
const httpsProxyAgent = withoutProxyTls[kHttpsProxyAgent] as Record<PropertyKey, unknown>;
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),
);
});
});

View File

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

View File

@@ -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<string>;
};
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<typeof ProxyAgent>[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<typeof EnvHttpProxyAgent>[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<typeof Agent>[0])
: undefined,
),
mode: "direct",
effectivePolicy: directPolicy,
};
}
}
return {
dispatcher: new Agent(
policy.connect
? ({
connect: { ...policy.connect },
} satisfies ConstructorParameters<typeof Agent>[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<typeof resolveTelegramAutoSelectFamilyDecision>;
dnsDecision: ReturnType<typeof resolveTelegramDnsResultOrderDecision>;
}): 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<string> {
const codes = new Set<string>();
const queue: unknown[] = [err];
const seen = new Set<unknown>();
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;
}

View File

@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escapeHtmlAttr(text: string): string {
return escapeHtml(text).replace(/"/g, "&quot;");
}
/**
* File extensions that share TLDs and commonly appear in code/documentation.
* These are wrapped in <code> 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: `<a href="${safeHref}">`,
close: "</a>",
};
}
function renderTelegramHtml(ir: MarkdownIR): string {
return renderMarkdownWithMarkers(ir, {
styleMarkers: {
bold: { open: "<b>", close: "</b>" },
italic: { open: "<i>", close: "</i>" },
strikethrough: { open: "<s>", close: "</s>" },
code: { open: "<code>", close: "</code>" },
code_block: { open: "<pre><code>", close: "</code></pre>" },
spoiler: { open: "<tg-spoiler>", close: "</tg-spoiler>" },
blockquote: { open: "<blockquote>", close: "</blockquote>" },
},
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 <code> 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 <code>, <pre>, and <a> 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 = /<a\s+href="https?:\/\/([^"]+)"[^>]*>\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}<code>${escapeHtml(filename)}</code>`;
}
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}<code>${escapeHtml(tld)}</code>`,
);
}
export function wrapFileReferencesInHtml(html: string): string {
// Safety-net: de-linkify auto-generated anchors where href="http://<label>" (defense in depth for textMode: "html")
AUTO_LINKED_ANCHOR_PATTERN.lastIndex = 0;
const deLinkified = html.replace(AUTO_LINKED_ANCHOR_PATTERN, (_match, label: string) => {
if (!isAutoLinkedFileRef(`http://${label}`, label)) {
return _match;
}
return `<code>${escapeHtml(label)}</code>`;
});
// Track nesting depth for tags that should not be modified
let codeDepth = 0;
let preDepth = 0;
let anchorDepth = 0;
let result = "";
let lastIndex = 0;
// Process tags token-by-token so we can skip protected regions while wrapping plain text.
HTML_TAG_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = HTML_TAG_PATTERN.exec(deLinkified)) !== null) {
const tagStart = match.index;
const tagEnd = HTML_TAG_PATTERN.lastIndex;
const isClosing = match[1] === "</";
const tagName = match[2].toLowerCase();
// Process text before this tag
const textBefore = deLinkified.slice(lastIndex, tagStart);
result += wrapSegmentFileRefs(textBefore, codeDepth, preDepth, anchorDepth);
// Update tag depth (clamp at 0 for malformed HTML with stray closing tags)
if (tagName === "code") {
codeDepth = isClosing ? Math.max(0, codeDepth - 1) : codeDepth + 1;
} else if (tagName === "pre") {
preDepth = isClosing ? Math.max(0, preDepth - 1) : preDepth + 1;
} else if (tagName === "a") {
anchorDepth = isClosing ? Math.max(0, anchorDepth - 1) : anchorDepth + 1;
}
// Add the tag itself
result += deLinkified.slice(tagStart, tagEnd);
lastIndex = tagEnd;
}
// Process remaining text
const remainingText = deLinkified.slice(lastIndex);
result += wrapSegmentFileRefs(remainingText, codeDepth, preDepth, anchorDepth);
return result;
}
export function renderTelegramHtmlText(
text: string,
options: { textMode?: "markdown" | "html"; tableMode?: MarkdownTableMode } = {},
): string {
const textMode = options.textMode ?? "markdown";
if (textMode === "html") {
// For HTML mode, trust caller markup - don't modify
return text;
}
// markdownToTelegramHtml already wraps file references by default
return markdownToTelegramHtml(text, { tableMode: options.tableMode });
}
type TelegramHtmlTag = {
name: string;
openTag: string;
closeTag: string;
};
const TELEGRAM_SELF_CLOSING_HTML_TAGS = new Set(["br"]);
function buildTelegramHtmlOpenPrefix(tags: TelegramHtmlTag[]): string {
return tags.map((tag) => tag.openTag).join("");
}
function buildTelegramHtmlCloseSuffix(tags: TelegramHtmlTag[]): string {
return tags
.slice()
.toReversed()
.map((tag) => tag.closeTag)
.join("");
}
function buildTelegramHtmlCloseSuffixLength(tags: TelegramHtmlTag[]): number {
return tags.reduce((total, tag) => total + tag.closeTag.length, 0);
}
function findTelegramHtmlEntityEnd(text: string, start: number): number {
if (text[start] !== "&") {
return -1;
}
let index = start + 1;
if (index >= text.length) {
return -1;
}
if (text[index] === "#") {
index += 1;
if (index >= text.length) {
return -1;
}
const isHex = text[index] === "x" || text[index] === "X";
if (isHex) {
index += 1;
const hexStart = index;
while (/[0-9A-Fa-f]/.test(text[index] ?? "")) {
index += 1;
}
if (index === hexStart) {
return -1;
}
} else {
const digitStart = index;
while (/[0-9]/.test(text[index] ?? "")) {
index += 1;
}
if (index === digitStart) {
return -1;
}
}
} else {
const nameStart = index;
while (/[A-Za-z0-9]/.test(text[index] ?? "")) {
index += 1;
}
if (index === nameStart) {
return -1;
}
}
return text[index] === ";" ? index : -1;
}
function findTelegramHtmlSafeSplitIndex(text: string, maxLength: number): number {
if (text.length <= maxLength) {
return text.length;
}
const normalizedMaxLength = Math.max(1, Math.floor(maxLength));
const lastAmpersand = text.lastIndexOf("&", normalizedMaxLength - 1);
if (lastAmpersand === -1) {
return normalizedMaxLength;
}
const lastSemicolon = text.lastIndexOf(";", normalizedMaxLength - 1);
if (lastAmpersand < lastSemicolon) {
return normalizedMaxLength;
}
const entityEnd = findTelegramHtmlEntityEnd(text, lastAmpersand);
if (entityEnd === -1 || entityEnd < normalizedMaxLength) {
return normalizedMaxLength;
}
return lastAmpersand;
}
function popTelegramHtmlTag(tags: TelegramHtmlTag[], name: string): void {
for (let index = tags.length - 1; index >= 0; index -= 1) {
if (tags[index]?.name === name) {
tags.splice(index, 1);
return;
}
}
}
export function splitTelegramHtmlChunks(html: string, limit: number): string[] {
if (!html) {
return [];
}
const normalizedLimit = Math.max(1, Math.floor(limit));
if (html.length <= normalizedLimit) {
return [html];
}
const chunks: string[] = [];
const openTags: TelegramHtmlTag[] = [];
let current = "";
let chunkHasPayload = false;
const resetCurrent = () => {
current = buildTelegramHtmlOpenPrefix(openTags);
chunkHasPayload = false;
};
const flushCurrent = () => {
if (!chunkHasPayload) {
return;
}
chunks.push(`${current}${buildTelegramHtmlCloseSuffix(openTags)}`);
resetCurrent();
};
const appendText = (segment: string) => {
let remaining = segment;
while (remaining.length > 0) {
const available =
normalizedLimit - current.length - buildTelegramHtmlCloseSuffixLength(openTags);
if (available <= 0) {
if (!chunkHasPayload) {
throw new Error(
`Telegram HTML chunk limit exceeded by tag overhead (limit=${normalizedLimit})`,
);
}
flushCurrent();
continue;
}
if (remaining.length <= available) {
current += remaining;
chunkHasPayload = true;
break;
}
const splitAt = findTelegramHtmlSafeSplitIndex(remaining, available);
if (splitAt <= 0) {
if (!chunkHasPayload) {
throw new Error(
`Telegram HTML chunk limit exceeded by leading entity (limit=${normalizedLimit})`,
);
}
flushCurrent();
continue;
}
current += remaining.slice(0, splitAt);
chunkHasPayload = true;
remaining = remaining.slice(splitAt);
flushCurrent();
}
};
resetCurrent();
HTML_TAG_PATTERN.lastIndex = 0;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = HTML_TAG_PATTERN.exec(html)) !== null) {
const tagStart = match.index;
const tagEnd = HTML_TAG_PATTERN.lastIndex;
appendText(html.slice(lastIndex, tagStart));
const rawTag = match[0];
const isClosing = match[1] === "</";
const tagName = match[2].toLowerCase();
const isSelfClosing =
!isClosing &&
(TELEGRAM_SELF_CLOSING_HTML_TAGS.has(tagName) || rawTag.trimEnd().endsWith("/>"));
if (!isClosing) {
const nextCloseLength = isSelfClosing ? 0 : `</${tagName}>`.length;
if (
chunkHasPayload &&
current.length +
rawTag.length +
buildTelegramHtmlCloseSuffixLength(openTags) +
nextCloseLength >
normalizedLimit
) {
flushCurrent();
}
}
current += rawTag;
if (isSelfClosing) {
chunkHasPayload = true;
}
if (isClosing) {
popTelegramHtmlTag(openTags, tagName);
} else if (!isSelfClosing) {
openTags.push({
name: tagName,
openTag: rawTag,
closeTag: `</${tagName}>`,
});
}
lastIndex = tagEnd;
}
appendText(html.slice(lastIndex));
flushCurrent();
return chunks.length > 0 ? chunks : [html];
}
function splitTelegramChunkByHtmlLimit(
chunk: MarkdownIR,
htmlLimit: number,
renderedHtmlLength: number,
): MarkdownIR[] {
const currentTextLength = chunk.text.length;
if (currentTextLength <= 1) {
return [chunk];
}
const proportionalLimit = Math.floor(
(currentTextLength * htmlLimit) / Math.max(renderedHtmlLength, 1),
);
const candidateLimit = Math.min(currentTextLength - 1, proportionalLimit);
const splitLimit =
Number.isFinite(candidateLimit) && candidateLimit > 0
? candidateLimit
: Math.max(1, Math.floor(currentTextLength / 2));
const split = splitMarkdownIRPreserveWhitespace(chunk, splitLimit);
if (split.length > 1) {
return split;
}
return splitMarkdownIRPreserveWhitespace(chunk, Math.max(1, Math.floor(currentTextLength / 2)));
}
function sliceStyleSpans(
styles: MarkdownIR["styles"],
start: number,
end: number,
): MarkdownIR["styles"] {
return styles.flatMap((span) => {
if (span.end <= start || span.start >= end) {
return [];
}
const nextStart = Math.max(span.start, start) - start;
const nextEnd = Math.min(span.end, end) - start;
if (nextEnd <= nextStart) {
return [];
}
return [{ ...span, start: nextStart, end: nextEnd }];
});
}
function sliceLinkSpans(
links: MarkdownIR["links"],
start: number,
end: number,
): MarkdownIR["links"] {
return links.flatMap((link) => {
if (link.end <= start || link.start >= end) {
return [];
}
const nextStart = Math.max(link.start, start) - start;
const nextEnd = Math.min(link.end, end) - start;
if (nextEnd <= nextStart) {
return [];
}
return [{ ...link, start: nextStart, end: nextEnd }];
});
}
function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): MarkdownIR[] {
if (!ir.text) {
return [];
}
const normalizedLimit = Math.max(1, Math.floor(limit));
if (normalizedLimit <= 0 || ir.text.length <= normalizedLimit) {
return [ir];
}
const chunks: MarkdownIR[] = [];
let cursor = 0;
while (cursor < ir.text.length) {
const end = Math.min(ir.text.length, cursor + normalizedLimit);
chunks.push({
text: ir.text.slice(cursor, end),
styles: sliceStyleSpans(ir.styles, cursor, end),
links: sliceLinkSpans(ir.links, cursor, end),
});
cursor = end;
}
return chunks;
}
function renderTelegramChunksWithinHtmlLimit(
ir: MarkdownIR,
limit: number,
): TelegramFormattedChunk[] {
const normalizedLimit = Math.max(1, Math.floor(limit));
const pending = chunkMarkdownIR(ir, normalizedLimit);
const rendered: TelegramFormattedChunk[] = [];
while (pending.length > 0) {
const chunk = pending.shift();
if (!chunk) {
continue;
}
const html = wrapFileReferencesInHtml(renderTelegramHtml(chunk));
if (html.length <= normalizedLimit || chunk.text.length <= 1) {
rendered.push({ html, text: chunk.text });
continue;
}
const split = splitTelegramChunkByHtmlLimit(chunk, normalizedLimit, html.length);
if (split.length <= 1) {
// Worst-case safety: avoid retry loops, deliver the chunk as-is.
rendered.push({ html, text: chunk.text });
continue;
}
pending.unshift(...split);
}
return rendered;
}
export function markdownToTelegramChunks(
markdown: string,
limit: number,
options: { tableMode?: MarkdownTableMode } = {},
): TelegramFormattedChunk[] {
const ir = markdownToIR(markdown ?? "", {
linkify: true,
enableSpoilers: true,
headingStyle: "none",
blockquotePrefix: "",
tableMode: options.tableMode,
});
return renderTelegramChunksWithinHtmlLimit(ir, limit);
}
export function markdownToTelegramHtmlChunks(markdown: string, limit: number): string[] {
return markdownToTelegramChunks(markdown, limit).map((chunk) => chunk.html);
}

View File

@@ -0,0 +1,23 @@
/** Telegram forum-topic service-message fields (Bot API). */
export const TELEGRAM_FORUM_SERVICE_FIELDS = [
"forum_topic_created",
"forum_topic_edited",
"forum_topic_closed",
"forum_topic_reopened",
"general_forum_topic_hidden",
"general_forum_topic_unhidden",
] as const;
/**
* Returns `true` when the message is a Telegram forum service message (e.g.
* "Topic created"). These auto-generated messages carry one of the
* `forum_topic_*` / `general_forum_topic_*` fields and should not count as
* regular bot replies for implicit-mention purposes.
*/
export function isTelegramForumServiceMessage(msg: unknown): boolean {
if (!msg || typeof msg !== "object") {
return false;
}
const record = msg as Record<string, unknown>;
return TELEGRAM_FORUM_SERVICE_FIELDS.some((field) => record[field] != null);
}

View File

@@ -1,5 +1,5 @@
import { describe } from "vitest";
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../test-utils/runtime-group-policy-contract.js";
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../src/test-utils/runtime-group-policy-contract.js";
import { resolveTelegramRuntimeGroupPolicy } from "./group-access.js";
describe("resolveTelegramRuntimeGroupPolicy", () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } 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 { evaluateTelegramGroupPolicyAccess } from "./group-access.js";
/**

View File

@@ -0,0 +1,205 @@
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js";
import { resolveOpenProviderRuntimeGroupPolicy } from "../../../src/config/runtime-group-policy.js";
import type {
TelegramAccountConfig,
TelegramDirectConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "../../../src/config/types.js";
import { evaluateMatchedGroupAccessForPolicy } from "../../../src/plugin-sdk/group-access.js";
import { isSenderAllowed, type NormalizedAllowFrom } from "./bot-access.js";
import { firstDefined } from "./bot-access.js";
export type TelegramGroupBaseBlockReason =
| "group-disabled"
| "topic-disabled"
| "group-override-unauthorized";
export type TelegramGroupBaseAccessResult =
| { allowed: true }
| { allowed: false; reason: TelegramGroupBaseBlockReason };
function isGroupAllowOverrideAuthorized(params: {
effectiveGroupAllow: NormalizedAllowFrom;
senderId?: string;
senderUsername?: string;
requireSenderForAllowOverride: boolean;
}): boolean {
if (!params.effectiveGroupAllow.hasEntries) {
return false;
}
const senderId = params.senderId ?? "";
if (params.requireSenderForAllowOverride && !senderId) {
return false;
}
return isSenderAllowed({
allow: params.effectiveGroupAllow,
senderId,
senderUsername: params.senderUsername ?? "",
});
}
export const evaluateTelegramGroupBaseAccess = (params: {
isGroup: boolean;
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
hasGroupAllowOverride: boolean;
effectiveGroupAllow: NormalizedAllowFrom;
senderId?: string;
senderUsername?: string;
enforceAllowOverride: boolean;
requireSenderForAllowOverride: boolean;
}): TelegramGroupBaseAccessResult => {
// Check enabled flags for both groups and DMs
if (params.groupConfig?.enabled === false) {
return { allowed: false, reason: "group-disabled" };
}
if (params.topicConfig?.enabled === false) {
return { allowed: false, reason: "topic-disabled" };
}
if (!params.isGroup) {
// For DMs, check allowFrom override if present
if (params.enforceAllowOverride && params.hasGroupAllowOverride) {
if (
!isGroupAllowOverrideAuthorized({
effectiveGroupAllow: params.effectiveGroupAllow,
senderId: params.senderId,
senderUsername: params.senderUsername,
requireSenderForAllowOverride: params.requireSenderForAllowOverride,
})
) {
return { allowed: false, reason: "group-override-unauthorized" };
}
}
return { allowed: true };
}
if (!params.enforceAllowOverride || !params.hasGroupAllowOverride) {
return { allowed: true };
}
if (
!isGroupAllowOverrideAuthorized({
effectiveGroupAllow: params.effectiveGroupAllow,
senderId: params.senderId,
senderUsername: params.senderUsername,
requireSenderForAllowOverride: params.requireSenderForAllowOverride,
})
) {
return { allowed: false, reason: "group-override-unauthorized" };
}
return { allowed: true };
};
export type TelegramGroupPolicyBlockReason =
| "group-policy-disabled"
| "group-policy-allowlist-no-sender"
| "group-policy-allowlist-empty"
| "group-policy-allowlist-unauthorized"
| "group-chat-not-allowed";
export type TelegramGroupPolicyAccessResult =
| { allowed: true; groupPolicy: "open" | "disabled" | "allowlist" }
| {
allowed: false;
reason: TelegramGroupPolicyBlockReason;
groupPolicy: "open" | "disabled" | "allowlist";
};
export const resolveTelegramRuntimeGroupPolicy = (params: {
providerConfigPresent: boolean;
groupPolicy?: TelegramAccountConfig["groupPolicy"];
defaultGroupPolicy?: TelegramAccountConfig["groupPolicy"];
}) =>
resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: params.providerConfigPresent,
groupPolicy: params.groupPolicy,
defaultGroupPolicy: params.defaultGroupPolicy,
});
export const evaluateTelegramGroupPolicyAccess = (params: {
isGroup: boolean;
chatId: string | number;
cfg: OpenClawConfig;
telegramCfg: TelegramAccountConfig;
topicConfig?: TelegramTopicConfig;
groupConfig?: TelegramGroupConfig;
effectiveGroupAllow: NormalizedAllowFrom;
senderId?: string;
senderUsername?: string;
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
enforcePolicy: boolean;
useTopicAndGroupOverrides: boolean;
enforceAllowlistAuthorization: boolean;
allowEmptyAllowlistEntries: boolean;
requireSenderForAllowlistAuthorization: boolean;
checkChatAllowlist: boolean;
}): TelegramGroupPolicyAccessResult => {
const { groupPolicy: runtimeFallbackPolicy } = resolveTelegramRuntimeGroupPolicy({
providerConfigPresent: params.cfg.channels?.telegram !== undefined,
groupPolicy: params.telegramCfg.groupPolicy,
defaultGroupPolicy: params.cfg.channels?.defaults?.groupPolicy,
});
const fallbackPolicy =
firstDefined(params.telegramCfg.groupPolicy, params.cfg.channels?.defaults?.groupPolicy) ??
runtimeFallbackPolicy;
const groupPolicy = params.useTopicAndGroupOverrides
? (firstDefined(
params.topicConfig?.groupPolicy,
params.groupConfig?.groupPolicy,
params.telegramCfg.groupPolicy,
params.cfg.channels?.defaults?.groupPolicy,
) ?? runtimeFallbackPolicy)
: fallbackPolicy;
if (!params.isGroup || !params.enforcePolicy) {
return { allowed: true, groupPolicy };
}
if (groupPolicy === "disabled") {
return { allowed: false, reason: "group-policy-disabled", groupPolicy };
}
// Check chat-level allowlist first so that groups explicitly listed in the
// `groups` config are not blocked by the sender-level "empty allowlist" guard.
let chatExplicitlyAllowed = false;
if (params.checkChatAllowlist) {
const groupAllowlist = params.resolveGroupPolicy(params.chatId);
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
return { allowed: false, reason: "group-chat-not-allowed", groupPolicy };
}
// The chat is explicitly allowed when it has a dedicated entry in the groups
// config (groupConfig is set). A wildcard ("*") match alone does not count
// because it only enables the group — sender-level filtering still applies.
if (groupAllowlist.allowlistEnabled && groupAllowlist.allowed && groupAllowlist.groupConfig) {
chatExplicitlyAllowed = true;
}
}
if (groupPolicy === "allowlist" && params.enforceAllowlistAuthorization) {
const senderId = params.senderId ?? "";
const senderAuthorization = evaluateMatchedGroupAccessForPolicy({
groupPolicy,
requireMatchInput: params.requireSenderForAllowlistAuthorization,
hasMatchInput: Boolean(senderId),
allowlistConfigured:
chatExplicitlyAllowed ||
params.allowEmptyAllowlistEntries ||
params.effectiveGroupAllow.hasEntries,
allowlistMatched:
(chatExplicitlyAllowed && !params.effectiveGroupAllow.hasEntries) ||
isSenderAllowed({
allow: params.effectiveGroupAllow,
senderId,
senderUsername: params.senderUsername ?? "",
}),
});
if (!senderAuthorization.allowed && senderAuthorization.reason === "missing_match_input") {
return { allowed: false, reason: "group-policy-allowlist-no-sender", groupPolicy };
}
if (!senderAuthorization.allowed && senderAuthorization.reason === "empty_allowlist") {
return { allowed: false, reason: "group-policy-allowlist-empty", groupPolicy };
}
if (!senderAuthorization.allowed && senderAuthorization.reason === "not_allowlisted") {
return { allowed: false, reason: "group-policy-allowlist-unauthorized", groupPolicy };
}
}
return { allowed: true, groupPolicy };
};

View File

@@ -0,0 +1,23 @@
import type {
TelegramDirectConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "../../../src/config/types.js";
import { firstDefined } from "./bot-access.js";
export function resolveTelegramGroupPromptSettings(params: {
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
}): {
skillFilter: string[] | undefined;
groupSystemPrompt: string | undefined;
} {
const skillFilter = firstDefined(params.topicConfig?.skills, params.groupConfig?.skills);
const systemPromptParts = [
params.groupConfig?.systemPrompt?.trim() || null,
params.topicConfig?.systemPrompt?.trim() || null,
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
return { skillFilter, groupSystemPrompt };
}

View File

@@ -0,0 +1,89 @@
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { TelegramGroupConfig } from "../../../src/config/types.telegram.js";
import { normalizeAccountId } from "../../../src/routing/session-key.js";
type TelegramGroups = Record<string, TelegramGroupConfig>;
type MigrationScope = "account" | "global";
export type TelegramGroupMigrationResult = {
migrated: boolean;
skippedExisting: boolean;
scopes: MigrationScope[];
};
function resolveAccountGroups(
cfg: OpenClawConfig,
accountId?: string | null,
): { groups?: TelegramGroups } {
if (!accountId) {
return {};
}
const normalized = normalizeAccountId(accountId);
const accounts = cfg.channels?.telegram?.accounts;
if (!accounts || typeof accounts !== "object") {
return {};
}
const exact = accounts[normalized];
if (exact?.groups) {
return { groups: exact.groups };
}
const matchKey = Object.keys(accounts).find(
(key) => key.toLowerCase() === normalized.toLowerCase(),
);
return { groups: matchKey ? accounts[matchKey]?.groups : undefined };
}
export function migrateTelegramGroupsInPlace(
groups: TelegramGroups | undefined,
oldChatId: string,
newChatId: string,
): { migrated: boolean; skippedExisting: boolean } {
if (!groups) {
return { migrated: false, skippedExisting: false };
}
if (oldChatId === newChatId) {
return { migrated: false, skippedExisting: false };
}
if (!Object.hasOwn(groups, oldChatId)) {
return { migrated: false, skippedExisting: false };
}
if (Object.hasOwn(groups, newChatId)) {
return { migrated: false, skippedExisting: true };
}
groups[newChatId] = groups[oldChatId];
delete groups[oldChatId];
return { migrated: true, skippedExisting: false };
}
export function migrateTelegramGroupConfig(params: {
cfg: OpenClawConfig;
accountId?: string | null;
oldChatId: string;
newChatId: string;
}): TelegramGroupMigrationResult {
const scopes: MigrationScope[] = [];
let migrated = false;
let skippedExisting = false;
const migrationTargets: Array<{
scope: MigrationScope;
groups: TelegramGroups | undefined;
}> = [
{ scope: "account", groups: resolveAccountGroups(params.cfg, params.accountId).groups },
{ scope: "global", groups: params.cfg.channels?.telegram?.groups },
];
for (const target of migrationTargets) {
const result = migrateTelegramGroupsInPlace(target.groups, params.oldChatId, params.newChatId);
if (result.migrated) {
migrated = true;
scopes.push(target.scope);
}
if (result.skippedExisting) {
skippedExisting = true;
}
}
return { migrated, skippedExisting, scopes };
}

View File

@@ -0,0 +1,67 @@
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { TelegramInlineButtonsScope } from "../../../src/config/types.telegram.js";
import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js";
const DEFAULT_INLINE_BUTTONS_SCOPE: TelegramInlineButtonsScope = "allowlist";
function normalizeInlineButtonsScope(value: unknown): TelegramInlineButtonsScope | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim().toLowerCase();
if (
trimmed === "off" ||
trimmed === "dm" ||
trimmed === "group" ||
trimmed === "all" ||
trimmed === "allowlist"
) {
return trimmed as TelegramInlineButtonsScope;
}
return undefined;
}
function resolveInlineButtonsScopeFromCapabilities(
capabilities: unknown,
): TelegramInlineButtonsScope {
if (!capabilities) {
return DEFAULT_INLINE_BUTTONS_SCOPE;
}
if (Array.isArray(capabilities)) {
const enabled = capabilities.some(
(entry) => String(entry).trim().toLowerCase() === "inlinebuttons",
);
return enabled ? "all" : "off";
}
if (typeof capabilities === "object") {
const inlineButtons = (capabilities as { inlineButtons?: unknown }).inlineButtons;
return normalizeInlineButtonsScope(inlineButtons) ?? DEFAULT_INLINE_BUTTONS_SCOPE;
}
return DEFAULT_INLINE_BUTTONS_SCOPE;
}
export function resolveTelegramInlineButtonsScope(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): TelegramInlineButtonsScope {
const account = resolveTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
return resolveInlineButtonsScopeFromCapabilities(account.config.capabilities);
}
export function isTelegramInlineButtonsEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
if (params.accountId) {
return resolveTelegramInlineButtonsScope(params) !== "off";
}
const accountIds = listTelegramAccountIds(params.cfg);
if (accountIds.length === 0) {
return resolveTelegramInlineButtonsScope(params) !== "off";
}
return accountIds.some(
(accountId) => resolveTelegramInlineButtonsScope({ cfg: params.cfg, accountId }) !== "off",
);
}
export { resolveTelegramTargetChatType } from "./targets.js";

View File

@@ -0,0 +1,32 @@
export type LaneDeliverySnapshot = {
delivered: boolean;
skippedNonSilent: number;
failedNonSilent: number;
};
export type LaneDeliveryStateTracker = {
markDelivered: () => void;
markNonSilentSkip: () => void;
markNonSilentFailure: () => void;
snapshot: () => LaneDeliverySnapshot;
};
export function createLaneDeliveryStateTracker(): LaneDeliveryStateTracker {
const state: LaneDeliverySnapshot = {
delivered: false,
skippedNonSilent: 0,
failedNonSilent: 0,
};
return {
markDelivered: () => {
state.delivered = true;
},
markNonSilentSkip: () => {
state.skippedNonSilent += 1;
},
markNonSilentFailure: () => {
state.failedNonSilent += 1;
},
snapshot: () => ({ ...state }),
};
}

View File

@@ -0,0 +1,574 @@
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
import type { TelegramInlineButtons } from "./button-types.js";
import type { TelegramDraftStream } from "./draft-stream.js";
import {
isRecoverableTelegramNetworkError,
isSafeToRetrySendError,
isTelegramClientRejection,
} from "./network-errors.js";
const MESSAGE_NOT_MODIFIED_RE =
/400:\s*Bad Request:\s*message is not modified|MESSAGE_NOT_MODIFIED/i;
const MESSAGE_NOT_FOUND_RE =
/400:\s*Bad Request:\s*message to edit not found|MESSAGE_ID_INVALID|message can't be edited/i;
function extractErrorText(err: unknown): string {
return typeof err === "string"
? err
: err instanceof Error
? err.message
: typeof err === "object" && err && "description" in err
? typeof err.description === "string"
? err.description
: ""
: "";
}
function isMessageNotModifiedError(err: unknown): boolean {
return MESSAGE_NOT_MODIFIED_RE.test(extractErrorText(err));
}
/**
* Returns true when Telegram rejects an edit because the target message can no
* longer be resolved or edited. The caller still needs preview context to
* decide whether to retain a different visible preview or fall back to send.
*/
function isMissingPreviewMessageError(err: unknown): boolean {
return MESSAGE_NOT_FOUND_RE.test(extractErrorText(err));
}
export type LaneName = "answer" | "reasoning";
export type DraftLaneState = {
stream: TelegramDraftStream | undefined;
lastPartialText: string;
hasStreamedMessage: boolean;
};
export type ArchivedPreview = {
messageId: number;
textSnapshot: string;
// Boundary-finalized previews should remain visible even if no matching
// final edit arrives; superseded previews can be safely deleted.
deleteIfUnused?: boolean;
};
export type LanePreviewLifecycle = "transient" | "complete";
export type LaneDeliveryResult =
| "preview-finalized"
| "preview-retained"
| "preview-updated"
| "sent"
| "skipped";
type CreateLaneTextDelivererParams = {
lanes: Record<LaneName, DraftLaneState>;
archivedAnswerPreviews: ArchivedPreview[];
activePreviewLifecycleByLane: Record<LaneName, LanePreviewLifecycle>;
retainPreviewOnCleanupByLane: Record<LaneName, boolean>;
draftMaxChars: number;
applyTextToPayload: (payload: ReplyPayload, text: string) => ReplyPayload;
sendPayload: (payload: ReplyPayload) => Promise<boolean>;
flushDraftLane: (lane: DraftLaneState) => Promise<void>;
stopDraftLane: (lane: DraftLaneState) => Promise<void>;
editPreview: (params: {
laneName: LaneName;
messageId: number;
text: string;
context: "final" | "update";
previewButtons?: TelegramInlineButtons;
}) => Promise<void>;
deletePreviewMessage: (messageId: number) => Promise<void>;
log: (message: string) => void;
markDelivered: () => void;
};
type DeliverLaneTextParams = {
laneName: LaneName;
text: string;
payload: ReplyPayload;
infoKind: string;
previewButtons?: TelegramInlineButtons;
allowPreviewUpdateForNonFinal?: boolean;
};
type TryUpdatePreviewParams = {
lane: DraftLaneState;
laneName: LaneName;
text: string;
previewButtons?: TelegramInlineButtons;
stopBeforeEdit?: boolean;
updateLaneSnapshot?: boolean;
skipRegressive: "always" | "existingOnly";
context: "final" | "update";
previewMessageId?: number;
previewTextSnapshot?: string;
};
type PreviewEditResult = "edited" | "retained" | "fallback";
type ConsumeArchivedAnswerPreviewParams = {
lane: DraftLaneState;
text: string;
payload: ReplyPayload;
previewButtons?: TelegramInlineButtons;
canEditViaPreview: boolean;
};
type PreviewUpdateContext = "final" | "update";
type RegressiveSkipMode = "always" | "existingOnly";
type ResolvePreviewTargetParams = {
lane: DraftLaneState;
previewMessageIdOverride?: number;
stopBeforeEdit: boolean;
context: PreviewUpdateContext;
};
type PreviewTargetResolution = {
hadPreviewMessage: boolean;
previewMessageId: number | undefined;
stopCreatesFirstPreview: boolean;
};
function shouldSkipRegressivePreviewUpdate(args: {
currentPreviewText: string | undefined;
text: string;
skipRegressive: RegressiveSkipMode;
hadPreviewMessage: boolean;
}): boolean {
const currentPreviewText = args.currentPreviewText;
if (currentPreviewText === undefined) {
return false;
}
return (
currentPreviewText.startsWith(args.text) &&
args.text.length < currentPreviewText.length &&
(args.skipRegressive === "always" || args.hadPreviewMessage)
);
}
function resolvePreviewTarget(params: ResolvePreviewTargetParams): PreviewTargetResolution {
const lanePreviewMessageId = params.lane.stream?.messageId();
const previewMessageId =
typeof params.previewMessageIdOverride === "number"
? params.previewMessageIdOverride
: lanePreviewMessageId;
const hadPreviewMessage =
typeof params.previewMessageIdOverride === "number" || typeof lanePreviewMessageId === "number";
return {
hadPreviewMessage,
previewMessageId: typeof previewMessageId === "number" ? previewMessageId : undefined,
stopCreatesFirstPreview:
params.stopBeforeEdit && !hadPreviewMessage && params.context === "final",
};
}
export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
const getLanePreviewText = (lane: DraftLaneState) => lane.lastPartialText;
const markActivePreviewComplete = (laneName: LaneName) => {
params.activePreviewLifecycleByLane[laneName] = "complete";
params.retainPreviewOnCleanupByLane[laneName] = true;
};
const isDraftPreviewLane = (lane: DraftLaneState) => lane.stream?.previewMode?.() === "draft";
const canMaterializeDraftFinal = (
lane: DraftLaneState,
previewButtons?: TelegramInlineButtons,
) => {
const hasPreviewButtons = Boolean(previewButtons && previewButtons.length > 0);
return (
isDraftPreviewLane(lane) &&
!hasPreviewButtons &&
typeof lane.stream?.materialize === "function"
);
};
const tryMaterializeDraftPreviewForFinal = async (args: {
lane: DraftLaneState;
laneName: LaneName;
text: string;
}): Promise<boolean> => {
const stream = args.lane.stream;
if (!stream || !isDraftPreviewLane(args.lane)) {
return false;
}
// Draft previews have no message_id to edit; materialize the final text
// into a real message and treat that as the finalized delivery.
stream.update(args.text);
const materializedMessageId = await stream.materialize?.();
if (typeof materializedMessageId !== "number") {
params.log(
`telegram: ${args.laneName} draft preview materialize produced no message id; falling back to standard send`,
);
return false;
}
args.lane.lastPartialText = args.text;
params.markDelivered();
return true;
};
const tryEditPreviewMessage = async (args: {
laneName: LaneName;
messageId: number;
text: string;
context: "final" | "update";
previewButtons?: TelegramInlineButtons;
updateLaneSnapshot: boolean;
lane: DraftLaneState;
finalTextAlreadyLanded: boolean;
retainAlternatePreviewOnMissingTarget: boolean;
}): Promise<PreviewEditResult> => {
try {
await params.editPreview({
laneName: args.laneName,
messageId: args.messageId,
text: args.text,
previewButtons: args.previewButtons,
context: args.context,
});
if (args.updateLaneSnapshot) {
args.lane.lastPartialText = args.text;
}
params.markDelivered();
return "edited";
} catch (err) {
if (isMessageNotModifiedError(err)) {
params.log(
`telegram: ${args.laneName} preview ${args.context} edit returned "message is not modified"; treating as delivered`,
);
params.markDelivered();
return "edited";
}
if (args.context === "final") {
if (args.finalTextAlreadyLanded) {
params.log(
`telegram: ${args.laneName} preview final edit failed after stop flush; keeping existing preview (${String(err)})`,
);
params.markDelivered();
return "retained";
}
if (isSafeToRetrySendError(err)) {
params.log(
`telegram: ${args.laneName} preview final edit failed before reaching Telegram; falling back to standard send (${String(err)})`,
);
return "fallback";
}
if (isMissingPreviewMessageError(err)) {
if (args.retainAlternatePreviewOnMissingTarget) {
params.log(
`telegram: ${args.laneName} preview final edit target missing; keeping alternate preview without fallback (${String(err)})`,
);
params.markDelivered();
return "retained";
}
params.log(
`telegram: ${args.laneName} preview final edit target missing with no alternate preview; falling back to standard send (${String(err)})`,
);
return "fallback";
}
if (isRecoverableTelegramNetworkError(err, { allowMessageMatch: true })) {
params.log(
`telegram: ${args.laneName} preview final edit may have landed despite network error; keeping existing preview (${String(err)})`,
);
params.markDelivered();
return "retained";
}
if (isTelegramClientRejection(err)) {
params.log(
`telegram: ${args.laneName} preview final edit rejected by Telegram (client error); falling back to standard send (${String(err)})`,
);
return "fallback";
}
// Default: ambiguous error — prefer incomplete over duplicate
params.log(
`telegram: ${args.laneName} preview final edit failed with ambiguous error; keeping existing preview to avoid duplicate (${String(err)})`,
);
params.markDelivered();
return "retained";
}
params.log(
`telegram: ${args.laneName} preview ${args.context} edit failed; falling back to standard send (${String(err)})`,
);
return "fallback";
}
};
const tryUpdatePreviewForLane = async ({
lane,
laneName,
text,
previewButtons,
stopBeforeEdit = false,
updateLaneSnapshot = false,
skipRegressive,
context,
previewMessageId: previewMessageIdOverride,
previewTextSnapshot,
}: TryUpdatePreviewParams): Promise<PreviewEditResult> => {
const editPreview = (
messageId: number,
finalTextAlreadyLanded: boolean,
retainAlternatePreviewOnMissingTarget: boolean,
) =>
tryEditPreviewMessage({
laneName,
messageId,
text,
context,
previewButtons,
updateLaneSnapshot,
lane,
finalTextAlreadyLanded,
retainAlternatePreviewOnMissingTarget,
});
const finalizePreview = (
previewMessageId: number,
finalTextAlreadyLanded: boolean,
hadPreviewMessage: boolean,
retainAlternatePreviewOnMissingTarget = false,
): PreviewEditResult | Promise<PreviewEditResult> => {
const currentPreviewText = previewTextSnapshot ?? getLanePreviewText(lane);
const shouldSkipRegressive = shouldSkipRegressivePreviewUpdate({
currentPreviewText,
text,
skipRegressive,
hadPreviewMessage,
});
if (shouldSkipRegressive) {
params.markDelivered();
return "edited";
}
return editPreview(
previewMessageId,
finalTextAlreadyLanded,
retainAlternatePreviewOnMissingTarget,
);
};
if (!lane.stream) {
return "fallback";
}
const previewTargetBeforeStop = resolvePreviewTarget({
lane,
previewMessageIdOverride,
stopBeforeEdit,
context,
});
if (previewTargetBeforeStop.stopCreatesFirstPreview) {
// Final stop() can create the first visible preview message.
// Prime pending text so the stop flush sends the final text snapshot.
lane.stream.update(text);
await params.stopDraftLane(lane);
const previewTargetAfterStop = resolvePreviewTarget({
lane,
stopBeforeEdit: false,
context,
});
if (typeof previewTargetAfterStop.previewMessageId !== "number") {
return "fallback";
}
return finalizePreview(previewTargetAfterStop.previewMessageId, true, false);
}
if (stopBeforeEdit) {
await params.stopDraftLane(lane);
}
const previewTargetAfterStop = resolvePreviewTarget({
lane,
previewMessageIdOverride,
stopBeforeEdit: false,
context,
});
if (typeof previewTargetAfterStop.previewMessageId !== "number") {
// Only retain for final delivery when a prior preview is already visible
// to the user — otherwise falling back is safer than silence. For updates,
// always fall back so the caller can attempt sendPayload without stale
// markDelivered() state.
if (context === "final" && lane.hasStreamedMessage && lane.stream?.sendMayHaveLanded?.()) {
params.log(
`telegram: ${laneName} preview send may have landed despite missing message id; keeping to avoid duplicate`,
);
params.markDelivered();
return "retained";
}
return "fallback";
}
const activePreviewMessageId = lane.stream?.messageId();
return finalizePreview(
previewTargetAfterStop.previewMessageId,
false,
previewTargetAfterStop.hadPreviewMessage,
typeof activePreviewMessageId === "number" &&
activePreviewMessageId !== previewTargetAfterStop.previewMessageId,
);
};
const consumeArchivedAnswerPreviewForFinal = async ({
lane,
text,
payload,
previewButtons,
canEditViaPreview,
}: ConsumeArchivedAnswerPreviewParams): Promise<LaneDeliveryResult | undefined> => {
const archivedPreview = params.archivedAnswerPreviews.shift();
if (!archivedPreview) {
return undefined;
}
if (canEditViaPreview) {
const finalized = await tryUpdatePreviewForLane({
lane,
laneName: "answer",
text,
previewButtons,
stopBeforeEdit: false,
skipRegressive: "existingOnly",
context: "final",
previewMessageId: archivedPreview.messageId,
previewTextSnapshot: archivedPreview.textSnapshot,
});
if (finalized === "edited") {
return "preview-finalized";
}
if (finalized === "retained") {
params.retainPreviewOnCleanupByLane.answer = true;
return "preview-retained";
}
}
// Send the replacement message first, then clean up the old preview.
// This avoids the visual "disappear then reappear" flash.
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
// Once this archived preview is consumed by a fallback final send, delete it
// regardless of deleteIfUnused. That flag only applies to unconsumed boundaries.
if (delivered || archivedPreview.deleteIfUnused !== false) {
try {
await params.deletePreviewMessage(archivedPreview.messageId);
} catch (err) {
params.log(
`telegram: archived answer preview cleanup failed (${archivedPreview.messageId}): ${String(err)}`,
);
}
}
return delivered ? "sent" : "skipped";
};
return async ({
laneName,
text,
payload,
infoKind,
previewButtons,
allowPreviewUpdateForNonFinal = false,
}: DeliverLaneTextParams): Promise<LaneDeliveryResult> => {
const lane = params.lanes[laneName];
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const canEditViaPreview =
!hasMedia && text.length > 0 && text.length <= params.draftMaxChars && !payload.isError;
if (infoKind === "final") {
// Transient previews must decide cleanup retention per final attempt.
// Completed previews intentionally stay retained so later extra payloads
// do not clear the already-finalized message.
if (params.activePreviewLifecycleByLane[laneName] === "transient") {
params.retainPreviewOnCleanupByLane[laneName] = false;
}
if (laneName === "answer") {
const archivedResult = await consumeArchivedAnswerPreviewForFinal({
lane,
text,
payload,
previewButtons,
canEditViaPreview,
});
if (archivedResult) {
return archivedResult;
}
}
if (canEditViaPreview && params.activePreviewLifecycleByLane[laneName] === "transient") {
await params.flushDraftLane(lane);
if (laneName === "answer") {
const archivedResultAfterFlush = await consumeArchivedAnswerPreviewForFinal({
lane,
text,
payload,
previewButtons,
canEditViaPreview,
});
if (archivedResultAfterFlush) {
return archivedResultAfterFlush;
}
}
if (canMaterializeDraftFinal(lane, previewButtons)) {
const materialized = await tryMaterializeDraftPreviewForFinal({
lane,
laneName,
text,
});
if (materialized) {
markActivePreviewComplete(laneName);
return "preview-finalized";
}
}
const finalized = await tryUpdatePreviewForLane({
lane,
laneName,
text,
previewButtons,
stopBeforeEdit: true,
skipRegressive: "existingOnly",
context: "final",
});
if (finalized === "edited") {
markActivePreviewComplete(laneName);
return "preview-finalized";
}
if (finalized === "retained") {
markActivePreviewComplete(laneName);
return "preview-retained";
}
} else if (!hasMedia && !payload.isError && text.length > params.draftMaxChars) {
params.log(
`telegram: preview final too long for edit (${text.length} > ${params.draftMaxChars}); falling back to standard send`,
);
}
await params.stopDraftLane(lane);
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
return delivered ? "sent" : "skipped";
}
if (allowPreviewUpdateForNonFinal && canEditViaPreview) {
if (isDraftPreviewLane(lane)) {
// DM draft flow has no message_id to edit; updates are sent via sendMessageDraft.
// Only mark as updated when the draft flush actually emits an update.
const previewRevisionBeforeFlush = lane.stream?.previewRevision?.() ?? 0;
lane.stream?.update(text);
await params.flushDraftLane(lane);
const previewUpdated = (lane.stream?.previewRevision?.() ?? 0) > previewRevisionBeforeFlush;
if (!previewUpdated) {
params.log(
`telegram: ${laneName} draft preview update not emitted; falling back to standard send`,
);
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
return delivered ? "sent" : "skipped";
}
lane.lastPartialText = text;
params.markDelivered();
return "preview-updated";
}
const updated = await tryUpdatePreviewForLane({
lane,
laneName,
text,
previewButtons,
stopBeforeEdit: false,
updateLaneSnapshot: true,
skipRegressive: "always",
context: "update",
});
if (updated === "edited") {
return "preview-updated";
}
}
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
return delivered ? "sent" : "skipped";
};
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { ReplyPayload } from "../auto-reply/types.js";
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
import { createTestDraftStream } from "./draft-stream.test-helpers.js";
import { createLaneTextDeliverer, type DraftLaneState, type LaneName } from "./lane-delivery.js";

View File

@@ -0,0 +1,13 @@
export {
type ArchivedPreview,
createLaneTextDeliverer,
type DraftLaneState,
type LaneDeliveryResult,
type LaneName,
type LanePreviewLifecycle,
} from "./lane-delivery-text-deliverer.js";
export {
createLaneDeliveryStateTracker,
type LaneDeliverySnapshot,
type LaneDeliveryStateTracker,
} from "./lane-delivery-state.js";

View File

@@ -0,0 +1,284 @@
/**
* Telegram inline button utilities for model selection.
*
* Callback data patterns (max 64 bytes for Telegram):
* - mdl_prov - show providers list
* - mdl_list_{prov}_{pg} - show models for provider (page N, 1-indexed)
* - mdl_sel_{provider/id} - select model (standard)
* - mdl_sel/{model} - select model (compact fallback when standard is >64 bytes)
* - mdl_back - back to providers list
*/
export type ButtonRow = Array<{ text: string; callback_data: string }>;
export type ParsedModelCallback =
| { type: "providers" }
| { type: "list"; provider: string; page: number }
| { type: "select"; provider?: string; model: string }
| { type: "back" };
export type ProviderInfo = {
id: string;
count: number;
};
export type ResolveModelSelectionResult =
| { kind: "resolved"; provider: string; model: string }
| { kind: "ambiguous"; model: string; matchingProviders: string[] };
export type ModelsKeyboardParams = {
provider: string;
models: readonly string[];
currentModel?: string;
currentPage: number;
totalPages: number;
pageSize?: number;
};
const MODELS_PAGE_SIZE = 8;
const MAX_CALLBACK_DATA_BYTES = 64;
const CALLBACK_PREFIX = {
providers: "mdl_prov",
back: "mdl_back",
list: "mdl_list_",
selectStandard: "mdl_sel_",
selectCompact: "mdl_sel/",
} as const;
/**
* Parse a model callback_data string into a structured object.
* Returns null if the data doesn't match a known pattern.
*/
export function parseModelCallbackData(data: string): ParsedModelCallback | null {
const trimmed = data.trim();
if (!trimmed.startsWith("mdl_")) {
return null;
}
if (trimmed === CALLBACK_PREFIX.providers || trimmed === CALLBACK_PREFIX.back) {
return { type: trimmed === CALLBACK_PREFIX.providers ? "providers" : "back" };
}
// mdl_list_{provider}_{page}
const listMatch = trimmed.match(/^mdl_list_([a-z0-9_-]+)_(\d+)$/i);
if (listMatch) {
const [, provider, pageStr] = listMatch;
const page = Number.parseInt(pageStr ?? "1", 10);
if (provider && Number.isFinite(page) && page >= 1) {
return { type: "list", provider, page };
}
}
// mdl_sel/{model} (compact fallback)
const compactSelMatch = trimmed.match(/^mdl_sel\/(.+)$/);
if (compactSelMatch) {
const modelRef = compactSelMatch[1];
if (modelRef) {
return {
type: "select",
model: modelRef,
};
}
}
// mdl_sel_{provider/model}
const selMatch = trimmed.match(/^mdl_sel_(.+)$/);
if (selMatch) {
const modelRef = selMatch[1];
if (modelRef) {
const slashIndex = modelRef.indexOf("/");
if (slashIndex > 0 && slashIndex < modelRef.length - 1) {
return {
type: "select",
provider: modelRef.slice(0, slashIndex),
model: modelRef.slice(slashIndex + 1),
};
}
}
}
return null;
}
export function buildModelSelectionCallbackData(params: {
provider: string;
model: string;
}): string | null {
const fullCallbackData = `${CALLBACK_PREFIX.selectStandard}${params.provider}/${params.model}`;
if (Buffer.byteLength(fullCallbackData, "utf8") <= MAX_CALLBACK_DATA_BYTES) {
return fullCallbackData;
}
const compactCallbackData = `${CALLBACK_PREFIX.selectCompact}${params.model}`;
return Buffer.byteLength(compactCallbackData, "utf8") <= MAX_CALLBACK_DATA_BYTES
? compactCallbackData
: null;
}
export function resolveModelSelection(params: {
callback: Extract<ParsedModelCallback, { type: "select" }>;
providers: readonly string[];
byProvider: ReadonlyMap<string, ReadonlySet<string>>;
}): ResolveModelSelectionResult {
if (params.callback.provider) {
return {
kind: "resolved",
provider: params.callback.provider,
model: params.callback.model,
};
}
const matchingProviders = params.providers.filter((id) =>
params.byProvider.get(id)?.has(params.callback.model),
);
if (matchingProviders.length === 1) {
return {
kind: "resolved",
provider: matchingProviders[0],
model: params.callback.model,
};
}
return {
kind: "ambiguous",
model: params.callback.model,
matchingProviders,
};
}
/**
* Build provider selection keyboard with 2 providers per row.
*/
export function buildProviderKeyboard(providers: ProviderInfo[]): ButtonRow[] {
if (providers.length === 0) {
return [];
}
const rows: ButtonRow[] = [];
let currentRow: ButtonRow = [];
for (const provider of providers) {
const button = {
text: `${provider.id} (${provider.count})`,
callback_data: `mdl_list_${provider.id}_1`,
};
currentRow.push(button);
if (currentRow.length === 2) {
rows.push(currentRow);
currentRow = [];
}
}
// Push any remaining button
if (currentRow.length > 0) {
rows.push(currentRow);
}
return rows;
}
/**
* Build model list keyboard with pagination and back button.
*/
export function buildModelsKeyboard(params: ModelsKeyboardParams): ButtonRow[] {
const { provider, models, currentModel, currentPage, totalPages } = params;
const pageSize = params.pageSize ?? MODELS_PAGE_SIZE;
if (models.length === 0) {
return [[{ text: "<< Back", callback_data: CALLBACK_PREFIX.back }]];
}
const rows: ButtonRow[] = [];
// Calculate page slice
const startIndex = (currentPage - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize, models.length);
const pageModels = models.slice(startIndex, endIndex);
// Model buttons - one per row
const currentModelId = currentModel?.includes("/")
? currentModel.split("/").slice(1).join("/")
: currentModel;
for (const model of pageModels) {
const callbackData = buildModelSelectionCallbackData({ provider, model });
// Skip models that still exceed Telegram's callback_data limit.
if (!callbackData) {
continue;
}
const isCurrentModel = model === currentModelId;
const displayText = truncateModelId(model, 38);
const text = isCurrentModel ? `${displayText}` : displayText;
rows.push([
{
text,
callback_data: callbackData,
},
]);
}
// Pagination row
if (totalPages > 1) {
const paginationRow: ButtonRow = [];
if (currentPage > 1) {
paginationRow.push({
text: "◀ Prev",
callback_data: `${CALLBACK_PREFIX.list}${provider}_${currentPage - 1}`,
});
}
paginationRow.push({
text: `${currentPage}/${totalPages}`,
callback_data: `${CALLBACK_PREFIX.list}${provider}_${currentPage}`, // noop
});
if (currentPage < totalPages) {
paginationRow.push({
text: "Next ▶",
callback_data: `${CALLBACK_PREFIX.list}${provider}_${currentPage + 1}`,
});
}
rows.push(paginationRow);
}
// Back button
rows.push([{ text: "<< Back", callback_data: CALLBACK_PREFIX.back }]);
return rows;
}
/**
* Build "Browse providers" button for /model summary.
*/
export function buildBrowseProvidersButton(): ButtonRow[] {
return [[{ text: "Browse providers", callback_data: CALLBACK_PREFIX.providers }]];
}
/**
* Truncate model ID for display, preserving end if too long.
*/
function truncateModelId(modelId: string, maxLen: number): string {
if (modelId.length <= maxLen) {
return modelId;
}
// Show last part with ellipsis prefix
return `${modelId.slice(-(maxLen - 1))}`;
}
/**
* Get page size for model list pagination.
*/
export function getModelsPageSize(): number {
return MODELS_PAGE_SIZE;
}
/**
* Calculate total pages for a model list.
*/
export function calculateTotalPages(totalModels: number, pageSize?: number): number {
const size = pageSize ?? MODELS_PAGE_SIZE;
return size > 0 ? Math.ceil(totalModels / size) : 1;
}

View File

@@ -209,8 +209,8 @@ async function monitorWithAutoAbort(
});
}
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
vi.mock("../../../src/config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
return {
...actual,
loadConfig,
@@ -254,12 +254,12 @@ vi.mock("@grammyjs/runner", () => ({
run: runSpy,
}));
vi.mock("../infra/backoff.js", () => ({
vi.mock("../../../src/infra/backoff.js", () => ({
computeBackoff,
sleepWithAbort,
}));
vi.mock("../infra/unhandled-rejections.js", () => ({
vi.mock("../../../src/infra/unhandled-rejections.js", () => ({
registerUnhandledRejectionHandler: registerUnhandledRejectionHandlerMock,
}));
@@ -272,7 +272,7 @@ vi.mock("./update-offset-store.js", () => ({
writeTelegramUpdateOffset: vi.fn(async () => undefined),
}));
vi.mock("../auto-reply/reply.js", () => ({
vi.mock("../../../src/auto-reply/reply.js", () => ({
getReplyFromConfig: async (ctx: { Body?: string }) => ({
text: `echo:${ctx.Body}`,
}),

Some files were not shown because too many files have changed in this diff Show More