mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 01:22:57 +00:00
583 lines
19 KiB
TypeScript
583 lines
19 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import type { LanguageCode } from "@grammyjs/types";
|
|
import type { Bot } from "grammy";
|
|
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
|
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
|
import {
|
|
normalizeOptionalString,
|
|
readStringValue,
|
|
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
|
import { normalizeTelegramCommandName, TELEGRAM_COMMAND_NAME_PATTERN } from "./command-config.js";
|
|
|
|
const TELEGRAM_MAX_COMMANDS = 100;
|
|
export const TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET = 5700;
|
|
const TELEGRAM_COMMAND_RETRY_RATIO = 0.8;
|
|
const TELEGRAM_MIN_COMMAND_DESCRIPTION_LENGTH = 1;
|
|
const TELEGRAM_MAX_COMMAND_DESCRIPTION_LENGTH = 256;
|
|
const TELEGRAM_MENU_RESULT_CACHE_MAX = 128;
|
|
|
|
export type TelegramMenuCommand = {
|
|
command: string;
|
|
description: string;
|
|
descriptionLocalizations?: Record<string, string>;
|
|
};
|
|
|
|
type TelegramCommandMenuScope =
|
|
| { label: "default"; options?: undefined }
|
|
| { label: "all_group_chats"; options: { scope: { type: "all_group_chats" } } };
|
|
|
|
type TelegramPluginCommandSpec = {
|
|
name: unknown;
|
|
description: unknown;
|
|
descriptionLocalizations?: Record<string, string>;
|
|
};
|
|
|
|
const TELEGRAM_COMMAND_MENU_SCOPES: readonly TelegramCommandMenuScope[] = [
|
|
{ label: "default" },
|
|
{ label: "all_group_chats", options: { scope: { type: "all_group_chats" } } },
|
|
];
|
|
|
|
const cappedTelegramMenuCache = new Map<
|
|
string,
|
|
ReturnType<typeof buildUncachedCappedTelegramMenuCommands>
|
|
>();
|
|
|
|
function countTelegramCommandText(value: string): number {
|
|
let count = 0;
|
|
for (let index = 0; index < value.length; ) {
|
|
const codePoint = value.codePointAt(index);
|
|
index += codePoint && codePoint > 0xffff ? 2 : 1;
|
|
count += 1;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function truncateTelegramCommandText(value: string, maxLength: number): string {
|
|
if (maxLength <= 0) {
|
|
return "";
|
|
}
|
|
|
|
const suffix = maxLength > 1 ? "…" : "";
|
|
const prefixLimit = maxLength - countTelegramCommandText(suffix);
|
|
let count = 0;
|
|
let prefixEnd = 0;
|
|
for (const char of value) {
|
|
count += 1;
|
|
if (count <= prefixLimit) {
|
|
prefixEnd += char.length;
|
|
}
|
|
if (count > maxLength) {
|
|
return `${value.slice(0, prefixEnd)}${suffix}`;
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function fitTelegramCommandsWithinTextBudget(
|
|
commands: TelegramMenuCommand[],
|
|
maxTotalChars: number,
|
|
): {
|
|
commands: TelegramMenuCommand[];
|
|
descriptionTrimmed: boolean;
|
|
textBudgetDropCount: number;
|
|
} {
|
|
let candidateCommands = [...commands];
|
|
while (candidateCommands.length > 0) {
|
|
const commandNameChars = candidateCommands.reduce(
|
|
(total, command) => total + countTelegramCommandText(command.command),
|
|
0,
|
|
);
|
|
const descriptionBudget = maxTotalChars - commandNameChars;
|
|
const minimumDescriptionBudget =
|
|
candidateCommands.length * TELEGRAM_MIN_COMMAND_DESCRIPTION_LENGTH;
|
|
if (descriptionBudget < minimumDescriptionBudget) {
|
|
candidateCommands = candidateCommands.slice(0, -1);
|
|
continue;
|
|
}
|
|
|
|
const descriptionCap = Math.max(
|
|
TELEGRAM_MIN_COMMAND_DESCRIPTION_LENGTH,
|
|
Math.floor(descriptionBudget / candidateCommands.length),
|
|
);
|
|
let descriptionTrimmed = false;
|
|
const fittedCommands = candidateCommands.map((command) => {
|
|
const description = truncateTelegramCommandText(
|
|
command.description,
|
|
Math.min(descriptionCap, TELEGRAM_MAX_COMMAND_DESCRIPTION_LENGTH),
|
|
);
|
|
if (description !== command.description) {
|
|
descriptionTrimmed = true;
|
|
return Object.assign({}, command, { description });
|
|
}
|
|
return command;
|
|
});
|
|
return {
|
|
commands: fittedCommands,
|
|
descriptionTrimmed,
|
|
textBudgetDropCount: commands.length - fittedCommands.length,
|
|
};
|
|
}
|
|
|
|
return {
|
|
commands: [],
|
|
descriptionTrimmed: false,
|
|
textBudgetDropCount: commands.length,
|
|
};
|
|
}
|
|
|
|
function readErrorTextField(value: unknown, key: "description" | "message"): string | undefined {
|
|
if (!value || typeof value !== "object" || !(key in value)) {
|
|
return undefined;
|
|
}
|
|
return readStringValue((value as Record<"description" | "message", unknown>)[key]);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
const description = readErrorTextField(err, "description");
|
|
if (description && pattern.test(description)) {
|
|
return true;
|
|
}
|
|
const message = readErrorTextField(err, "message");
|
|
if (message && pattern.test(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 = normalizeOptionalString(spec.description) ?? "";
|
|
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);
|
|
const menuCommand: TelegramMenuCommand = { command: normalized, description };
|
|
if (spec.descriptionLocalizations) {
|
|
menuCommand.descriptionLocalizations = spec.descriptionLocalizations;
|
|
}
|
|
commands.push(menuCommand);
|
|
}
|
|
|
|
return { commands, issues };
|
|
}
|
|
|
|
export function buildCappedTelegramMenuCommands(params: {
|
|
allCommands: TelegramMenuCommand[];
|
|
maxCommands?: number;
|
|
maxTotalChars?: number;
|
|
}): ReturnType<typeof buildUncachedCappedTelegramMenuCommands> {
|
|
const maxCommands = params.maxCommands ?? TELEGRAM_MAX_COMMANDS;
|
|
const maxTotalChars = params.maxTotalChars ?? TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET;
|
|
const cacheKey = buildTelegramMenuResultCacheKey({
|
|
allCommands: params.allCommands,
|
|
maxCommands,
|
|
maxTotalChars,
|
|
});
|
|
const cached = cappedTelegramMenuCache.get(cacheKey);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
const result = buildUncachedCappedTelegramMenuCommands({
|
|
allCommands: params.allCommands,
|
|
maxCommands,
|
|
maxTotalChars,
|
|
});
|
|
rememberCappedTelegramMenuResult(cacheKey, result);
|
|
return result;
|
|
}
|
|
|
|
function buildUncachedCappedTelegramMenuCommands(params: {
|
|
allCommands: TelegramMenuCommand[];
|
|
maxCommands: number;
|
|
maxTotalChars: number;
|
|
}): {
|
|
commandsToRegister: TelegramMenuCommand[];
|
|
totalCommands: number;
|
|
maxCommands: number;
|
|
overflowCount: number;
|
|
maxTotalChars: number;
|
|
descriptionTrimmed: boolean;
|
|
textBudgetDropCount: number;
|
|
} {
|
|
const { allCommands } = params;
|
|
const { maxCommands, maxTotalChars } = params;
|
|
const totalCommands = allCommands.length;
|
|
const overflowCount = Math.max(0, totalCommands - maxCommands);
|
|
const {
|
|
commands: commandsToRegister,
|
|
descriptionTrimmed,
|
|
textBudgetDropCount,
|
|
} = fitTelegramCommandsWithinTextBudget(allCommands.slice(0, maxCommands), maxTotalChars);
|
|
return {
|
|
commandsToRegister,
|
|
totalCommands,
|
|
maxCommands,
|
|
overflowCount,
|
|
maxTotalChars,
|
|
descriptionTrimmed,
|
|
textBudgetDropCount,
|
|
};
|
|
}
|
|
|
|
function buildTelegramMenuResultCacheKey(params: {
|
|
allCommands: TelegramMenuCommand[];
|
|
maxCommands: number;
|
|
maxTotalChars: number;
|
|
}): string {
|
|
const digest = createHash("sha256");
|
|
updateTelegramCommandDigestField(digest, String(params.maxCommands));
|
|
updateTelegramCommandDigestField(digest, String(params.maxTotalChars));
|
|
for (const command of params.allCommands) {
|
|
updateTelegramCommandDigestField(digest, command.command);
|
|
updateTelegramCommandDigestField(digest, command.description);
|
|
updateTelegramCommandLocalizationDigest(digest, command.descriptionLocalizations);
|
|
}
|
|
return digest.digest("hex").slice(0, 16);
|
|
}
|
|
|
|
function updateTelegramCommandDigestField(
|
|
digest: ReturnType<typeof createHash>,
|
|
value: string,
|
|
): void {
|
|
digest.update(String(value.length));
|
|
digest.update(":");
|
|
digest.update(value);
|
|
}
|
|
|
|
function updateTelegramCommandLocalizationDigest(
|
|
digest: ReturnType<typeof createHash>,
|
|
localizations: Record<string, string> | undefined,
|
|
): void {
|
|
const entries = Object.entries(localizations ?? {}).toSorted(([a], [b]) => a.localeCompare(b));
|
|
updateTelegramCommandDigestField(digest, String(entries.length));
|
|
for (const [locale, description] of entries) {
|
|
updateTelegramCommandDigestField(digest, locale);
|
|
updateTelegramCommandDigestField(digest, description);
|
|
}
|
|
}
|
|
|
|
function rememberCappedTelegramMenuResult(
|
|
key: string,
|
|
result: ReturnType<typeof buildUncachedCappedTelegramMenuCommands>,
|
|
): void {
|
|
cappedTelegramMenuCache.set(key, result);
|
|
if (cappedTelegramMenuCache.size <= TELEGRAM_MENU_RESULT_CACHE_MAX) {
|
|
return;
|
|
}
|
|
const oldestKey = cappedTelegramMenuCache.keys().next().value;
|
|
if (oldestKey) {
|
|
cappedTelegramMenuCache.delete(oldestKey);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// Keep the sync cache process-local so restarts always re-register commands.
|
|
const syncedCommandHashes = new Map<string, string>();
|
|
|
|
function getCommandHashKey(accountId?: string, botIdentity?: string): string {
|
|
return `${accountId ?? "default"}:${botIdentity ?? ""}`;
|
|
}
|
|
|
|
function readCachedCommandHash(accountId?: string, botIdentity?: string): string | null {
|
|
const key = getCommandHashKey(accountId, botIdentity);
|
|
return syncedCommandHashes.get(key) ?? null;
|
|
}
|
|
|
|
function writeCachedCommandHash(
|
|
accountId: string | undefined,
|
|
botIdentity: string | undefined,
|
|
hash: string,
|
|
): void {
|
|
const key = getCommandHashKey(accountId, botIdentity);
|
|
syncedCommandHashes.set(key, hash);
|
|
}
|
|
|
|
function normalizeTelegramLanguageCode(languageCode: string): string | null {
|
|
const normalized = languageCode.trim().toLowerCase();
|
|
return /^[a-z]{2}$/.test(normalized) ? normalized : null;
|
|
}
|
|
|
|
function readLocalizedDescription(
|
|
command: TelegramMenuCommand,
|
|
languageCode: string,
|
|
): string | undefined {
|
|
for (const [rawLanguageCode, rawDescription] of Object.entries(
|
|
command.descriptionLocalizations ?? {},
|
|
)) {
|
|
if (normalizeTelegramLanguageCode(rawLanguageCode) !== languageCode) {
|
|
continue;
|
|
}
|
|
const description = normalizeOptionalString(rawDescription);
|
|
if (description) {
|
|
return description;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function toTelegramBotCommands(commands: TelegramMenuCommand[]): Array<{
|
|
command: string;
|
|
description: string;
|
|
}> {
|
|
return commands.map((command) => ({
|
|
command: command.command,
|
|
description: command.description,
|
|
}));
|
|
}
|
|
|
|
function buildLocalizedCommandVariants(commands: TelegramMenuCommand[]): {
|
|
variants: Array<{ languageCode: string; commands: TelegramMenuCommand[] }>;
|
|
unsupportedLanguageCodes: string[];
|
|
} {
|
|
const locales = new Set<string>();
|
|
const unsupportedLanguageCodes = new Set<string>();
|
|
for (const cmd of commands) {
|
|
if (cmd.descriptionLocalizations) {
|
|
for (const lang of Object.keys(cmd.descriptionLocalizations)) {
|
|
const normalized = normalizeTelegramLanguageCode(lang);
|
|
if (normalized) {
|
|
locales.add(normalized);
|
|
} else {
|
|
unsupportedLanguageCodes.add(lang);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const variants = [...locales].toSorted().map((languageCode) => {
|
|
const localizedCommands = commands.map((cmd) => ({
|
|
command: cmd.command,
|
|
description: readLocalizedDescription(cmd, languageCode) ?? cmd.description,
|
|
}));
|
|
return {
|
|
languageCode,
|
|
commands: fitTelegramCommandsWithinTextBudget(
|
|
localizedCommands,
|
|
TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET,
|
|
).commands,
|
|
};
|
|
});
|
|
return {
|
|
variants,
|
|
unsupportedLanguageCodes: [...unsupportedLanguageCodes].toSorted(),
|
|
};
|
|
}
|
|
|
|
function formatTelegramCommandScopeOperation(
|
|
operation: "deleteMyCommands" | "setMyCommands",
|
|
scope: TelegramCommandMenuScope,
|
|
languageCode?: string,
|
|
): string {
|
|
const base = scope.label === "default" ? operation : `${operation}(${scope.label})`;
|
|
return languageCode ? `${base}(${languageCode})` : base;
|
|
}
|
|
|
|
async function deleteTelegramMenuCommandsForScopes(params: {
|
|
bot: Bot;
|
|
runtime: RuntimeEnv;
|
|
}): Promise<boolean> {
|
|
const { bot, runtime } = params;
|
|
if (typeof bot.api.deleteMyCommands !== "function") {
|
|
return true;
|
|
}
|
|
|
|
let allDeleted = true;
|
|
for (const scope of TELEGRAM_COMMAND_MENU_SCOPES) {
|
|
const deleted = await withTelegramApiErrorLogging({
|
|
operation: formatTelegramCommandScopeOperation("deleteMyCommands", scope),
|
|
runtime,
|
|
fn: () =>
|
|
scope.options ? bot.api.deleteMyCommands(scope.options) : bot.api.deleteMyCommands(),
|
|
})
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
allDeleted &&= deleted;
|
|
}
|
|
return allDeleted;
|
|
}
|
|
|
|
async function setTelegramMenuCommandsForScopes(params: {
|
|
bot: Bot;
|
|
runtime: RuntimeEnv;
|
|
commands: TelegramMenuCommand[];
|
|
languageCode?: string;
|
|
shouldLog?: (err: unknown) => boolean;
|
|
}): Promise<void> {
|
|
const { bot, runtime, commands, languageCode, shouldLog } = params;
|
|
for (const scope of TELEGRAM_COMMAND_MENU_SCOPES) {
|
|
await withTelegramApiErrorLogging({
|
|
operation: formatTelegramCommandScopeOperation("setMyCommands", scope, languageCode),
|
|
runtime,
|
|
shouldLog,
|
|
fn: () => {
|
|
const botCommands = toTelegramBotCommands(commands);
|
|
const opts = {
|
|
...scope.options,
|
|
...(languageCode ? { language_code: languageCode as LanguageCode } : undefined),
|
|
};
|
|
return Object.keys(opts).length > 0
|
|
? bot.api.setMyCommands(botCommands, opts)
|
|
: bot.api.setMyCommands(botCommands);
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
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 = 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.
|
|
const deleteSucceeded = await deleteTelegramMenuCommandsForScopes({ bot, runtime });
|
|
|
|
if (commandsToRegister.length === 0) {
|
|
if (!deleteSucceeded) {
|
|
runtime.log?.("telegram: deleteMyCommands failed; skipping empty-menu hash cache write");
|
|
return;
|
|
}
|
|
if (typeof bot.api.deleteMyCommands !== "function") {
|
|
await setTelegramMenuCommandsForScopes({ bot, runtime, commands: [] });
|
|
}
|
|
writeCachedCommandHash(accountId, botIdentity, currentHash);
|
|
return;
|
|
}
|
|
|
|
let retryCommands = commandsToRegister;
|
|
let acceptedCommands: TelegramMenuCommand[] | null = null;
|
|
const initialCommandCount = commandsToRegister.length;
|
|
while (retryCommands.length > 0) {
|
|
try {
|
|
await setTelegramMenuCommandsForScopes({
|
|
bot,
|
|
runtime,
|
|
commands: retryCommands,
|
|
shouldLog: (err) => !isBotCommandsTooMuchError(err),
|
|
});
|
|
if (retryCommands.length < initialCommandCount) {
|
|
runtime.log?.(
|
|
formatTelegramCommandRetrySuccessLog({
|
|
initialCount: initialCommandCount,
|
|
acceptedCount: retryCommands.length,
|
|
}),
|
|
);
|
|
}
|
|
acceptedCommands = retryCommands;
|
|
break;
|
|
} 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);
|
|
}
|
|
}
|
|
|
|
if (!acceptedCommands) {
|
|
return;
|
|
}
|
|
|
|
const { variants, unsupportedLanguageCodes } = buildLocalizedCommandVariants(acceptedCommands);
|
|
if (unsupportedLanguageCodes.length > 0) {
|
|
runtime.log?.(
|
|
`Telegram command menu ignored unsupported description localization codes: ${unsupportedLanguageCodes.join(", ")}.`,
|
|
);
|
|
}
|
|
|
|
for (const variant of variants) {
|
|
await setTelegramMenuCommandsForScopes({
|
|
bot,
|
|
runtime,
|
|
commands: variant.commands,
|
|
languageCode: variant.languageCode,
|
|
});
|
|
}
|
|
writeCachedCommandHash(accountId, botIdentity, currentHash);
|
|
};
|
|
|
|
void sync().catch((err) => {
|
|
runtime.error?.(`Telegram command sync failed: ${String(err)}`);
|
|
});
|
|
}
|