From c424836fbeec4634b0677b7160afdfc5d439eb8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 19:54:58 +0000 Subject: [PATCH] refactor: harden outbound, matrix bootstrap, and plugin entry resolution --- .../matrix/src/matrix/client-bootstrap.ts | 3 +- extensions/matrix/src/matrix/client/config.ts | 3 +- .../matrix/src/matrix/client/create-client.ts | 12 +++--- .../matrix/src/matrix/client/logging.ts | 24 ++++++++---- extensions/matrix/src/matrix/client/shared.ts | 4 +- .../matrix/src/matrix/monitor/auto-join.ts | 3 +- extensions/matrix/src/matrix/sdk-runtime.ts | 18 +++++++++ src/infra/outbound/deliver.test.ts | 33 ++++++++++++++++ src/infra/outbound/deliver.ts | 39 ++++++++----------- src/plugins/discovery.ts | 22 ++++------- src/plugins/install.ts | 22 +++++------ src/plugins/manifest.ts | 28 +++++++++++++ src/telegram/bot-native-command-menu.test.ts | 37 ++++++++++++++++++ src/telegram/bot-native-command-menu.ts | 11 +++++- 14 files changed, 194 insertions(+), 65 deletions(-) create mode 100644 extensions/matrix/src/matrix/sdk-runtime.ts diff --git a/extensions/matrix/src/matrix/client-bootstrap.ts b/extensions/matrix/src/matrix/client-bootstrap.ts index b2744d50039..9b8d4b7d7a2 100644 --- a/extensions/matrix/src/matrix/client-bootstrap.ts +++ b/extensions/matrix/src/matrix/client-bootstrap.ts @@ -1,6 +1,6 @@ -import { LogService } from "@vector-im/matrix-bot-sdk"; import { createMatrixClient } from "./client/create-client.js"; import { startMatrixClientWithGrace } from "./client/startup.js"; +import { getMatrixLogService } from "./sdk-runtime.js"; type MatrixClientBootstrapAuth = { homeserver: string; @@ -39,6 +39,7 @@ export async function createPreparedMatrixClient(opts: { await startMatrixClientWithGrace({ client, onError: (err: unknown) => { + const LogService = getMatrixLogService(); LogService.error("MatrixClientBootstrap", "client.start() error:", err); }, }); diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index e29923d4cc9..4a98eadf933 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -1,7 +1,7 @@ -import { MatrixClient } from "@vector-im/matrix-bot-sdk"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { getMatrixRuntime } from "../../runtime.js"; import type { CoreConfig } from "../../types.js"; +import { loadMatrixSdk } from "../sdk-runtime.js"; import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; import type { MatrixAuth, MatrixResolvedConfig } from "./types.js"; @@ -119,6 +119,7 @@ export async function resolveMatrixAuth(params?: { if (!userId) { // Fetch userId from access token via whoami ensureMatrixSdkLoggingConfigured(); + const { MatrixClient } = loadMatrixSdk(); const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken); const whoami = await tempClient.getUserId(); userId = whoami; diff --git a/extensions/matrix/src/matrix/client/create-client.ts b/extensions/matrix/src/matrix/client/create-client.ts index dd9c99214bb..55cf210449c 100644 --- a/extensions/matrix/src/matrix/client/create-client.ts +++ b/extensions/matrix/src/matrix/client/create-client.ts @@ -1,11 +1,10 @@ import fs from "node:fs"; -import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk"; -import { - LogService, +import type { + IStorageProvider, + ICryptoStorageProvider, MatrixClient, - SimpleFsStorageProvider, - RustSdkCryptoStorageProvider, } from "@vector-im/matrix-bot-sdk"; +import { loadMatrixSdk } from "../sdk-runtime.js"; import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; import { maybeMigrateLegacyStorage, @@ -14,6 +13,7 @@ import { } from "./storage.js"; function sanitizeUserIdList(input: unknown, label: string): string[] { + const LogService = loadMatrixSdk().LogService; if (input == null) { return []; } @@ -44,6 +44,8 @@ export async function createMatrixClient(params: { localTimeoutMs?: number; accountId?: string | null; }): Promise { + const { MatrixClient, SimpleFsStorageProvider, RustSdkCryptoStorageProvider, LogService } = + loadMatrixSdk(); ensureMatrixSdkLoggingConfigured(); const env = process.env; diff --git a/extensions/matrix/src/matrix/client/logging.ts b/extensions/matrix/src/matrix/client/logging.ts index c5ef702b019..1f07d7ed542 100644 --- a/extensions/matrix/src/matrix/client/logging.ts +++ b/extensions/matrix/src/matrix/client/logging.ts @@ -1,7 +1,15 @@ -import { ConsoleLogger, LogService } from "@vector-im/matrix-bot-sdk"; +import { loadMatrixSdk } from "../sdk-runtime.js"; let matrixSdkLoggingConfigured = false; -const matrixSdkBaseLogger = new ConsoleLogger(); +let matrixSdkBaseLogger: + | { + trace: (module: string, ...messageOrObject: unknown[]) => void; + debug: (module: string, ...messageOrObject: unknown[]) => void; + info: (module: string, ...messageOrObject: unknown[]) => void; + warn: (module: string, ...messageOrObject: unknown[]) => void; + error: (module: string, ...messageOrObject: unknown[]) => void; + } + | undefined; function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean { if (module !== "MatrixHttpClient") { @@ -19,18 +27,20 @@ export function ensureMatrixSdkLoggingConfigured(): void { if (matrixSdkLoggingConfigured) { return; } + const { ConsoleLogger, LogService } = loadMatrixSdk(); + matrixSdkBaseLogger = new ConsoleLogger(); matrixSdkLoggingConfigured = true; LogService.setLogger({ - trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject), - debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject), - info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject), - warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject), + trace: (module, ...messageOrObject) => matrixSdkBaseLogger?.trace(module, ...messageOrObject), + debug: (module, ...messageOrObject) => matrixSdkBaseLogger?.debug(module, ...messageOrObject), + info: (module, ...messageOrObject) => matrixSdkBaseLogger?.info(module, ...messageOrObject), + warn: (module, ...messageOrObject) => matrixSdkBaseLogger?.warn(module, ...messageOrObject), error: (module, ...messageOrObject) => { if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) { return; } - matrixSdkBaseLogger.error(module, ...messageOrObject); + matrixSdkBaseLogger?.error(module, ...messageOrObject); }, }); } diff --git a/extensions/matrix/src/matrix/client/shared.ts b/extensions/matrix/src/matrix/client/shared.ts index d64b61ee083..e12aa795d8c 100644 --- a/extensions/matrix/src/matrix/client/shared.ts +++ b/extensions/matrix/src/matrix/client/shared.ts @@ -1,7 +1,7 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; -import { LogService } from "@vector-im/matrix-bot-sdk"; import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import type { CoreConfig } from "../../types.js"; +import { getMatrixLogService } from "../sdk-runtime.js"; import { resolveMatrixAuth } from "./config.js"; import { createMatrixClient } from "./create-client.js"; import { startMatrixClientWithGrace } from "./startup.js"; @@ -81,6 +81,7 @@ async function ensureSharedClientStarted(params: { params.state.cryptoReady = true; } } catch (err) { + const LogService = getMatrixLogService(); LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err); } } @@ -89,6 +90,7 @@ async function ensureSharedClientStarted(params: { client, onError: (err: unknown) => { params.state.started = false; + const LogService = getMatrixLogService(); LogService.error("MatrixClientLite", "client.start() error:", err); }, }); diff --git a/extensions/matrix/src/matrix/monitor/auto-join.ts b/extensions/matrix/src/matrix/monitor/auto-join.ts index 9f36ae405d8..58121a95f86 100644 --- a/extensions/matrix/src/matrix/monitor/auto-join.ts +++ b/extensions/matrix/src/matrix/monitor/auto-join.ts @@ -1,8 +1,8 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; -import { AutojoinRoomsMixin } from "@vector-im/matrix-bot-sdk"; import type { RuntimeEnv } from "openclaw/plugin-sdk"; import { getMatrixRuntime } from "../../runtime.js"; import type { CoreConfig } from "../../types.js"; +import { loadMatrixSdk } from "../sdk-runtime.js"; export function registerMatrixAutoJoin(params: { client: MatrixClient; @@ -26,6 +26,7 @@ export function registerMatrixAutoJoin(params: { if (autoJoin === "always") { // Use the built-in autojoin mixin for "always" mode + const { AutojoinRoomsMixin } = loadMatrixSdk(); AutojoinRoomsMixin.setupOnClient(client); logVerbose("matrix: auto-join enabled for all invites"); return; diff --git a/extensions/matrix/src/matrix/sdk-runtime.ts b/extensions/matrix/src/matrix/sdk-runtime.ts new file mode 100644 index 00000000000..8903da896ab --- /dev/null +++ b/extensions/matrix/src/matrix/sdk-runtime.ts @@ -0,0 +1,18 @@ +import { createRequire } from "node:module"; + +type MatrixSdkRuntime = typeof import("@vector-im/matrix-bot-sdk"); + +let cachedMatrixSdkRuntime: MatrixSdkRuntime | null = null; + +export function loadMatrixSdk(): MatrixSdkRuntime { + if (cachedMatrixSdkRuntime) { + return cachedMatrixSdkRuntime; + } + const req = createRequire(import.meta.url); + cachedMatrixSdkRuntime = req("@vector-im/matrix-bot-sdk") as MatrixSdkRuntime; + return cachedMatrixSdkRuntime; +} + +export function getMatrixLogService() { + return loadMatrixSdk().LogService; +} diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 79b1ba74611..e23cdf496f7 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -855,6 +855,39 @@ describe("deliverOutboundPayloads", () => { ); }); + it("preserves channelData-only payloads with empty text for non-WhatsApp sendPayload channels", async () => { + const sendPayload = vi.fn().mockResolvedValue({ channel: "line", messageId: "ln-1" }); + const sendText = vi.fn(); + const sendMedia = vi.fn(); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "line", + source: "test", + plugin: createOutboundTestPlugin({ + id: "line", + outbound: { deliveryMode: "direct", sendPayload, sendText, sendMedia }, + }), + }, + ]), + ); + + const results = await deliverOutboundPayloads({ + cfg: {}, + channel: "line", + to: "U123", + payloads: [{ text: " \n\t ", channelData: { mode: "flex" } }], + }); + + expect(sendPayload).toHaveBeenCalledTimes(1); + expect(sendPayload).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ text: "", channelData: { mode: "flex" } }), + }), + ); + expect(results).toEqual([{ channel: "line", messageId: "ln-1" }]); + }); + it("emits message_sent failure when delivery errors", async () => { hookMocks.runner.hasHooks.mockReturnValue(true); const sendWhatsApp = vi.fn().mockRejectedValue(new Error("downstream failed")); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index c5b986baeb6..6cc52c71b9b 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -428,12 +428,21 @@ async function deliverOutboundPayloadsCore( })), }; }; - const normalizeWhatsAppPayload = (payload: ReplyPayload): ReplyPayload | null => { - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const hasMediaPayload = (payload: ReplyPayload): boolean => + Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const hasChannelDataPayload = (payload: ReplyPayload): boolean => + Boolean(payload.channelData && Object.keys(payload.channelData).length > 0); + const normalizePayloadForChannelDelivery = ( + payload: ReplyPayload, + channelId: string, + ): ReplyPayload | null => { + const hasMedia = hasMediaPayload(payload); + const hasChannelData = hasChannelDataPayload(payload); const rawText = typeof payload.text === "string" ? payload.text : ""; - const normalizedText = rawText.replace(/^(?:[ \t]*\r?\n)+/, ""); + const normalizedText = + channelId === "whatsapp" ? rawText.replace(/^(?:[ \t]*\r?\n)+/, "") : rawText; if (!normalizedText.trim()) { - if (!hasMedia) { + if (!hasMedia && !hasChannelData) { return null; } return { @@ -441,25 +450,14 @@ async function deliverOutboundPayloadsCore( text: "", }; } + if (normalizedText === rawText) { + return payload; + } return { ...payload, text: normalizedText, }; }; - const normalizeEmptyTextPayload = (payload: ReplyPayload): ReplyPayload | null => { - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; - const rawText = typeof payload.text === "string" ? payload.text : ""; - if (!rawText.trim()) { - if (!hasMedia) { - return null; - } - return { - ...payload, - text: "", - }; - } - return payload; - }; const normalizedPayloads = normalizeReplyPayloadsForDelivery(payloads) .map((payload) => { // Strip HTML tags for plain-text surfaces (WhatsApp, Signal, etc.) @@ -475,10 +473,7 @@ async function deliverOutboundPayloadsCore( return { ...payload, text: sanitizeForPlainText(payload.text) }; }) .flatMap((payload) => { - const normalized = - channel === "whatsapp" - ? normalizeWhatsAppPayload(payload) - : normalizeEmptyTextPayload(payload); + const normalized = normalizePayloadForChannelDelivery(payload, channel); return normalized ? [normalized] : []; }); const hookRunner = getGlobalHookRunner(); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index b0bcda0321e..37d63714099 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -4,7 +4,9 @@ import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { resolveConfigDir, resolveUserPath } from "../utils.js"; import { resolveBundledPluginsDir } from "./bundled-dir.js"; import { + DEFAULT_PLUGIN_ENTRY_CANDIDATES, getPackageManifestMetadata, + resolvePackageExtensionEntries, type OpenClawPackageManifest, type PackageManifest, } from "./manifest.js"; @@ -243,14 +245,6 @@ function readPackageManifest(dir: string): PackageManifest | null { } } -function resolvePackageExtensions(manifest: PackageManifest): string[] { - const raw = getPackageManifestMetadata(manifest)?.extensions; - if (!Array.isArray(raw)) { - return []; - } - return raw.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); -} - function deriveIdHint(params: { filePath: string; packageName?: string; @@ -394,7 +388,8 @@ function discoverInDirectory(params: { } const manifest = readPackageManifest(fullPath); - const extensions = manifest ? resolvePackageExtensions(manifest) : []; + const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined); + const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : []; if (extensions.length > 0) { for (const extPath of extensions) { @@ -428,8 +423,7 @@ function discoverInDirectory(params: { continue; } - const indexCandidates = ["index.ts", "index.js", "index.mjs", "index.cjs"]; - const indexFile = indexCandidates + const indexFile = [...DEFAULT_PLUGIN_ENTRY_CANDIDATES] .map((candidate) => path.join(fullPath, candidate)) .find((candidate) => fs.existsSync(candidate)); if (indexFile && isExtensionFile(indexFile)) { @@ -495,7 +489,8 @@ function discoverFromPath(params: { if (stat.isDirectory()) { const manifest = readPackageManifest(resolved); - const extensions = manifest ? resolvePackageExtensions(manifest) : []; + const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined); + const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : []; if (extensions.length > 0) { for (const extPath of extensions) { @@ -529,8 +524,7 @@ function discoverFromPath(params: { return; } - const indexCandidates = ["index.ts", "index.js", "index.mjs", "index.cjs"]; - const indexFile = indexCandidates + const indexFile = [...DEFAULT_PLUGIN_ENTRY_CANDIDATES] .map((candidate) => path.join(resolved, candidate)) .find((candidate) => fs.existsSync(candidate)); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 59a4d94d0a3..ab391548d1b 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -1,6 +1,5 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { MANIFEST_KEY } from "../compat/legacy-names.js"; import { fileExists, readJsonFile, resolveArchiveKind } from "../infra/archive.js"; import { writeFileFromPathWithinRoot } from "../infra/fs-safe.js"; import { resolveExistingInstallPath, withExtractedArchiveRoot } from "../infra/install-flow.js"; @@ -31,18 +30,20 @@ import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js"; import * as skillScanner from "../security/skill-scanner.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; -import { loadPluginManifest } from "./manifest.js"; +import { + loadPluginManifest, + resolvePackageExtensionEntries, + type PackageManifest as PluginPackageManifest, +} from "./manifest.js"; type PluginInstallLogger = { info?: (message: string) => void; warn?: (message: string) => void; }; -type PackageManifest = { - name?: string; - version?: string; +type PackageManifest = PluginPackageManifest & { dependencies?: Record; -} & Partial>; +}; const MISSING_EXTENSIONS_ERROR = 'package.json missing openclaw.extensions; update the plugin package to include openclaw.extensions (for example ["./dist/index.js"]). See https://docs.openclaw.ai/help/troubleshooting#plugin-install-fails-with-missing-openclaw-extensions'; @@ -86,15 +87,14 @@ function validatePluginId(pluginId: string): string | null { } function ensureOpenClawExtensions(params: { manifest: PackageManifest }): string[] { - const extensions = params.manifest[MANIFEST_KEY]?.extensions; - if (!Array.isArray(extensions)) { + const resolved = resolvePackageExtensionEntries(params.manifest); + if (resolved.status === "missing") { throw new Error(MISSING_EXTENSIONS_ERROR); } - const list = extensions.map((e) => (typeof e === "string" ? e.trim() : "")).filter(Boolean); - if (list.length === 0) { + if (resolved.status === "empty") { throw new Error("package.json openclaw.extensions is empty"); } - return list; + return resolved.entries; } function buildFileInstallResult(pluginId: string, targetFile: string): InstallPluginResult { diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index b507ffd11f3..0e01a223178 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -148,6 +148,18 @@ export type OpenClawPackageManifest = { install?: PluginPackageInstall; }; +export const DEFAULT_PLUGIN_ENTRY_CANDIDATES = [ + "index.ts", + "index.js", + "index.mjs", + "index.cjs", +] as const; + +export type PackageExtensionResolution = + | { status: "ok"; entries: string[] } + | { status: "missing"; entries: [] } + | { status: "empty"; entries: [] }; + export type ManifestKey = typeof MANIFEST_KEY; export type PackageManifest = { @@ -164,3 +176,19 @@ export function getPackageManifestMetadata( } return manifest[MANIFEST_KEY]; } + +export function resolvePackageExtensionEntries( + manifest: PackageManifest | undefined, +): PackageExtensionResolution { + const raw = getPackageManifestMetadata(manifest)?.extensions; + if (!Array.isArray(raw)) { + return { status: "missing", entries: [] }; + } + const entries = raw + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter(Boolean); + if (entries.length === 0) { + return { status: "empty", entries: [] }; + } + return { status: "ok", entries }; +} diff --git a/src/telegram/bot-native-command-menu.test.ts b/src/telegram/bot-native-command-menu.test.ts index 3b521daa4ac..c249f0ff761 100644 --- a/src/telegram/bot-native-command-menu.test.ts +++ b/src/telegram/bot-native-command-menu.test.ts @@ -208,6 +208,43 @@ describe("bot-native-command-menu", () => { expect(runtimeLog).not.toHaveBeenCalledWith("telegram: command menu unchanged; skipping sync"); }); + it("does not cache empty-menu hash when deleteMyCommands fails", async () => { + const deleteMyCommands = vi + .fn() + .mockRejectedValueOnce(new Error("transient failure")) + .mockResolvedValue(undefined); + const setMyCommands = vi.fn(async () => undefined); + const runtimeLog = vi.fn(); + const accountId = `test-empty-delete-fail-${Date.now()}`; + + syncTelegramMenuCommands({ + bot: { api: { deleteMyCommands, setMyCommands } } as unknown as Parameters< + typeof syncTelegramMenuCommands + >[0]["bot"], + runtime: { log: runtimeLog, error: vi.fn(), exit: vi.fn() } as Parameters< + typeof syncTelegramMenuCommands + >[0]["runtime"], + commandsToRegister: [], + accountId, + botIdentity: "bot-a", + }); + await vi.waitFor(() => expect(deleteMyCommands).toHaveBeenCalledTimes(1)); + + syncTelegramMenuCommands({ + bot: { api: { deleteMyCommands, setMyCommands } } as unknown as Parameters< + typeof syncTelegramMenuCommands + >[0]["bot"], + runtime: { log: runtimeLog, error: vi.fn(), exit: vi.fn() } as Parameters< + typeof syncTelegramMenuCommands + >[0]["runtime"], + commandsToRegister: [], + accountId, + botIdentity: "bot-a", + }); + await vi.waitFor(() => expect(deleteMyCommands).toHaveBeenCalledTimes(2)); + expect(runtimeLog).not.toHaveBeenCalledWith("telegram: command menu unchanged; skipping sync"); + }); + it("retries with fewer commands on BOT_COMMANDS_TOO_MUCH", async () => { const deleteMyCommands = vi.fn(async () => undefined); const setMyCommands = vi diff --git a/src/telegram/bot-native-command-menu.ts b/src/telegram/bot-native-command-menu.ts index 8881b708bcf..6b29c5f9366 100644 --- a/src/telegram/bot-native-command-menu.ts +++ b/src/telegram/bot-native-command-menu.ts @@ -174,15 +174,22 @@ export function syncTelegramMenuCommands(params: { } // Keep delete -> set ordering to avoid stale deletions racing after fresh registrations. + let deleteSucceeded = true; if (typeof bot.api.deleteMyCommands === "function") { - await withTelegramApiErrorLogging({ + deleteSucceeded = await withTelegramApiErrorLogging({ operation: "deleteMyCommands", runtime, fn: () => bot.api.deleteMyCommands(), - }).catch(() => {}); + }) + .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; }