重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,6 @@
import type { OpenClawConfig } from "./runtime-api.js";
import { inspectTelegramAccount } from "./src/account-inspect.js";
export function inspectTelegramReadOnlyAccount(cfg: OpenClawConfig, accountId?: string | null) {
return inspectTelegramAccount({ cfg, accountId });
}

View file

@ -0,0 +1 @@
export * from "./src/allow-from.js";

View file

@ -0,0 +1,37 @@
export { telegramPlugin } from "./src/channel.js";
export { telegramSetupPlugin } from "./src/channel.setup.js";
export * from "./src/account-inspect.js";
export * from "./src/accounts.js";
export * from "./src/action-threading.js";
export * from "./src/allow-from.js";
export * from "./src/api-fetch.js";
export * from "./src/bot/helpers.js";
export * from "./src/command-config.js";
export {
buildCommandsPaginationKeyboard,
buildTelegramModelsProviderChannelData,
} from "./src/command-ui.js";
export * from "./src/directory-config.js";
export * from "./src/exec-approval-forwarding.js";
export * from "./src/exec-approvals.js";
export * from "./src/group-policy.js";
export type {
TelegramInteractiveHandlerContext,
TelegramInteractiveHandlerRegistration,
} from "./src/interactive-dispatch.js";
export * from "./src/inline-buttons.js";
export * from "./src/model-buttons.js";
export * from "./src/normalize.js";
export * from "./src/outbound-adapter.js";
export * from "./src/outbound-params.js";
export * from "./src/probe.js";
export * from "./src/reaction-level.js";
export * from "./src/security-audit.js";
export * from "./src/sticker-cache.js";
export * from "./src/status-issues.js";
export * from "./src/targets.js";
export * from "./src/topic-conversation.js";
export * from "./src/update-offset-store.js";
export type { TelegramButtonStyle, TelegramInlineButtons } from "./src/button-types.js";
export type { StickerMetadata } from "./src/bot/types.js";
export type { TelegramTokenResolution } from "./src/token.js";

View file

@ -0,0 +1,9 @@
import { TELEGRAM_COMMAND_NAME_PATTERN as sdkTelegramCommandNamePattern } from "openclaw/plugin-sdk/telegram-command-config";
import { describe, expect, it } from "vitest";
import { TELEGRAM_COMMAND_NAME_PATTERN } from "./channel-config-api.js";
describe("telegram channel config api", () => {
it("keeps the command regex aligned with the public SDK contract", () => {
expect(TELEGRAM_COMMAND_NAME_PATTERN.toString()).toBe(sdkTelegramCommandNamePattern.toString());
});
});

View file

@ -0,0 +1,6 @@
export {
TELEGRAM_COMMAND_NAME_PATTERN,
normalizeTelegramCommandDescription,
normalizeTelegramCommandName,
resolveTelegramCustomCommands,
} from "./src/command-config.js";

View file

@ -0,0 +1,4 @@
// Keep bundled channel entry imports narrow so bootstrap/discovery paths do
// not drag the broad Telegram API barrel into lightweight plugin loads.
export { telegramPlugin } from "./src/channel.js";
export { telegramSetupPlugin } from "./src/channel.setup.js";

View file

@ -0,0 +1,9 @@
export {
buildChannelConfigSchema,
TelegramConfigSchema,
} from "openclaw/plugin-sdk/channel-config-schema";
export {
normalizeTelegramCommandDescription,
normalizeTelegramCommandName,
resolveTelegramCustomCommands,
} from "./src/command-config.js";

View file

@ -0,0 +1,6 @@
export function hasTelegramConfiguredState(params: { env?: NodeJS.ProcessEnv }): boolean {
return (
typeof params.env?.TELEGRAM_BOT_TOKEN === "string" &&
params.env.TELEGRAM_BOT_TOKEN.trim().length > 0
);
}

View file

@ -0,0 +1,31 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export {
TELEGRAM_COMMAND_NAME_PATTERN,
normalizeTelegramCommandDescription,
normalizeTelegramCommandName,
resolveTelegramCustomCommands,
} from "./src/command-config.js";
export {
listTelegramDirectoryGroupsFromConfig,
listTelegramDirectoryPeersFromConfig,
} from "./src/directory-config.js";
export { parseTelegramTopicConversation } from "./src/topic-conversation.js";
export { singleAccountKeysToMove } from "./src/setup-contract.js";
export { mergeTelegramAccountConfig } from "./src/accounts.js";
export {
buildCommandsPaginationKeyboard,
buildTelegramModelsProviderChannelData,
} from "./src/command-ui.js";
export {
createTelegramThreadBindingManager,
resetTelegramThreadBindingsForTests,
} from "./src/thread-bindings.js";
export type {
TelegramInteractiveHandlerContext,
TelegramInteractiveHandlerRegistration,
} from "./src/interactive-dispatch.js";
export { collectTelegramSecurityAuditFindings } from "./src/security-audit.js";

View file

@ -0,0 +1,4 @@
export {
listTelegramDirectoryGroupsFromConfig,
listTelegramDirectoryPeersFromConfig,
} from "./src/directory-config.js";

View file

@ -0,0 +1,18 @@
import { beforeEach, describe, vi } from "vitest";
import { assertBundledChannelEntries } from "../../test/helpers/bundled-channel-entry.ts";
import entry from "./index.js";
import setupEntry from "./setup-entry.js";
describe("telegram bundled entries", () => {
beforeEach(() => {
vi.useRealTimers();
});
assertBundledChannelEntries({
entry,
expectedId: "telegram",
expectedName: "Telegram",
setupEntry,
channelMessage: "declares the channel entry without importing the broad api barrel",
});
});

View file

@ -0,0 +1,24 @@
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelEntry({
id: "telegram",
name: "Telegram",
description: "Telegram channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./channel-plugin-api.js",
exportName: "telegramPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setTelegramRuntime",
},
accountInspect: {
specifier: "./account-inspect-api.js",
exportName: "inspectTelegramReadOnlyAccount",
},
});

View file

@ -0,0 +1 @@
export { detectTelegramLegacyStateMigrations } from "./src/state-migrations.js";

View file

@ -0,0 +1,12 @@
{
"id": "telegram",
"channels": ["telegram"],
"channelEnvVars": {
"telegram": ["TELEGRAM_BOT_TOKEN"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View file

@ -0,0 +1,47 @@
{
"name": "@openclaw/telegram",
"version": "2026.4.20",
"private": true,
"description": "OpenClaw Telegram channel plugin",
"type": "module",
"dependencies": {
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"grammy": "^1.42.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"setupFeatures": {
"legacyStateMigrations": true
},
"channel": {
"id": "telegram",
"label": "Telegram",
"selectionLabel": "Telegram (Bot API)",
"detailLabel": "Telegram Bot",
"docsPath": "/channels/telegram",
"docsLabel": "telegram",
"blurb": "simplest way to get started — register a bot with @BotFather and get going.",
"systemImage": "paperplane",
"selectionDocsPrefix": "",
"selectionDocsOmitLabel": true,
"selectionExtras": [
"https://openclaw.ai"
],
"markdownCapable": true,
"configuredState": {
"specifier": "./configured-state",
"exportName": "hasTelegramConfiguredState"
}
},
"bundle": {
"stageRuntimeDependencies": true
}
}
}

View file

@ -0,0 +1,96 @@
export type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
export type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract";
export type { TelegramApiOverride } from "./src/send.js";
export type {
OpenClawPluginService,
OpenClawPluginServiceContext,
PluginLogger,
} from "openclaw/plugin-sdk/plugin-entry";
import type { OpenClawConfig as RuntimeOpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
export type {
AcpRuntime,
AcpRuntimeCapabilities,
AcpRuntimeDoctorReport,
AcpRuntimeEnsureInput,
AcpRuntimeEvent,
AcpRuntimeHandle,
AcpRuntimeStatus,
AcpRuntimeTurnInput,
AcpRuntimeErrorCode,
AcpSessionUpdateTag,
} from "openclaw/plugin-sdk/acp-runtime";
export { AcpRuntimeError } from "openclaw/plugin-sdk/acp-runtime";
export {
emptyPluginConfigSchema,
formatPairingApproveHint,
getChatChannelMeta,
} from "openclaw/plugin-sdk/channel-plugin-common";
export { clearAccountEntryFields } from "openclaw/plugin-sdk/channel-core";
export { buildChannelConfigSchema, TelegramConfigSchema } from "./config-api.js";
export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
export {
PAIRING_APPROVED_MESSAGE,
buildTokenChannelStatusSummary,
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
} from "openclaw/plugin-sdk/channel-status";
export {
jsonResult,
readNumberParam,
readReactionParams,
readStringArrayParam,
readStringOrNumberParam,
readStringParam,
resolvePollMaxSelections,
} from "openclaw/plugin-sdk/channel-actions";
export type { TelegramProbe } from "./src/probe.js";
export { auditTelegramGroupMembership, collectTelegramUnmentionedGroupIds } from "./src/audit.js";
export { resolveTelegramRuntimeGroupPolicy } from "./src/group-access.js";
export {
buildTelegramExecApprovalPendingPayload,
shouldSuppressTelegramExecApprovalForwardingFallback,
} from "./src/exec-approval-forwarding.js";
export { telegramMessageActions } from "./src/channel-actions.js";
export { monitorTelegramProvider } from "./src/monitor.js";
export { probeTelegram } from "./src/probe.js";
export {
resolveTelegramFetch,
resolveTelegramTransport,
shouldRetryTelegramTransportFallback,
} from "./src/fetch.js";
export { makeProxyFetch } from "./src/proxy.js";
export {
createForumTopicTelegram,
deleteMessageTelegram,
editForumTopicTelegram,
editMessageReplyMarkupTelegram,
editMessageTelegram,
pinMessageTelegram,
reactMessageTelegram,
renameForumTopicTelegram,
sendMessageTelegram,
sendPollTelegram,
sendStickerTelegram,
sendTypingTelegram,
unpinMessageTelegram,
} from "./src/send.js";
export {
createTelegramThreadBindingManager,
getTelegramThreadBindingManager,
resetTelegramThreadBindingsForTests,
setTelegramThreadBindingIdleTimeoutBySessionKey,
setTelegramThreadBindingMaxAgeBySessionKey,
} from "./src/thread-bindings.js";
export { resolveTelegramToken } from "./src/token.js";
export { setTelegramRuntime } from "./src/runtime.js";
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
export type TelegramAccountConfig = NonNullable<
NonNullable<RuntimeOpenClawConfig["channels"]>["telegram"]
>;
export type TelegramActionConfig = NonNullable<TelegramAccountConfig["actions"]>;
export type TelegramNetworkConfig = NonNullable<TelegramAccountConfig["network"]>;
export { parseTelegramTopicConversation } from "./src/topic-conversation.js";
export { resolveTelegramPollVisibility } from "./src/poll-visibility.js";

View file

@ -0,0 +1,5 @@
export {
channelSecrets,
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View file

@ -0,0 +1 @@
export { collectTelegramSecurityAuditFindings } from "./src/security-audit.js";

View file

@ -0,0 +1 @@
export { resolveTelegramSessionConversation as resolveSessionConversation } from "./src/session-conversation.js";

View file

@ -0,0 +1,20 @@
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
features: {
legacyStateMigrations: true,
},
plugin: {
specifier: "./setup-plugin-api.js",
exportName: "telegramSetupPlugin",
},
legacyStateMigrations: {
specifier: "./legacy-state-migrations-api.js",
exportName: "detectTelegramLegacyStateMigrations",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
});

View file

@ -0,0 +1,3 @@
// Keep bundled setup entry imports narrow so setup loads do not pull the
// broader Telegram channel plugin surface.
export { telegramSetupPlugin } from "./src/channel.setup.js";

View file

@ -0,0 +1,37 @@
import {
normalizeAccountId,
resolveAccountEntry,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-core";
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
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) ?? {};
// Multi-account bots must not inherit channel-level groups unless explicitly set.
const configuredAccountIds = Object.keys(cfg.channels?.telegram?.accounts ?? {});
const isMultiAccount = configuredAccountIds.length > 1;
const groups = account.groups ?? (isMultiAccount ? undefined : channelGroups);
return { ...base, ...account, groups };
}

View file

@ -0,0 +1,107 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { withEnv } from "openclaw/plugin-sdk/testing";
import { describe, expect, it } from "vitest";
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,237 @@
import { resolveAccountWithDefaultFallback } from "openclaw/plugin-sdk/account-core";
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/channel-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { coerceSecretRef } from "openclaw/plugin-sdk/config-runtime";
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/secret-input";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
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 = normalizeOptionalString(pathValue) ?? "";
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 = normalizeOptionalString(process.env[ref.id]);
if (envValue) {
return {
token: envValue,
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: normalizeOptionalString(merged.name),
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: normalizeOptionalString(merged.name),
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: normalizeOptionalString(merged.name),
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: normalizeOptionalString(merged.name),
token: channelToken.token,
tokenSource: channelToken.tokenSource,
tokenStatus: channelToken.tokenStatus,
configured: channelToken.tokenStatus !== "missing",
config: merged,
};
}
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const envToken = allowEnv
? (normalizeOptionalString(params.envToken) ??
normalizeOptionalString(process.env.TELEGRAM_BOT_TOKEN) ??
"")
: "";
if (envToken) {
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
token: envToken,
tokenSource: "env",
tokenStatus: "available",
configured: true,
config: merged,
};
}
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
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

@ -0,0 +1,151 @@
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
const DEFAULT_AGENT_ID = "main";
function normalizeAgentId(value: string | undefined | null): string {
const normalized = (value ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+/g, "")
.replace(/-+$/g, "");
return normalized || DEFAULT_AGENT_ID;
}
function normalizeChannelId(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function resolveDefaultAgentId(cfg: OpenClawConfig): string {
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
const chosen = (agents.find((agent) => agent?.default) ?? agents[0])?.id;
return normalizeAgentId(chosen);
}
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const ids = new Set<string>();
for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) {
if (key) {
ids.add(normalizeAccountId(key));
}
}
return [...ids];
}
function resolveBindingAccount(params: {
binding: unknown;
channelId: string;
}): { agentId: string; accountId: string } | null {
if (!params.binding || typeof params.binding !== "object") {
return null;
}
const binding = params.binding as {
agentId?: unknown;
match?: { channel?: unknown; accountId?: unknown };
};
if (normalizeChannelId(binding.match?.channel) !== params.channelId) {
return null;
}
const accountId = typeof binding.match?.accountId === "string" ? binding.match.accountId : "";
if (!accountId.trim() || accountId.trim() === "*") {
return null;
}
return {
agentId: normalizeAgentId(typeof binding.agentId === "string" ? binding.agentId : undefined),
accountId: normalizeAccountId(accountId),
};
}
function listBoundAccountIds(cfg: OpenClawConfig, channelId: string): string[] {
const ids = new Set<string>();
for (const binding of cfg.bindings ?? []) {
const resolved = resolveBindingAccount({ binding, channelId });
if (resolved) {
ids.add(resolved.accountId);
}
}
return [...ids].toSorted((left, right) => left.localeCompare(right));
}
function resolveDefaultAgentBoundAccountId(cfg: OpenClawConfig, channelId: string): string | null {
const defaultAgentId = resolveDefaultAgentId(cfg);
for (const binding of cfg.bindings ?? []) {
const resolved = resolveBindingAccount({ binding, channelId });
if (resolved?.agentId === defaultAgentId) {
return resolved.accountId;
}
}
return null;
}
function combineAccountIds(params: {
configuredAccountIds: readonly string[];
additionalAccountIds: readonly string[];
}): string[] {
const ids = new Set<string>();
for (const id of [...params.configuredAccountIds, ...params.additionalAccountIds]) {
ids.add(normalizeAccountId(id));
}
if (ids.size === 0) {
return [DEFAULT_ACCOUNT_ID];
}
return [...ids].toSorted((left, right) => left.localeCompare(right));
}
function resolveListedDefaultAccountId(params: {
accountIds: readonly string[];
configuredDefaultAccountId: string | null | undefined;
}): string {
const configured = normalizeOptionalAccountId(params.configuredDefaultAccountId);
if (configured && params.accountIds.includes(configured)) {
return configured;
}
if (params.accountIds.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return params.accountIds[0] ?? DEFAULT_ACCOUNT_ID;
}
export function listTelegramAccountIds(cfg: OpenClawConfig): string[] {
return combineAccountIds({
configuredAccountIds: listConfiguredAccountIds(cfg),
additionalAccountIds: listBoundAccountIds(cfg, "telegram"),
});
}
export function resolveDefaultTelegramAccountSelection(cfg: OpenClawConfig): {
accountId: string;
accountIds: string[];
shouldWarnMissingDefault: boolean;
} {
const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram");
if (boundDefault) {
return {
accountId: boundDefault,
accountIds: listTelegramAccountIds(cfg),
shouldWarnMissingDefault: false,
};
}
const accountIds = listTelegramAccountIds(cfg);
const resolved = resolveListedDefaultAccountId({
accountIds,
configuredDefaultAccountId: cfg.channels?.telegram?.defaultAccount,
});
return {
accountId: resolved,
accountIds,
shouldWarnMissingDefault:
resolved === accountIds[0] &&
!accountIds.includes(DEFAULT_ACCOUNT_ID) &&
accountIds.length > 1,
};
}
export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string {
return resolveDefaultTelegramAccountSelection(cfg).accountId;
}

View file

@ -0,0 +1,575 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import * as runtimeEnvModule from "openclaw/plugin-sdk/runtime-env";
import { withEnv } from "openclaw/plugin-sdk/testing";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createTelegramActionGate,
listTelegramAccountIds,
mergeTelegramAccountConfig,
resolveTelegramMediaRuntimeOptions,
resetMissingDefaultWarnFlag,
resolveTelegramPollActionGateState,
resolveDefaultTelegramAccountId,
resolveTelegramAccount,
} from "./accounts.js";
const { warnMock } = vi.hoisted(() => ({
warnMock: vi.fn(),
}));
function warningLines(): string[] {
return warnMock.mock.calls.map(([line]) => String(line));
}
function expectNoMissingDefaultWarning() {
expect(warningLines().every((line) => !line.includes("accounts.default is missing"))).toBe(true);
}
function resolveAccountWithEnv(
env: Record<string, string>,
cfg: OpenClawConfig,
accountId?: string,
) {
return withEnv(env, () => resolveTelegramAccount({ cfg, ...(accountId ? { accountId } : {}) }));
}
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(runtimeEnvModule, "createSubsystemLogger").mockImplementation(() => {
const logger = {
warn: warnMock,
child: () => logger,
};
return logger as unknown as ReturnType<typeof runtimeEnvModule.createSubsystemLogger>;
});
});
describe("resolveTelegramAccount", () => {
afterEach(() => {
warnMock.mockClear();
resetMissingDefaultWarnFlag();
});
it("falls back to the first configured account when accountId is omitted", () => {
const account = resolveAccountWithEnv(
{ TELEGRAM_BOT_TOKEN: "" },
{
channels: {
telegram: { accounts: { work: { botToken: "tok-work" } } },
},
},
);
expect(account.accountId).toBe("work");
expect(account.token).toBe("tok-work");
expect(account.tokenSource).toBe("config");
});
it("uses TELEGRAM_BOT_TOKEN when default account config is missing", () => {
const account = resolveAccountWithEnv(
{ TELEGRAM_BOT_TOKEN: "tok-env" },
{
channels: {
telegram: { accounts: { work: { botToken: "tok-work" } } },
},
},
);
expect(account.accountId).toBe("default");
expect(account.token).toBe("tok-env");
expect(account.tokenSource).toBe("env");
});
it("prefers default config token over TELEGRAM_BOT_TOKEN", () => {
const account = resolveAccountWithEnv(
{ TELEGRAM_BOT_TOKEN: "tok-env" },
{
channels: {
telegram: { botToken: "tok-config" },
},
},
);
expect(account.accountId).toBe("default");
expect(account.token).toBe("tok-config");
expect(account.tokenSource).toBe("config");
});
it("does not fall back when accountId is explicitly provided", () => {
const account = resolveAccountWithEnv(
{ TELEGRAM_BOT_TOKEN: "" },
{
channels: {
telegram: { accounts: { work: { botToken: "tok-work" } } },
},
},
"default",
);
expect(account.accountId).toBe("default");
expect(account.tokenSource).toBe("none");
expect(account.token).toBe("");
});
it("formats debug logs with inspect-style output when debug env is enabled", () => {
withEnv({ TELEGRAM_BOT_TOKEN: "", OPENCLAW_DEBUG_TELEGRAM_ACCOUNTS: "1" }, () => {
const cfg: OpenClawConfig = {
channels: {
telegram: { accounts: { work: { botToken: "tok-work" } } },
},
};
expect(listTelegramAccountIds(cfg)).toEqual(["work"]);
resolveTelegramAccount({ cfg, accountId: "work" });
});
const lines = warnMock.mock.calls.map(([line]) => String(line));
expect(lines).toContain("listTelegramAccountIds [ 'work' ]");
expect(lines).toContain("resolve { accountId: 'work', enabled: true, tokenSource: 'config' }");
});
});
describe("resolveDefaultTelegramAccountId", () => {
beforeEach(() => {
resetMissingDefaultWarnFlag();
});
afterEach(() => {
warnMock.mockClear();
resetMissingDefaultWarnFlag();
});
it("warns when accounts.default is missing in multi-account setup (#32137)", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
accounts: { work: { botToken: "tok-work" }, alerts: { botToken: "tok-alerts" } },
},
},
};
const result = resolveDefaultTelegramAccountId(cfg);
expect(result).toBe("alerts");
expect(warnMock).toHaveBeenCalledWith(expect.stringContaining("accounts.default is missing"));
});
it("does not warn when accounts.default exists", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
accounts: { default: { botToken: "tok-default" }, work: { botToken: "tok-work" } },
},
},
};
resolveDefaultTelegramAccountId(cfg);
expectNoMissingDefaultWarning();
});
it("does not warn when defaultAccount is explicitly set", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
defaultAccount: "work",
accounts: { work: { botToken: "tok-work" } },
},
},
};
resolveDefaultTelegramAccountId(cfg);
expectNoMissingDefaultWarning();
});
it("does not warn when only one non-default account is configured", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
accounts: { work: { botToken: "tok-work" } },
},
},
};
resolveDefaultTelegramAccountId(cfg);
expectNoMissingDefaultWarning();
});
it("warns only once per process lifetime", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
accounts: { work: { botToken: "tok-work" }, alerts: { botToken: "tok-alerts" } },
},
},
};
resolveDefaultTelegramAccountId(cfg);
resolveDefaultTelegramAccountId(cfg);
resolveDefaultTelegramAccountId(cfg);
const missingDefaultWarns = warningLines().filter((line) =>
line.includes("accounts.default is missing"),
);
expect(missingDefaultWarns).toHaveLength(1);
});
it("prefers channels.telegram.defaultAccount when it matches a configured account", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
defaultAccount: "work",
accounts: { default: { botToken: "tok-default" }, work: { botToken: "tok-work" } },
},
},
};
expect(resolveDefaultTelegramAccountId(cfg)).toBe("work");
});
it("normalizes channels.telegram.defaultAccount before lookup", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
defaultAccount: "Router D",
accounts: { "router-d": { botToken: "tok-work" } },
},
},
};
expect(resolveDefaultTelegramAccountId(cfg)).toBe("router-d");
});
it("falls back when channels.telegram.defaultAccount is not configured", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
defaultAccount: "missing",
accounts: { default: { botToken: "tok-default" }, work: { botToken: "tok-work" } },
},
},
};
expect(resolveDefaultTelegramAccountId(cfg)).toBe("default");
});
});
describe("resolveTelegramAccount allowFrom precedence", () => {
it("prefers accounts.default allowlists over top-level for default account", () => {
const resolved = resolveTelegramAccount({
cfg: {
channels: {
telegram: {
allowFrom: ["top"],
groupAllowFrom: ["top-group"],
accounts: {
default: {
botToken: "123:default",
allowFrom: ["default"],
groupAllowFrom: ["default-group"],
},
},
},
},
},
accountId: "default",
});
expect(resolved.config.allowFrom).toEqual(["default"]);
expect(resolved.config.groupAllowFrom).toEqual(["default-group"]);
});
it("falls back to top-level allowlists for named account without overrides", () => {
const resolved = resolveTelegramAccount({
cfg: {
channels: {
telegram: {
allowFrom: ["top"],
groupAllowFrom: ["top-group"],
accounts: {
work: { botToken: "123:work" },
},
},
},
},
accountId: "work",
});
expect(resolved.config.allowFrom).toEqual(["top"]);
expect(resolved.config.groupAllowFrom).toEqual(["top-group"]);
});
it("does not inherit default account allowlists for named account when top-level is absent", () => {
const resolved = resolveTelegramAccount({
cfg: {
channels: {
telegram: {
accounts: {
default: {
botToken: "123:default",
allowFrom: ["default"],
groupAllowFrom: ["default-group"],
},
work: { botToken: "123:work" },
},
},
},
},
accountId: "work",
});
expect(resolved.config.allowFrom).toBeUndefined();
expect(resolved.config.groupAllowFrom).toBeUndefined();
});
});
describe("mergeTelegramAccountConfig", () => {
it("inherits top-level policy fallback for named accounts", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
enabled: true,
dmPolicy: "allowlist",
allowFrom: ["123"],
groupPolicy: "allowlist",
accounts: {
bot1: {
enabled: true,
botToken: "bot-1-token",
},
bot2: {
enabled: true,
botToken: "bot-2-token",
},
},
},
},
};
expect(mergeTelegramAccountConfig(cfg, "bot1")).toMatchObject({
botToken: "bot-1-token",
dmPolicy: "allowlist",
allowFrom: ["123"],
groupPolicy: "allowlist",
});
expect(mergeTelegramAccountConfig(cfg, "bot2")).toMatchObject({
botToken: "bot-2-token",
dmPolicy: "allowlist",
allowFrom: ["123"],
groupPolicy: "allowlist",
});
});
it("keeps top-level policy fallback when auth lives in accounts.default", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
enabled: true,
dmPolicy: "allowlist",
allowFrom: ["123"],
groupPolicy: "allowlist",
accounts: {
default: {
botToken: "legacy-token",
},
},
},
},
};
expect(mergeTelegramAccountConfig(cfg, "default")).toMatchObject({
botToken: "legacy-token",
dmPolicy: "allowlist",
allowFrom: ["123"],
groupPolicy: "allowlist",
});
});
});
describe("resolveTelegramPollActionGateState", () => {
it("requires both sendMessage and poll actions", () => {
const state = resolveTelegramPollActionGateState((key) => key !== "poll");
expect(state).toEqual({
sendMessageEnabled: true,
pollEnabled: false,
enabled: false,
});
});
it("returns enabled only when both actions are enabled", () => {
const state = resolveTelegramPollActionGateState(() => true);
expect(state).toEqual({
sendMessageEnabled: true,
pollEnabled: true,
enabled: true,
});
});
it("uses configured defaultAccount when telegram action gate accountId is omitted", () => {
const gate = createTelegramActionGate({
cfg: {
channels: {
telegram: {
actions: { sendMessage: false, poll: false },
defaultAccount: "work",
accounts: {
work: {
botToken: "123:work",
actions: { sendMessage: true, poll: true },
},
},
},
},
},
});
expect(gate("sendMessage")).toBe(true);
expect(gate("poll")).toBe(true);
});
});
describe("resolveTelegramAccount groups inheritance (#30673)", () => {
const createMultiAccountGroupsConfig = (): OpenClawConfig => ({
channels: {
telegram: {
groups: { "-100123": { requireMention: false } },
accounts: {
default: { botToken: "123:default" },
dev: { botToken: "456:dev" },
},
},
},
});
const createDefaultAccountGroupsConfig = (includeDevAccount: boolean): OpenClawConfig => ({
channels: {
telegram: {
groups: { "-100999": { requireMention: true } },
accounts: {
default: {
botToken: "123:default",
groups: { "-100123": { requireMention: false } },
},
...(includeDevAccount ? { dev: { botToken: "456:dev" } } : {}),
},
},
},
});
it("inherits channel-level groups in single-account setup", () => {
const resolved = resolveTelegramAccount({
cfg: {
channels: {
telegram: {
groups: { "-100123": { requireMention: false } },
accounts: {
default: { botToken: "123:default" },
},
},
},
},
accountId: "default",
});
expect(resolved.config.groups).toEqual({ "-100123": { requireMention: false } });
});
it("does NOT inherit channel-level groups to secondary account in multi-account setup", () => {
const resolved = resolveTelegramAccount({
cfg: createMultiAccountGroupsConfig(),
accountId: "dev",
});
expect(resolved.config.groups).toBeUndefined();
});
it("does NOT inherit channel-level groups to default account in multi-account setup", () => {
const resolved = resolveTelegramAccount({
cfg: createMultiAccountGroupsConfig(),
accountId: "default",
});
expect(resolved.config.groups).toBeUndefined();
});
it("uses account-level groups even in multi-account setup", () => {
const resolved = resolveTelegramAccount({
cfg: createDefaultAccountGroupsConfig(true),
accountId: "default",
});
expect(resolved.config.groups).toEqual({ "-100123": { requireMention: false } });
});
it("account-level groups takes priority over channel-level in single-account setup", () => {
const resolved = resolveTelegramAccount({
cfg: createDefaultAccountGroupsConfig(false),
accountId: "default",
});
expect(resolved.config.groups).toEqual({ "-100123": { requireMention: false } });
});
});
describe("resolveTelegramMediaRuntimeOptions", () => {
it("uses per-account network overrides for Telegram media downloads", () => {
const resolved = resolveTelegramMediaRuntimeOptions({
cfg: {
channels: {
telegram: {
apiRoot: "https://api.telegram.org",
network: {
dangerouslyAllowPrivateNetwork: false,
},
trustedLocalFileRoots: ["/srv/telegram/cache"],
accounts: {
work: {
botToken: "123:work",
apiRoot: "http://tg-proxy.internal:8081",
network: {
dangerouslyAllowPrivateNetwork: true,
},
trustedLocalFileRoots: ["/var/lib/telegram-bot-api"],
},
},
},
},
},
accountId: "work",
token: "123:work",
});
expect(resolved).toEqual({
token: "123:work",
apiRoot: "http://tg-proxy.internal:8081",
trustedLocalFileRoots: ["/var/lib/telegram-bot-api"],
dangerouslyAllowPrivateNetwork: true,
transport: undefined,
});
});
it("falls back to top-level Telegram media settings when account override is absent", () => {
const resolved = resolveTelegramMediaRuntimeOptions({
cfg: {
channels: {
telegram: {
apiRoot: "http://tg-proxy.internal:8081",
network: {
dangerouslyAllowPrivateNetwork: true,
},
trustedLocalFileRoots: ["/srv/telegram/cache"],
accounts: {
work: {
botToken: "123:work",
},
},
},
},
},
accountId: "work",
token: "123:work",
});
expect(resolved).toEqual({
token: "123:work",
apiRoot: "http://tg-proxy.internal:8081",
trustedLocalFileRoots: ["/srv/telegram/cache"],
dangerouslyAllowPrivateNetwork: true,
transport: undefined,
});
});
});

View file

@ -0,0 +1,186 @@
import util from "node:util";
import {
createAccountActionGate,
normalizeAccountId,
normalizeOptionalAccountId,
resolveAccountWithDefaultFallback,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-core";
import type {
TelegramAccountConfig,
TelegramActionConfig,
} from "openclaw/plugin-sdk/config-runtime";
import { formatSetExplicitDefaultInstruction } from "openclaw/plugin-sdk/routing";
import { createSubsystemLogger, isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { mergeTelegramAccountConfig, resolveTelegramAccountConfig } from "./account-config.js";
import {
listTelegramAccountIds as listSelectedTelegramAccountIds,
resolveDefaultTelegramAccountSelection,
} from "./account-selection.js";
import type { TelegramTransport } from "./fetch.js";
import { resolveTelegramToken } from "./token.js";
export { mergeTelegramAccountConfig, resolveTelegramAccountConfig } from "./account-config.js";
let log: ReturnType<typeof createSubsystemLogger> | null = null;
function getLog() {
if (!log) {
log = createSubsystemLogger("telegram/accounts");
}
return log;
}
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));
getLog().warn(parts.join(" ").trim());
}
};
export type ResolvedTelegramAccount = {
accountId: string;
enabled: boolean;
name?: string;
token: string;
tokenSource: "env" | "tokenFile" | "config" | "none";
config: TelegramAccountConfig;
};
export type TelegramMediaRuntimeOptions = {
token: string;
transport?: TelegramTransport;
apiRoot?: string;
trustedLocalFileRoots?: readonly string[];
dangerouslyAllowPrivateNetwork?: boolean;
};
export function listTelegramAccountIds(cfg: OpenClawConfig): string[] {
const ids = listSelectedTelegramAccountIds(cfg);
debugAccounts("listTelegramAccountIds", ids);
return ids;
}
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 selection = resolveDefaultTelegramAccountSelection(cfg);
if (selection.shouldWarnMissingDefault && !emittedMissingDefaultWarn) {
emittedMissingDefaultWarn = true;
getLog().warn(
`channels.telegram: accounts.default is missing; falling back to "${selection.accountId}". ` +
`${formatSetExplicitDefaultInstruction("telegram")} to avoid routing surprises in multi-account setups.`,
);
}
return selection.accountId;
}
export function createTelegramActionGate(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): (key: keyof TelegramActionConfig, defaultValue?: boolean) => boolean {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultTelegramAccountId(params.cfg),
);
return createAccountActionGate({
baseActions: params.cfg.channels?.telegram?.actions,
accountActions: resolveTelegramAccountConfig(params.cfg, accountId)?.actions,
});
}
export function resolveTelegramMediaRuntimeOptions(params: {
cfg: OpenClawConfig;
accountId?: string | null;
token: string;
transport?: TelegramTransport;
}): TelegramMediaRuntimeOptions {
const normalizedAccountId = normalizeOptionalAccountId(params.accountId);
const accountCfg = normalizedAccountId
? mergeTelegramAccountConfig(params.cfg, normalizedAccountId)
: params.cfg.channels?.telegram;
return {
token: params.token,
transport: params.transport,
apiRoot: accountCfg?.apiRoot,
trustedLocalFileRoots: accountCfg?.trustedLocalFileRoots,
dangerouslyAllowPrivateNetwork: accountCfg?.network?.dangerouslyAllowPrivateNetwork,
};
}
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: normalizeOptionalString(merged.name),
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);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,643 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import {
jsonResult,
readNumberParam,
readReactionParams,
readStringArrayParam,
readStringOrNumberParam,
readStringParam,
resolvePollMaxSelections,
resolveReactionMessageId,
} from "openclaw/plugin-sdk/channel-actions";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import { createTelegramActionGate, resolveTelegramPollActionGateState } from "./accounts.js";
import {
fitsTelegramCallbackData,
TELEGRAM_CALLBACK_DATA_MAX_BYTES,
} from "./approval-callback-data.js";
import type { TelegramButtonStyle, TelegramInlineButtons } from "./button-types.js";
import { resolveTelegramInlineButtons } from "./button-types.js";
import {
resolveTelegramInlineButtonsScope,
resolveTelegramTargetChatType,
} from "./inline-buttons.js";
import { resolveTelegramPollVisibility } from "./poll-visibility.js";
import { resolveTelegramReactionLevel } from "./reaction-level.js";
import {
createForumTopicTelegram,
deleteMessageTelegram,
editForumTopicTelegram,
editMessageTelegram,
reactMessageTelegram,
sendMessageTelegram,
sendPollTelegram,
sendStickerTelegram,
} from "./send.js";
import { getCacheStats, searchStickers } from "./sticker-cache.js";
import { resolveTelegramToken } from "./token.js";
export const telegramActionRuntime = {
createForumTopicTelegram,
deleteMessageTelegram,
editForumTopicTelegram,
editMessageTelegram,
getCacheStats,
reactMessageTelegram,
searchStickers,
sendMessageTelegram,
sendPollTelegram,
sendStickerTelegram,
};
const TELEGRAM_BUTTON_STYLES: readonly TelegramButtonStyle[] = ["danger", "success", "primary"];
const TELEGRAM_FORUM_TOPIC_ICON_COLORS = [
0x6fb9f0, 0xffd67e, 0xcb86db, 0x8eee98, 0xff93b2, 0xfb6f5f,
] as const;
const TELEGRAM_ACTION_ALIASES = {
createForumTopic: "createForumTopic",
delete: "deleteMessage",
deleteMessage: "deleteMessage",
edit: "editMessage",
editForumTopic: "editForumTopic",
editMessage: "editMessage",
poll: "poll",
react: "react",
searchSticker: "searchSticker",
send: "sendMessage",
sendMessage: "sendMessage",
sendSticker: "sendSticker",
sticker: "sendSticker",
stickerCacheStats: "stickerCacheStats",
"sticker-search": "searchSticker",
"topic-create": "createForumTopic",
"topic-edit": "editForumTopic",
} as const;
type TelegramActionName = (typeof TELEGRAM_ACTION_ALIASES)[keyof typeof TELEGRAM_ACTION_ALIASES];
type TelegramForumTopicIconColor = (typeof TELEGRAM_FORUM_TOPIC_ICON_COLORS)[number];
type RawTelegramButton = {
callback_data?: unknown;
style?: unknown;
text?: unknown;
};
function readTelegramForumTopicIconColor(
params: Record<string, unknown>,
): TelegramForumTopicIconColor | undefined {
const iconColor = readNumberParam(params, "iconColor", { integer: true });
if (iconColor == null) {
return undefined;
}
if (!TELEGRAM_FORUM_TOPIC_ICON_COLORS.includes(iconColor as TelegramForumTopicIconColor)) {
throw new Error("iconColor must be one of Telegram's supported forum topic colors.");
}
return iconColor as TelegramForumTopicIconColor;
}
export function readTelegramButtons(
params: Record<string, unknown>,
): TelegramInlineButtons | undefined {
const raw = params.buttons;
if (raw == null) {
return undefined;
}
if (!Array.isArray(raw)) {
throw new Error("buttons must be an array of button rows");
}
const rows = raw.map((row, rowIndex) => {
if (!Array.isArray(row)) {
throw new Error(`buttons[${rowIndex}] must be an array`);
}
return row.map((button, buttonIndex) => {
if (!button || typeof button !== "object") {
throw new Error(`buttons[${rowIndex}][${buttonIndex}] must be an object`);
}
const rawButton = button as RawTelegramButton;
const text = normalizeOptionalString(rawButton.text) ?? "";
const callbackData = normalizeOptionalString(rawButton.callback_data) ?? "";
if (!text || !callbackData) {
throw new Error(`buttons[${rowIndex}][${buttonIndex}] requires text and callback_data`);
}
if (!fitsTelegramCallbackData(callbackData)) {
throw new Error(
`buttons[${rowIndex}][${buttonIndex}] callback_data too long (max ${TELEGRAM_CALLBACK_DATA_MAX_BYTES} bytes)`,
);
}
const styleRaw = rawButton.style;
const style = normalizeOptionalLowercaseString(styleRaw);
if (styleRaw !== undefined && !style) {
throw new Error(`buttons[${rowIndex}][${buttonIndex}] style must be string`);
}
if (style && !TELEGRAM_BUTTON_STYLES.includes(style as TelegramButtonStyle)) {
throw new Error(
`buttons[${rowIndex}][${buttonIndex}] style must be one of ${TELEGRAM_BUTTON_STYLES.join(", ")}`,
);
}
return {
text,
callback_data: callbackData,
...(style ? { style: style as TelegramButtonStyle } : {}),
};
});
});
const filtered = rows.filter((row) => row.length > 0);
return filtered.length > 0 ? filtered : undefined;
}
function normalizeTelegramActionName(action: string): TelegramActionName {
const normalized = TELEGRAM_ACTION_ALIASES[action as keyof typeof TELEGRAM_ACTION_ALIASES];
if (!normalized) {
throw new Error(`Unsupported Telegram action: ${action}`);
}
return normalized;
}
function readTelegramChatId(params: Record<string, unknown>) {
return (
readStringOrNumberParam(params, "chatId") ??
readStringOrNumberParam(params, "channelId") ??
readStringOrNumberParam(params, "to", { required: true })
);
}
function readTelegramThreadId(params: Record<string, unknown>) {
return (
readNumberParam(params, "messageThreadId", { integer: true }) ??
readNumberParam(params, "threadId", { integer: true })
);
}
function readTelegramReplyToMessageId(params: Record<string, unknown>) {
return (
readNumberParam(params, "replyToMessageId", { integer: true }) ??
readNumberParam(params, "replyTo", { integer: true })
);
}
function resolveTelegramButtonsFromParams(params: Record<string, unknown>) {
return resolveTelegramInlineButtons({
buttons: readTelegramButtons(params),
interactive: params.interactive,
});
}
function readTelegramSendContent(params: {
args: Record<string, unknown>;
mediaUrl?: string;
hasButtons: boolean;
}) {
const content =
readStringParam(params.args, "content", { allowEmpty: true }) ??
readStringParam(params.args, "message", { allowEmpty: true }) ??
readStringParam(params.args, "caption", { allowEmpty: true });
if (content == null && !params.mediaUrl && !params.hasButtons) {
throw new Error("content required.");
}
return content ?? "";
}
export async function handleTelegramAction(
params: Record<string, unknown>,
cfg: OpenClawConfig,
options?: {
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
},
): Promise<AgentToolResult<unknown>> {
const { action, accountId } = {
action: normalizeTelegramActionName(readStringParam(params, "action", { required: true })),
accountId: readStringParam(params, "accountId"),
};
const isActionEnabled = createTelegramActionGate({
cfg,
accountId,
});
if (action === "react") {
// All react failures return soft results (jsonResult with ok:false) instead
// of throwing, because hard tool errors can trigger model re-generation
// loops and duplicate content.
const reactionLevelInfo = resolveTelegramReactionLevel({
cfg,
accountId: accountId ?? undefined,
});
if (!reactionLevelInfo.agentReactionsEnabled) {
return jsonResult({
ok: false,
reason: "disabled",
hint: `Telegram agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). Do not retry.`,
});
}
if (!isActionEnabled("reactions")) {
return jsonResult({
ok: false,
reason: "disabled",
hint: "Telegram reactions are disabled via actions.reactions. Do not retry.",
});
}
const chatId = readTelegramChatId(params);
const messageId =
readNumberParam(params, "messageId", { integer: true }) ??
resolveReactionMessageId({ args: params });
if (typeof messageId !== "number" || !Number.isFinite(messageId) || messageId <= 0) {
return jsonResult({
ok: false,
reason: "missing_message_id",
hint: "Telegram reaction requires a valid messageId (or inbound context fallback). Do not retry.",
});
}
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a Telegram reaction.",
});
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
return jsonResult({
ok: false,
reason: "missing_token",
hint: "Telegram bot token missing. Do not retry.",
});
}
let reactionResult: Awaited<ReturnType<typeof telegramActionRuntime.reactMessageTelegram>>;
try {
reactionResult = await telegramActionRuntime.reactMessageTelegram(
chatId ?? "",
messageId ?? 0,
emoji ?? "",
{
cfg,
token,
remove,
accountId: accountId ?? undefined,
},
);
} catch (err) {
const isInvalid = String(err).includes("REACTION_INVALID");
return jsonResult({
ok: false,
reason: isInvalid ? "REACTION_INVALID" : "error",
emoji,
hint: isInvalid
? "This emoji is not supported for Telegram reactions. Add it to your reaction disallow list so you do not try it again."
: "Reaction failed. Do not retry.",
});
}
if (!reactionResult.ok) {
return jsonResult({
ok: false,
warning: reactionResult.warning,
...(remove || isEmpty ? { removed: true } : { added: emoji }),
});
}
if (!remove && !isEmpty) {
return jsonResult({ ok: true, added: emoji });
}
return jsonResult({ ok: true, removed: true });
}
if (action === "sendMessage") {
if (!isActionEnabled("sendMessage")) {
throw new Error("Telegram sendMessage is disabled.");
}
const to = readStringParam(params, "to", { required: true });
const mediaUrl =
readStringParam(params, "mediaUrl") ??
readStringParam(params, "media", {
trim: false,
});
const buttons = resolveTelegramButtonsFromParams(params);
const content = readTelegramSendContent({
args: params,
mediaUrl: mediaUrl ?? undefined,
hasButtons: Array.isArray(buttons) && buttons.length > 0,
});
if (buttons) {
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg,
accountId: accountId ?? undefined,
});
if (inlineButtonsScope === "off") {
throw new Error(
'Telegram inline buttons are disabled. Set channels.telegram.capabilities.inlineButtons to "dm", "group", "all", or "allowlist".',
);
}
if (inlineButtonsScope === "dm" || inlineButtonsScope === "group") {
const targetType = resolveTelegramTargetChatType(to);
if (targetType === "unknown") {
throw new Error(
`Telegram inline buttons require a numeric chat id when inlineButtons="${inlineButtonsScope}".`,
);
}
if (inlineButtonsScope === "dm" && targetType !== "direct") {
throw new Error('Telegram inline buttons are limited to DMs when inlineButtons="dm".');
}
if (inlineButtonsScope === "group" && targetType !== "group") {
throw new Error(
'Telegram inline buttons are limited to groups when inlineButtons="group".',
);
}
}
}
// Optional threading parameters for forum topics and reply chains
const replyToMessageId = readTelegramReplyToMessageId(params);
const messageThreadId = readTelegramThreadId(params);
const quoteText = readStringParam(params, "quoteText");
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
const result = await telegramActionRuntime.sendMessageTelegram(to, content, {
cfg,
token,
accountId: accountId ?? undefined,
mediaUrl: mediaUrl || undefined,
mediaLocalRoots: options?.mediaLocalRoots,
mediaReadFile: options?.mediaReadFile,
buttons,
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
quoteText: quoteText ?? undefined,
asVoice: readBooleanParam(params, "asVoice"),
silent: readBooleanParam(params, "silent"),
forceDocument:
readBooleanParam(params, "forceDocument") ??
readBooleanParam(params, "asDocument") ??
false,
});
return jsonResult({
ok: true,
messageId: result.messageId,
chatId: result.chatId,
});
}
if (action === "poll") {
const pollActionState = resolveTelegramPollActionGateState(isActionEnabled);
if (!pollActionState.sendMessageEnabled) {
throw new Error("Telegram sendMessage is disabled.");
}
if (!pollActionState.pollEnabled) {
throw new Error("Telegram polls are disabled.");
}
const to = readStringParam(params, "to", { required: true });
const question =
readStringParam(params, "question") ??
readStringParam(params, "pollQuestion", { required: true });
const answers =
readStringArrayParam(params, "answers") ??
readStringArrayParam(params, "pollOption", { required: true });
const allowMultiselect =
readBooleanParam(params, "allowMultiselect") ?? readBooleanParam(params, "pollMulti");
const durationSeconds =
readNumberParam(params, "durationSeconds", { integer: true }) ??
readNumberParam(params, "pollDurationSeconds", {
integer: true,
strict: true,
});
const durationHours =
readNumberParam(params, "durationHours", { integer: true }) ??
readNumberParam(params, "pollDurationHours", {
integer: true,
strict: true,
});
const replyToMessageId = readTelegramReplyToMessageId(params);
const messageThreadId = readTelegramThreadId(params);
const isAnonymous =
readBooleanParam(params, "isAnonymous") ??
resolveTelegramPollVisibility({
pollAnonymous: readBooleanParam(params, "pollAnonymous"),
pollPublic: readBooleanParam(params, "pollPublic"),
});
const silent = readBooleanParam(params, "silent");
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
const result = await telegramActionRuntime.sendPollTelegram(
to,
{
question,
options: answers,
maxSelections: resolvePollMaxSelections(answers.length, allowMultiselect ?? false),
durationSeconds: durationSeconds ?? undefined,
durationHours: durationHours ?? undefined,
},
{
cfg,
token,
accountId: accountId ?? undefined,
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
isAnonymous: isAnonymous ?? undefined,
silent: silent ?? undefined,
},
);
return jsonResult({
ok: true,
messageId: result.messageId,
chatId: result.chatId,
pollId: result.pollId,
});
}
if (action === "deleteMessage") {
if (!isActionEnabled("deleteMessage")) {
throw new Error("Telegram deleteMessage is disabled.");
}
const chatId = readTelegramChatId(params);
const messageId = readNumberParam(params, "messageId", {
required: true,
integer: true,
});
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
await telegramActionRuntime.deleteMessageTelegram(chatId ?? "", messageId ?? 0, {
cfg,
token,
accountId: accountId ?? undefined,
});
return jsonResult({ ok: true, deleted: true });
}
if (action === "editMessage") {
if (!isActionEnabled("editMessage")) {
throw new Error("Telegram editMessage is disabled.");
}
const chatId = readTelegramChatId(params);
const messageId = readNumberParam(params, "messageId", {
required: true,
integer: true,
});
const content =
readStringParam(params, "content", { allowEmpty: false }) ??
readStringParam(params, "message", { required: true, allowEmpty: false });
const buttons = resolveTelegramButtonsFromParams(params);
if (buttons) {
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg,
accountId: accountId ?? undefined,
});
if (inlineButtonsScope === "off") {
throw new Error(
'Telegram inline buttons are disabled. Set channels.telegram.capabilities.inlineButtons to "dm", "group", "all", or "allowlist".',
);
}
}
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
const result = await telegramActionRuntime.editMessageTelegram(
chatId ?? "",
messageId ?? 0,
content,
{
cfg,
token,
accountId: accountId ?? undefined,
buttons,
},
);
return jsonResult({
ok: true,
messageId: result.messageId,
chatId: result.chatId,
});
}
if (action === "sendSticker") {
if (!isActionEnabled("sticker", false)) {
throw new Error(
"Telegram sticker actions are disabled. Set channels.telegram.actions.sticker to true.",
);
}
const to =
readStringParam(params, "to") ?? readStringParam(params, "target", { required: true });
const fileId =
readStringParam(params, "fileId") ?? readStringArrayParam(params, "stickerId")?.[0];
if (!fileId) {
throw new Error("fileId is required.");
}
const replyToMessageId = readTelegramReplyToMessageId(params);
const messageThreadId = readTelegramThreadId(params);
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
const result = await telegramActionRuntime.sendStickerTelegram(to, fileId, {
cfg,
token,
accountId: accountId ?? undefined,
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
});
return jsonResult({
ok: true,
messageId: result.messageId,
chatId: result.chatId,
});
}
if (action === "searchSticker") {
if (!isActionEnabled("sticker", false)) {
throw new Error(
"Telegram sticker actions are disabled. Set channels.telegram.actions.sticker to true.",
);
}
const query = readStringParam(params, "query", { required: true });
const limit = readNumberParam(params, "limit", { integer: true }) ?? 5;
const results = telegramActionRuntime.searchStickers(query, limit);
return jsonResult({
ok: true,
count: results.length,
stickers: results.map((s) => ({
fileId: s.fileId,
emoji: s.emoji,
description: s.description,
setName: s.setName,
})),
});
}
if (action === "stickerCacheStats") {
const stats = telegramActionRuntime.getCacheStats();
return jsonResult({ ok: true, ...stats });
}
if (action === "createForumTopic") {
if (!isActionEnabled("createForumTopic")) {
throw new Error("Telegram createForumTopic is disabled.");
}
const chatId = readTelegramChatId(params);
const name = readStringParam(params, "name", { required: true });
const iconColor = readTelegramForumTopicIconColor(params);
const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId");
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
const result = await telegramActionRuntime.createForumTopicTelegram(chatId ?? "", name, {
cfg,
token,
accountId: accountId ?? undefined,
iconColor,
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
});
return jsonResult({
ok: true,
topicId: result.topicId,
name: result.name,
chatId: result.chatId,
});
}
if (action === "editForumTopic") {
if (!isActionEnabled("editForumTopic")) {
throw new Error("Telegram editForumTopic is disabled.");
}
const chatId = readTelegramChatId(params);
const messageThreadId = readTelegramThreadId(params);
if (typeof messageThreadId !== "number") {
throw new Error("messageThreadId or threadId is required.");
}
const name = readStringParam(params, "name");
const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId");
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
const result = await telegramActionRuntime.editForumTopicTelegram(
chatId ?? "",
messageThreadId,
{
cfg,
token,
accountId: accountId ?? undefined,
name: name ?? undefined,
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
},
);
return jsonResult(result);
}
throw new Error(`Unsupported Telegram action: ${String(action)}`);
}

View file

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { resolveTelegramAutoThreadId } from "./action-threading.js";
describe("resolveTelegramAutoThreadId", () => {
it("keeps current DM topic threadId even when replyToId-like flow is active", () => {
expect(
resolveTelegramAutoThreadId({
to: "telegram:1234",
toolContext: {
currentChannelId: "telegram:1234",
currentThreadTs: "533274",
},
}),
).toBe("533274");
});
it("does not override an explicit target topic", () => {
expect(
resolveTelegramAutoThreadId({
to: "telegram:-1001:topic:99",
toolContext: {
currentChannelId: "telegram:-1001:topic:77",
currentThreadTs: "77",
},
}),
).toBeUndefined();
});
});

View file

@ -0,0 +1,24 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { parseTelegramTarget } from "./targets.js";
export function resolveTelegramAutoThreadId(params: {
to: string;
toolContext?: { currentThreadTs?: string; currentChannelId?: string };
}): string | undefined {
const context = params.toolContext;
if (!context?.currentThreadTs || !context.currentChannelId) {
return undefined;
}
const parsedTo = parseTelegramTarget(params.to);
if (parsedTo.messageThreadId != null) {
return undefined;
}
const parsedChannel = parseTelegramTarget(context.currentChannelId);
if (
normalizeLowercaseStringOrEmpty(parsedTo.chatId) !==
normalizeLowercaseStringOrEmpty(parsedChannel.chatId)
) {
return undefined;
}
return context.currentThreadTs;
}

View file

@ -0,0 +1,17 @@
export function normalizeTelegramAllowFromEntry(raw: unknown): string {
const base = typeof raw === "string" ? raw : typeof raw === "number" ? String(raw) : "";
return base
.trim()
.replace(/^(telegram|tg):/i, "")
.trim();
}
export function isNumericTelegramUserId(raw: string): boolean {
return /^-?\d+$/.test(raw);
}
// Telegram sender authorization only accepts concrete user IDs. Negative chat IDs
// belong under `channels.telegram.groups`, not sender allowlists.
export function isNumericTelegramSenderUserId(raw: string): boolean {
return /^\d+$/.test(raw);
}

View file

@ -0,0 +1,26 @@
import { beforeAll, describe, expect, it } from "vitest";
let API_CONSTANTS: typeof import("grammy").API_CONSTANTS;
let DEFAULT_TELEGRAM_UPDATE_TYPES: typeof import("./allowed-updates.js").DEFAULT_TELEGRAM_UPDATE_TYPES;
let resolveTelegramAllowedUpdates: typeof import("./allowed-updates.js").resolveTelegramAllowedUpdates;
beforeAll(async () => {
({ API_CONSTANTS } = await import("grammy"));
({ DEFAULT_TELEGRAM_UPDATE_TYPES, resolveTelegramAllowedUpdates } =
await import("./allowed-updates.js"));
});
describe("resolveTelegramAllowedUpdates", () => {
it("includes the default update types plus reaction and channel post support", () => {
const updates = resolveTelegramAllowedUpdates();
expect(updates).toEqual(
expect.arrayContaining([
...DEFAULT_TELEGRAM_UPDATE_TYPES,
...(API_CONSTANTS?.DEFAULT_UPDATE_TYPES ?? []),
]),
);
expect(updates).toContain("message_reaction");
expect(updates).toContain("channel_post");
expect(new Set(updates).size).toBe(updates.length);
});
});

View file

@ -0,0 +1,65 @@
import * as grammy from "grammy";
const FALLBACK_ALL_UPDATE_TYPES = [
"message",
"edited_message",
"channel_post",
"edited_channel_post",
"business_connection",
"business_message",
"edited_business_message",
"deleted_business_messages",
"message_reaction",
"message_reaction_count",
"inline_query",
"chosen_inline_result",
"callback_query",
"shipping_query",
"pre_checkout_query",
"poll",
"poll_answer",
"my_chat_member",
"chat_member",
"chat_join_request",
] as const;
const FALLBACK_DEFAULT_UPDATE_TYPES = [
"message",
"edited_message",
"channel_post",
"edited_channel_post",
"business_connection",
"business_message",
"edited_business_message",
"deleted_business_messages",
"message_reaction",
"message_reaction_count",
"inline_query",
"chosen_inline_result",
"callback_query",
"shipping_query",
"pre_checkout_query",
"poll",
"poll_answer",
"my_chat_member",
"chat_member",
"chat_join_request",
] as const;
export type TelegramUpdateType =
| (typeof FALLBACK_ALL_UPDATE_TYPES)[number]
| (typeof grammy.API_CONSTANTS.ALL_UPDATE_TYPES)[number];
export const DEFAULT_TELEGRAM_UPDATE_TYPES: ReadonlyArray<TelegramUpdateType> =
grammy.API_CONSTANTS?.DEFAULT_UPDATE_TYPES ?? FALLBACK_DEFAULT_UPDATE_TYPES;
export function resolveTelegramAllowedUpdates(): ReadonlyArray<TelegramUpdateType> {
const updates = [...DEFAULT_TELEGRAM_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,188 @@
import { createRequire } from "node:module";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { fetchTelegramChatId } from "./api-fetch.js";
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;
};
const proxyMocks = vi.hoisted(() => {
const undiciFetch = vi.fn();
const proxyAgentSpy = vi.fn();
const setGlobalDispatcher = vi.fn();
class ProxyAgent {
static lastCreated: ProxyAgent | undefined;
proxyUrl: string;
constructor(proxyUrl: string) {
this.proxyUrl = proxyUrl;
ProxyAgent.lastCreated = this;
proxyAgentSpy(proxyUrl);
}
}
return {
ProxyAgent,
undiciFetch,
proxyAgentSpy,
setGlobalDispatcher,
getLastAgent: () => ProxyAgent.lastCreated,
};
});
let getProxyUrlFromFetch: typeof import("./proxy.js").getProxyUrlFromFetch;
let makeProxyFetch: typeof import("./proxy.js").makeProxyFetch;
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();
});
vi.mock("undici", () => ({
ProxyAgent: proxyMocks.ProxyAgent,
fetch: proxyMocks.undiciFetch,
setGlobalDispatcher: proxyMocks.setGlobalDispatcher,
}));
describe("fetchTelegramChatId", () => {
const cases = [
{
name: "returns stringified id when Telegram getChat succeeds",
fetchImpl: vi.fn(async () => ({
ok: true,
json: async () => ({ ok: true, result: { id: 12345 } }),
})),
expected: "12345",
},
{
name: "returns null when response is not ok",
fetchImpl: vi.fn(async () => ({
ok: false,
json: async () => ({}),
})),
expected: null,
},
{
name: "returns null on transport failures",
fetchImpl: vi.fn(async () => {
throw new Error("network failed");
}),
expected: null,
},
] as const;
for (const testCase of cases) {
it(testCase.name, async () => {
vi.stubGlobal("fetch", testCase.fetchImpl);
const id = await fetchTelegramChatId({
token: "abc",
chatId: "@user",
});
expect(id).toBe(testCase.expected);
});
}
it("calls Telegram getChat endpoint", async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ ok: true, result: { id: 12345 } }),
}));
vi.stubGlobal("fetch", fetchMock);
await fetchTelegramChatId({ token: "abc", chatId: "@user" });
expect(fetchMock).toHaveBeenCalledWith(
"https://api.telegram.org/botabc/getChat?chat_id=%40user",
undefined,
);
});
it("uses caller-provided fetch impl when present", async () => {
const customFetch = vi.fn(async () => ({
ok: true,
json: async () => ({ ok: true, result: { id: 12345 } }),
}));
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("global fetch should not be called");
}),
);
await fetchTelegramChatId({
token: "abc",
chatId: "@user",
fetchImpl: customFetch as unknown as typeof fetch,
});
expect(customFetch).toHaveBeenCalledWith(
"https://api.telegram.org/botabc/getChat?chat_id=%40user",
undefined,
);
});
});
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),
);
});
});
describe("makeProxyFetch", () => {
beforeAll(async () => {
({ getProxyUrlFromFetch, makeProxyFetch } = await import("./proxy.js"));
});
beforeEach(() => {
proxyMocks.undiciFetch.mockReset();
proxyMocks.proxyAgentSpy.mockClear();
proxyMocks.setGlobalDispatcher.mockClear();
});
it("attaches proxy metadata for resolver transport handling", () => {
const proxyUrl = "http://proxy.test:8080";
const proxyFetch = makeProxyFetch(proxyUrl);
expect(getProxyUrlFromFetch(proxyFetch)).toBe(proxyUrl);
});
});

View file

@ -0,0 +1,61 @@
import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js";
import { makeProxyFetch } from "./proxy.js";
export function resolveTelegramChatLookupFetch(params?: {
proxyUrl?: string;
network?: TelegramNetworkConfig;
}): typeof fetch {
const proxyUrl = params?.proxyUrl?.trim();
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
return resolveTelegramFetch(proxyFetch, { network: params?.network });
}
export async function lookupTelegramChatId(params: {
token: string;
chatId: string;
signal?: AbortSignal;
apiRoot?: string;
proxyUrl?: string;
network?: TelegramNetworkConfig;
}): Promise<string | null> {
return fetchTelegramChatId({
token: params.token,
chatId: params.chatId,
signal: params.signal,
apiRoot: params.apiRoot,
fetchImpl: resolveTelegramChatLookupFetch({
proxyUrl: params.proxyUrl,
network: params.network,
}),
});
}
export async function fetchTelegramChatId(params: {
token: string;
chatId: string;
signal?: AbortSignal;
apiRoot?: string;
fetchImpl?: typeof fetch;
}): Promise<string | null> {
const apiBase = resolveTelegramApiBase(params.apiRoot);
const url = `${apiBase}/bot${params.token}/getChat?chat_id=${encodeURIComponent(params.chatId)}`;
const fetchImpl = params.fetchImpl ?? fetch;
try {
const res = await fetchImpl(url, params.signal ? { signal: params.signal } : undefined);
if (!res.ok) {
return null;
}
const data = (await res.json().catch(() => null)) as {
ok?: boolean;
result?: { id?: number | string };
} | null;
const id = data?.ok ? data?.result?.id : undefined;
if (typeof id === "number" || typeof id === "string") {
return String(id);
}
return null;
} catch {
return null;
}
}

View file

@ -0,0 +1,44 @@
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
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(`telegram ${operation} failed: ${errText}`);
}
throw err;
}
}

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
fitsTelegramCallbackData,
rewriteTelegramApprovalDecisionAlias,
sanitizeTelegramCallbackData,
} from "./approval-callback-data.js";
describe("approval callback data", () => {
it("enforces Telegram callback byte boundaries", () => {
expect(fitsTelegramCallbackData("x".repeat(63))).toBe(true);
expect(fitsTelegramCallbackData("x".repeat(64))).toBe(true);
expect(fitsTelegramCallbackData("x".repeat(65))).toBe(false);
});
it("rewrites /approve allow-always callbacks to always", () => {
const approvalId = `plugin:${"a".repeat(36)}`;
expect(rewriteTelegramApprovalDecisionAlias(`/approve ${approvalId} allow-always`)).toBe(
`/approve ${approvalId} always`,
);
});
it("keeps rewritten allow-always callbacks when canonical form would overflow", () => {
const approvalId = `plugin:${"a".repeat(36)}`;
expect(sanitizeTelegramCallbackData(`/approve ${approvalId} allow-always`)).toBe(
`/approve ${approvalId} always`,
);
});
it("keeps 64-byte callbacks and drops 65-byte callbacks through sanitize", () => {
expect(sanitizeTelegramCallbackData("x".repeat(64))).toBe("x".repeat(64));
expect(sanitizeTelegramCallbackData("x".repeat(65))).toBeUndefined();
});
});

View file

@ -0,0 +1,23 @@
export const TELEGRAM_CALLBACK_DATA_MAX_BYTES = 64;
const TELEGRAM_APPROVE_ALLOW_ALWAYS_PATTERN =
/^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+allow-always$/i;
export function fitsTelegramCallbackData(value: string): boolean {
return Buffer.byteLength(value, "utf8") <= TELEGRAM_CALLBACK_DATA_MAX_BYTES;
}
export function rewriteTelegramApprovalDecisionAlias(value: string): string {
if (!value.endsWith(" allow-always")) {
return value;
}
if (!TELEGRAM_APPROVE_ALLOW_ALWAYS_PATTERN.test(value)) {
return value;
}
return value.slice(0, -"allow-always".length) + "always";
}
export function sanitizeTelegramCallbackData(value: string): string | undefined {
const rewritten = rewriteTelegramApprovalDecisionAlias(value);
return fitsTelegramCallbackData(rewritten) ? rewritten : undefined;
}

View file

@ -0,0 +1,126 @@
import { describe, expect, it, vi } from "vitest";
import { telegramApprovalNativeRuntime } from "./approval-handler.runtime.js";
type TelegramPayload = {
text: string;
buttons?: Array<Array<{ text: string }>>;
};
describe("telegramApprovalNativeRuntime", () => {
it("renders only the allowed pending buttons", async () => {
const payload = (await telegramApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: {
token: "tg-token",
},
request: {
id: "req-1",
request: {
command: "echo hi",
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "exec",
nowMs: 0,
view: {
approvalKind: "exec",
approvalId: "req-1",
commandText: "echo hi",
actions: [
{
decision: "allow-once",
label: "Allow Once",
command: "/approve req-1 allow-once",
style: "success",
},
{
decision: "deny",
label: "Deny",
command: "/approve req-1 deny",
style: "danger",
},
],
} as never,
})) as TelegramPayload;
expect(payload.text).toContain("/approve req-1 allow-once");
expect(payload.text).not.toContain("allow-always");
expect(payload.buttons?.[0]?.map((button) => button.text)).toEqual(["Allow Once", "Deny"]);
});
it("passes topic thread ids to typing and message delivery", async () => {
const sendTyping = vi.fn().mockResolvedValue({ ok: true });
const sendMessage = vi.fn().mockResolvedValue({
chatId: "-1003841603622",
messageId: "m1",
});
const entry = await telegramApprovalNativeRuntime.transport.deliverPending({
cfg: {} as never,
accountId: "default",
context: {
token: "tg-token",
deps: {
sendTyping,
sendMessage,
},
},
plannedTarget: {
surface: "origin",
reason: "preferred",
target: {
to: "-1003841603622",
threadId: 928,
},
},
preparedTarget: {
chatId: "-1003841603622",
messageThreadId: 928,
},
request: {
id: "req-1",
request: {
command: "echo hi",
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "exec",
view: {
approvalKind: "exec",
approvalId: "req-1",
commandText: "echo hi",
actions: [],
} as never,
pendingPayload: {
text: "pending",
buttons: [],
},
});
expect(sendTyping).toHaveBeenCalledWith(
"-1003841603622",
expect.objectContaining({
token: "tg-token",
accountId: "default",
messageThreadId: 928,
}),
);
expect(sendMessage).toHaveBeenCalledWith(
"-1003841603622",
"pending",
expect.objectContaining({
token: "tg-token",
accountId: "default",
messageThreadId: 928,
buttons: [],
}),
);
expect(entry).toEqual({
chatId: "-1003841603622",
messageId: "m1",
});
});
});

View file

@ -0,0 +1,189 @@
import type {
ChannelApprovalCapabilityHandlerContext,
PendingApprovalView,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import { buildPluginApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime";
import {
buildApprovalInteractiveReplyFromActionDescriptors,
buildExecApprovalPendingReplyPayload,
type ExecApprovalPendingReplyParams,
type ExecApprovalRequest,
type PluginApprovalRequest,
} from "openclaw/plugin-sdk/infra-runtime";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { resolveTelegramInlineButtons } from "./button-types.js";
import {
isTelegramExecApprovalHandlerConfigured,
shouldHandleTelegramExecApprovalRequest,
} from "./exec-approvals.js";
import { editMessageReplyMarkupTelegram, sendMessageTelegram, sendTypingTelegram } from "./send.js";
const log = createSubsystemLogger("telegram/approvals");
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type PendingMessage = {
chatId: string;
messageId: string;
};
type TelegramPendingDelivery = {
text: string;
buttons: ReturnType<typeof resolveTelegramInlineButtons>;
};
export type TelegramExecApprovalHandlerDeps = {
nowMs?: () => number;
sendTyping?: typeof sendTypingTelegram;
sendMessage?: typeof sendMessageTelegram;
editReplyMarkup?: typeof editMessageReplyMarkupTelegram;
};
export type TelegramApprovalHandlerContext = {
token: string;
deps?: TelegramExecApprovalHandlerDeps;
};
function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): {
accountId: string;
context: TelegramApprovalHandlerContext;
} | null {
const context = params.context as TelegramApprovalHandlerContext | undefined;
const accountId = normalizeOptionalString(params.accountId) ?? "";
if (!context?.token || !accountId) {
return null;
}
return { accountId, context };
}
function buildPendingPayload(params: {
request: ApprovalRequest;
approvalKind: "exec" | "plugin";
nowMs: number;
view: PendingApprovalView;
}): TelegramPendingDelivery {
const payload =
params.approvalKind === "plugin"
? buildPluginApprovalPendingReplyPayload({
request: params.request as PluginApprovalRequest,
nowMs: params.nowMs,
})
: buildExecApprovalPendingReplyPayload({
approvalId: params.request.id,
approvalSlug: params.request.id.slice(0, 8),
approvalCommandId: params.request.id,
command: params.view.approvalKind === "exec" ? params.view.commandText : "",
cwd: params.view.approvalKind === "exec" ? (params.view.cwd ?? undefined) : undefined,
host:
params.view.approvalKind === "exec" && params.view.host === "node" ? "node" : "gateway",
nodeId:
params.view.approvalKind === "exec" ? (params.view.nodeId ?? undefined) : undefined,
allowedDecisions: params.view.actions.map((action) => action.decision),
expiresAtMs: params.request.expiresAtMs,
nowMs: params.nowMs,
} satisfies ExecApprovalPendingReplyParams);
return {
text: payload.text ?? "",
buttons: resolveTelegramInlineButtons({
interactive: buildApprovalInteractiveReplyFromActionDescriptors(params.view.actions),
}),
};
}
export const telegramApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
TelegramPendingDelivery,
{ chatId: string; messageThreadId?: number },
PendingMessage,
never
>({
eventKinds: ["exec", "plugin"],
availability: {
isConfigured: (params) => {
const resolved = resolveHandlerContext(params);
return resolved
? isTelegramExecApprovalHandlerConfigured({
cfg: params.cfg,
accountId: resolved.accountId,
})
: false;
},
shouldHandle: (params) => {
const resolved = resolveHandlerContext(params);
return resolved
? shouldHandleTelegramExecApprovalRequest({
cfg: params.cfg,
accountId: resolved.accountId,
request: params.request,
})
: false;
},
},
presentation: {
buildPendingPayload: ({ request, approvalKind, nowMs, view }) =>
buildPendingPayload({ request, approvalKind, nowMs, view }),
buildResolvedResult: () => ({ kind: "clear-actions" }),
buildExpiredResult: () => ({ kind: "clear-actions" }),
},
transport: {
prepareTarget: ({ plannedTarget }) => ({
dedupeKey: buildChannelApprovalNativeTargetKey(plannedTarget.target),
target: {
chatId: plannedTarget.target.to,
messageThreadId:
typeof plannedTarget.target.threadId === "number"
? plannedTarget.target.threadId
: undefined,
},
}),
deliverPending: async ({ cfg, accountId, context, preparedTarget, pendingPayload }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return null;
}
const sendTyping = resolved.context.deps?.sendTyping ?? sendTypingTelegram;
const sendMessage = resolved.context.deps?.sendMessage ?? sendMessageTelegram;
await sendTyping(preparedTarget.chatId, {
cfg,
token: resolved.context.token,
accountId: resolved.accountId,
...(preparedTarget.messageThreadId != null
? { messageThreadId: preparedTarget.messageThreadId }
: {}),
}).catch(() => {});
const result = await sendMessage(preparedTarget.chatId, pendingPayload.text, {
cfg,
token: resolved.context.token,
accountId: resolved.accountId,
buttons: pendingPayload.buttons,
...(preparedTarget.messageThreadId != null
? { messageThreadId: preparedTarget.messageThreadId }
: {}),
});
return {
chatId: result.chatId,
messageId: result.messageId,
};
},
},
interactions: {
clearPendingActions: async ({ cfg, accountId, context, entry }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return;
}
const editReplyMarkup =
resolved.context.deps?.editReplyMarkup ?? editMessageReplyMarkupTelegram;
await editReplyMarkup(entry.chatId, entry.messageId, [], {
cfg,
token: resolved.context.token,
accountId: resolved.accountId,
});
},
},
observe: {
onDeliveryError: ({ error, request }) => {
log.error(`telegram approvals: failed to send request ${request.id}: ${String(error)}`);
},
},
});

View file

@ -0,0 +1,215 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import { clearSessionStoreCacheForTest } from "../../../src/config/sessions/store.js";
import { telegramApprovalCapability, telegramNativeApprovalAdapter } from "./approval-native.js";
function buildConfig(
overrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["telegram"]>>,
): OpenClawConfig {
return {
channels: {
telegram: {
botToken: "tok",
execApprovals: {
enabled: true,
approvers: ["8460800771"],
target: "dm",
},
...overrides,
},
},
} as OpenClawConfig;
}
const STORE_PATH = path.join(os.tmpdir(), "openclaw-telegram-approval-native-test.json");
function writeStore(store: Record<string, unknown>) {
fs.writeFileSync(STORE_PATH, `${JSON.stringify(store, null, 2)}\n`, "utf8");
clearSessionStoreCacheForTest();
}
describe("telegram native approval adapter", () => {
it("describes the correct Telegram exec-approval setup path", () => {
const text = telegramApprovalCapability.describeExecApprovalSetup?.({
channel: "telegram",
channelLabel: "Telegram",
});
expect(text).toContain("`channels.telegram.execApprovals.approvers`");
expect(text).toContain("`channels.telegram.allowFrom`");
expect(text).toContain("`channels.telegram.defaultTo`");
expect(text).not.toContain("`channels.telegram.dm.allowFrom`");
});
it("describes the named-account Telegram exec-approval setup path", () => {
const text = telegramApprovalCapability.describeExecApprovalSetup?.({
channel: "telegram",
channelLabel: "Telegram",
accountId: "work",
});
expect(text).toContain("`channels.telegram.accounts.work.execApprovals.approvers`");
expect(text).toContain("`channels.telegram.accounts.work.allowFrom`");
expect(text).toContain("`channels.telegram.accounts.work.defaultTo`");
expect(text).not.toContain("`channels.telegram.allowFrom`");
});
it("normalizes direct-chat origin targets so DM dedupe can converge", async () => {
const target = await telegramNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "telegram",
turnSourceTo: "telegram:8460800771",
turnSourceAccountId: "default",
sessionKey: "agent:main:telegram:direct:8460800771",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "8460800771",
threadId: undefined,
});
});
it("parses topic-scoped turn-source targets in the extension", async () => {
const target = await telegramNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-topic-1",
request: {
command: "echo hi",
turnSourceChannel: "telegram",
turnSourceTo: "telegram:-1003841603622:topic:928",
turnSourceAccountId: "default",
sessionKey: "agent:main:telegram:group:-1003841603622:topic:928",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "-1003841603622",
threadId: 928,
});
});
it("falls back to the session-bound origin target for plugin approvals", async () => {
writeStore({
"agent:main:telegram:group:-1003841603622:topic:928": {
sessionId: "sess",
updatedAt: Date.now(),
deliveryContext: {
channel: "telegram",
to: "-1003841603622",
accountId: "default",
threadId: 928,
},
},
});
const target = await telegramNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg: {
...buildConfig(),
session: { store: STORE_PATH },
},
accountId: "default",
approvalKind: "plugin",
request: {
id: "plugin:req-1",
request: {
title: "Plugin approval",
description: "Allow access",
sessionKey: "agent:main:telegram:group:-1003841603622:topic:928",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "-1003841603622",
threadId: 928,
});
});
it("parses numeric string thread ids from the session store for plugin approvals", async () => {
writeStore({
"agent:main:telegram:group:-1003841603622:topic:928": {
sessionId: "sess",
updatedAt: Date.now(),
deliveryContext: {
channel: "telegram",
to: "-1003841603622",
accountId: "default",
threadId: "928",
},
},
});
const target = await telegramNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg: {
...buildConfig(),
session: { store: STORE_PATH },
},
accountId: "default",
approvalKind: "plugin",
request: {
id: "plugin:req-2",
request: {
title: "Plugin approval",
description: "Allow access",
sessionKey: "agent:main:telegram:group:-1003841603622:topic:928",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "-1003841603622",
threadId: 928,
});
});
it("marks DM-only telegram approvals to notify the origin chat after delivery", () => {
const capabilities = telegramNativeApprovalAdapter.native?.describeDeliveryCapabilities({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-dm-1",
request: {
command: "echo hi",
turnSourceChannel: "telegram",
turnSourceTo: "telegram:-1003841603622:topic:928",
turnSourceAccountId: "default",
turnSourceThreadId: 928,
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(capabilities).toEqual({
enabled: true,
preferredSurface: "approver-dm",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: true,
});
});
});

View file

@ -0,0 +1,172 @@
import {
createApproverRestrictedNativeApprovalCapability,
splitChannelApprovalCapability,
} from "openclaw/plugin-sdk/approval-delivery-runtime";
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import {
createChannelApproverDmTargetResolver,
createChannelNativeOriginTargetResolver,
} from "openclaw/plugin-sdk/approval-native-runtime";
import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import { listTelegramAccountIds } from "./accounts.js";
import {
getTelegramExecApprovalApprovers,
isTelegramExecApprovalApprover,
isTelegramExecApprovalAuthorizedSender,
isTelegramExecApprovalClientEnabled,
isTelegramExecApprovalTargetRecipient,
resolveTelegramExecApprovalTarget,
shouldHandleTelegramExecApprovalRequest,
} from "./exec-approvals.js";
import { parseTelegramThreadId } from "./outbound-params.js";
import { normalizeTelegramChatId, parseTelegramTarget } from "./targets.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type TelegramOriginTarget = { to: string; threadId?: number };
function resolveTurnSourceTelegramOriginTarget(
request: ApprovalRequest,
): TelegramOriginTarget | null {
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
const rawTurnSourceTo = normalizeOptionalString(request.request.turnSourceTo) ?? "";
const parsedTurnSourceTarget = rawTurnSourceTo ? parseTelegramTarget(rawTurnSourceTo) : null;
const turnSourceTo = normalizeTelegramChatId(parsedTurnSourceTarget?.chatId ?? rawTurnSourceTo);
if (turnSourceChannel !== "telegram" || !turnSourceTo) {
return null;
}
const rawThreadId =
request.request.turnSourceThreadId ?? parsedTurnSourceTarget?.messageThreadId ?? undefined;
return {
to: turnSourceTo,
threadId: parseTelegramThreadId(rawThreadId),
};
}
function resolveSessionTelegramOriginTarget(sessionTarget: {
to: string;
threadId?: string | number | null;
}): TelegramOriginTarget {
return {
to: normalizeTelegramChatId(sessionTarget.to) ?? sessionTarget.to,
threadId: parseTelegramThreadId(sessionTarget.threadId),
};
}
function telegramTargetsMatch(a: TelegramOriginTarget, b: TelegramOriginTarget): boolean {
const normalizedA = normalizeTelegramChatId(a.to) ?? a.to;
const normalizedB = normalizeTelegramChatId(b.to) ?? b.to;
return normalizedA === normalizedB && a.threadId === b.threadId;
}
const resolveTelegramOriginTarget = createChannelNativeOriginTargetResolver({
channel: "telegram",
shouldHandleRequest: ({ cfg, accountId, request }) =>
shouldHandleTelegramExecApprovalRequest({
cfg,
accountId,
request,
}),
resolveTurnSourceTarget: resolveTurnSourceTelegramOriginTarget,
resolveSessionTarget: resolveSessionTelegramOriginTarget,
targetsMatch: telegramTargetsMatch,
});
const resolveTelegramApproverDmTargets = createChannelApproverDmTargetResolver({
shouldHandleRequest: ({ cfg, accountId, request }) =>
shouldHandleTelegramExecApprovalRequest({
cfg,
accountId,
request,
}),
resolveApprovers: getTelegramExecApprovalApprovers,
mapApprover: (approver) => ({ to: approver }),
});
const telegramNativeApprovalCapability = createApproverRestrictedNativeApprovalCapability({
channel: "telegram",
channelLabel: "Telegram",
describeExecApprovalSetup: ({ accountId }: { accountId?: string | null }) => {
const prefix =
accountId && accountId !== "default"
? `channels.telegram.accounts.${accountId}`
: "channels.telegram";
return `Approve it from the Web UI or terminal UI for now. Telegram supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\`; if you leave it unset, OpenClaw can infer numeric owner IDs from \`${prefix}.allowFrom\` or direct-message \`${prefix}.defaultTo\` when possible. Leave \`${prefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
},
listAccountIds: listTelegramAccountIds,
hasApprovers: ({ cfg, accountId }) =>
getTelegramExecApprovalApprovers({ cfg, accountId }).length > 0,
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
isTelegramExecApprovalAuthorizedSender({ cfg, accountId, senderId }),
isPluginAuthorizedSender: ({ cfg, accountId, senderId }) =>
isTelegramExecApprovalApprover({ cfg, accountId, senderId }),
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
isTelegramExecApprovalClientEnabled({ cfg, accountId }),
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
resolveTelegramExecApprovalTarget({ cfg, accountId }),
requireMatchingTurnSourceChannel: true,
resolveSuppressionAccountId: ({ target, request }) =>
normalizeOptionalString(target.accountId) ??
normalizeOptionalString(request.request.turnSourceAccountId),
resolveOriginTarget: resolveTelegramOriginTarget,
resolveApproverDmTargets: resolveTelegramApproverDmTargets,
notifyOriginWhenDmOnly: true,
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
eventKinds: ["exec", "plugin"],
isConfigured: ({ cfg, accountId }) =>
isTelegramExecApprovalClientEnabled({
cfg,
accountId,
}),
shouldHandle: ({ cfg, accountId, request }) =>
shouldHandleTelegramExecApprovalRequest({
cfg,
accountId,
request,
}),
load: async () =>
(await import("./approval-handler.runtime.js"))
.telegramApprovalNativeRuntime as unknown as ChannelApprovalNativeRuntimeAdapter,
}),
});
const resolveTelegramApproveCommandBehavior: NonNullable<
ChannelApprovalCapability["resolveApproveCommandBehavior"]
> = (
params: Parameters<NonNullable<ChannelApprovalCapability["resolveApproveCommandBehavior"]>>[0],
) => {
const { cfg, accountId, senderId, approvalKind } = params;
if (approvalKind !== "exec") {
return undefined;
}
if (isTelegramExecApprovalClientEnabled({ cfg, accountId })) {
return undefined;
}
if (isTelegramExecApprovalTargetRecipient({ cfg, accountId, senderId })) {
return undefined;
}
if (
isTelegramExecApprovalAuthorizedSender({ cfg, accountId, senderId }) &&
!isTelegramExecApprovalApprover({ cfg, accountId, senderId })
) {
return undefined;
}
return {
kind: "reply",
text: "❌ Telegram exec approvals are not enabled for this bot account.",
};
};
export const telegramApprovalCapability: ChannelApprovalCapability = {
...telegramNativeApprovalCapability,
resolveApproveCommandBehavior: resolveTelegramApproveCommandBehavior,
};
export const telegramNativeApprovalAdapter = splitChannelApprovalCapability(
telegramApprovalCapability,
);

View file

@ -0,0 +1,78 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime";
import type {
AuditTelegramGroupMembershipParams,
TelegramGroupMembershipAudit,
TelegramGroupMembershipAuditEntry,
} from "./audit.types.js";
import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js";
import { makeProxyFetch } from "./proxy.js";
type TelegramApiOk<T> = { ok: true; result: T };
type TelegramApiErr = { ok: false; description?: string };
type TelegramGroupMembershipAuditData = Omit<TelegramGroupMembershipAudit, "elapsedMs">;
type TelegramChatMemberResult = { status?: string };
export async function auditTelegramGroupMembershipImpl(
params: AuditTelegramGroupMembershipParams,
): Promise<TelegramGroupMembershipAuditData> {
const proxyFetch = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : undefined;
const fetcher = resolveTelegramFetch(proxyFetch, {
network: params.network,
});
const apiBase = resolveTelegramApiBase(params.apiRoot);
const base = `${apiBase}/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<TelegramChatMemberResult> | 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.result) && typeof json.result.status === "string" ? json.result.status : 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: formatErrorMessage(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,85 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
let collectTelegramUnmentionedGroupIds: typeof import("./audit.js").collectTelegramUnmentionedGroupIds;
let auditTelegramGroupMembership: typeof import("./audit.js").auditTelegramGroupMembership;
const fetchWithTimeoutMock = vi.hoisted(() => vi.fn());
const resolveTelegramFetchMock = vi.hoisted(() => vi.fn(() => fetchWithTimeoutMock));
const resolveTelegramApiBaseMock = vi.hoisted(() => vi.fn(() => "https://api.telegram.org"));
vi.mock("openclaw/plugin-sdk/text-runtime", () => ({
fetchWithTimeout: fetchWithTimeoutMock,
isRecord: (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null,
normalizeOptionalString: (value: unknown) => {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
},
}));
function mockGetChatMemberStatus(status: string) {
fetchWithTimeoutMock.mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true, result: { status } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}
async function auditSingleGroup() {
return auditTelegramGroupMembership({
token: "t",
botId: 123,
groupIds: ["-1001"],
timeoutMs: 5000,
});
}
describe("telegram audit", () => {
beforeAll(async () => {
vi.doMock("./fetch.js", () => ({
resolveTelegramApiBase: resolveTelegramApiBaseMock,
resolveTelegramFetch: resolveTelegramFetchMock,
}));
({ collectTelegramUnmentionedGroupIds, auditTelegramGroupMembership } =
await import("./audit.js"));
});
beforeEach(() => {
fetchWithTimeoutMock.mockReset();
resolveTelegramFetchMock.mockClear();
resolveTelegramApiBaseMock.mockClear();
});
it("collects unmentioned numeric group ids and flags wildcard", async () => {
const res = collectTelegramUnmentionedGroupIds({
"*": { requireMention: false },
"-1001": { requireMention: false },
"@group": { requireMention: false },
"-1002": { requireMention: true },
"-1003": { requireMention: false, enabled: false },
});
expect(res.hasWildcardUnmentionedGroups).toBe(true);
expect(res.groupIds).toEqual(["-1001"]);
expect(res.unresolvedGroups).toBe(1);
});
it("audits membership via getChatMember", async () => {
mockGetChatMemberStatus("member");
const res = await auditSingleGroup();
expect(res.ok).toBe(true);
expect(res.groups[0]?.chatId).toBe("-1001");
expect(res.groups[0]?.status).toBe("member");
expect(resolveTelegramFetchMock).toHaveBeenCalled();
});
it("reports bot not in group when status is left", async () => {
mockGetChatMemberStatus("left");
const res = await auditSingleGroup();
expect(res.ok).toBe(false);
expect(res.groups[0]?.ok).toBe(false);
expect(res.groups[0]?.status).toBe("left");
});
});

View file

@ -0,0 +1,89 @@
import type { TelegramGroupConfig } from "openclaw/plugin-sdk/config-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
export type {
AuditTelegramGroupMembershipParams,
TelegramGroupMembershipAudit,
TelegramGroupMembershipAuditEntry,
} from "./audit.types.js";
import type {
AuditTelegramGroupMembershipParams,
TelegramGroupMembershipAudit,
} from "./audit.types.js";
export function collectTelegramUnmentionedGroupIds(
groups: Record<string, TelegramGroupConfig> | undefined,
) {
if (!groups || typeof groups !== "object") {
return {
groupIds: [] as string[],
unresolvedGroups: 0,
hasWildcardUnmentionedGroups: false,
};
}
const hasWildcardUnmentionedGroups =
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 = normalizeOptionalString(key) ?? "";
if (!id) {
continue;
}
if (/^-?\d+$/.test(id)) {
groupIds.push(id);
} else {
unresolvedGroups += 1;
}
}
groupIds.sort((a, b) => a.localeCompare(b));
return { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups };
}
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 = normalizeOptionalString(params.token) ?? "";
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,29 @@
import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/config-runtime";
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 type AuditTelegramGroupMembershipParams = {
token: string;
botId: number;
groupIds: string[];
proxyUrl?: string;
network?: TelegramNetworkConfig;
apiRoot?: string;
timeoutMs: number;
};

View file

@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
const generateConversationLabel = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
generateConversationLabel,
}));
import {
AUTO_TOPIC_LABEL_DEFAULT_PROMPT,
generateTelegramTopicLabel,
resolveAutoTopicLabelConfig,
} from "./auto-topic-label.js";
describe("resolveAutoTopicLabelConfig", () => {
it("returns enabled with default prompt when configs are undefined", () => {
const result = resolveAutoTopicLabelConfig(undefined, undefined);
expect(result).toEqual({ enabled: true, prompt: AUTO_TOPIC_LABEL_DEFAULT_PROMPT });
});
it("prefers direct config over account config", () => {
expect(resolveAutoTopicLabelConfig(false, true)).toBeNull();
expect(
resolveAutoTopicLabelConfig({ prompt: "DM prompt" }, { prompt: "Account prompt" }),
).toEqual({
enabled: true,
prompt: "DM prompt",
});
});
it("falls back to default prompt for empty object prompt", () => {
expect(resolveAutoTopicLabelConfig({ enabled: true, prompt: " " }, undefined)).toEqual({
enabled: true,
prompt: AUTO_TOPIC_LABEL_DEFAULT_PROMPT,
});
});
});
describe("generateTelegramTopicLabel", () => {
it("delegates to the generic conversation label helper with telegram max length", async () => {
generateConversationLabel.mockResolvedValue("Billing");
await expect(
generateTelegramTopicLabel({
userMessage: "Need help with invoices",
prompt: "prompt",
cfg: {},
agentId: "billing",
}),
).resolves.toBe("Billing");
expect(generateConversationLabel).toHaveBeenCalledWith({
userMessage: "Need help with invoices",
prompt: "prompt",
cfg: {},
agentId: "billing",
maxLength: 128,
});
});
});

View file

@ -0,0 +1,39 @@
import type {
OpenClawConfig,
TelegramAccountConfig,
TelegramDirectConfig,
} from "openclaw/plugin-sdk/config-runtime";
import { generateConversationLabel } from "openclaw/plugin-sdk/reply-runtime";
export const AUTO_TOPIC_LABEL_DEFAULT_PROMPT =
"Generate a very short topic label (2-4 words, max 25 chars) for a chat conversation based on the user's first message below. No emoji. Use the same language as the message. Be concise and descriptive. Return ONLY the topic name, nothing else.";
export function resolveAutoTopicLabelConfig(
directConfig?: TelegramDirectConfig["autoTopicLabel"],
accountConfig?: TelegramAccountConfig["autoTopicLabel"],
): { enabled: true; prompt: string } | null {
const config = directConfig ?? accountConfig;
if (config === undefined || config === true) {
return { enabled: true, prompt: AUTO_TOPIC_LABEL_DEFAULT_PROMPT };
}
if (config === false || config.enabled === false) {
return null;
}
return {
enabled: true,
prompt: config.prompt?.trim() || AUTO_TOPIC_LABEL_DEFAULT_PROMPT,
};
}
export async function generateTelegramTopicLabel(params: {
userMessage: string;
prompt: string;
cfg: OpenClawConfig;
agentId?: string;
agentDir?: string;
}): Promise<string | null> {
return await generateConversationLabel({
...params,
maxLength: 128,
});
}

View file

@ -0,0 +1,97 @@
import {
firstDefined,
isSenderIdAllowed,
mergeDmAllowFromSources,
type AllowlistMatch,
} from "openclaw/plugin-sdk/allow-from";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
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 setup (it resolves @username to IDs) or replace them manually.',
].join(" "),
);
}
}
export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAllowFrom => {
const entries = (list ?? [])
.map((value) => normalizeOptionalString(String(value)) ?? "")
.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 };
};

View file

@ -0,0 +1,94 @@
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { loadConfig, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import { loadSessionStore } from "openclaw/plugin-sdk/config-runtime";
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
import { buildModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime";
import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/skill-commands-runtime";
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
import { syncTelegramMenuCommands } from "./bot-native-command-menu.js";
import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js";
import { createTelegramDraftStream } from "./draft-stream.js";
import { resolveTelegramExecApproval } from "./exec-approval-resolver.js";
import { editMessageTelegram } from "./send.js";
import { wasSentByBot } from "./sent-message-cache.js";
export type TelegramBotDeps = {
loadConfig: typeof loadConfig;
resolveStorePath: typeof resolveStorePath;
loadSessionStore?: typeof loadSessionStore;
readChannelAllowFromStore: typeof readChannelAllowFromStore;
upsertChannelPairingRequest: typeof upsertChannelPairingRequest;
enqueueSystemEvent: typeof enqueueSystemEvent;
dispatchReplyWithBufferedBlockDispatcher: typeof dispatchReplyWithBufferedBlockDispatcher;
loadWebMedia?: typeof loadWebMedia;
buildModelsProviderData: typeof buildModelsProviderData;
listSkillCommandsForAgents: typeof listSkillCommandsForAgents;
syncTelegramMenuCommands?: typeof syncTelegramMenuCommands;
wasSentByBot: typeof wasSentByBot;
resolveExecApproval?: typeof resolveTelegramExecApproval;
createTelegramDraftStream?: typeof createTelegramDraftStream;
deliverReplies?: typeof deliverReplies;
emitInternalMessageSentHook?: typeof emitInternalMessageSentHook;
editMessageTelegram?: typeof editMessageTelegram;
createChannelReplyPipeline?: typeof createChannelReplyPipeline;
};
export const defaultTelegramBotDeps: TelegramBotDeps = {
get loadConfig() {
return loadConfig;
},
get resolveStorePath() {
return resolveStorePath;
},
get readChannelAllowFromStore() {
return readChannelAllowFromStore;
},
get loadSessionStore() {
return loadSessionStore;
},
get upsertChannelPairingRequest() {
return upsertChannelPairingRequest;
},
get enqueueSystemEvent() {
return enqueueSystemEvent;
},
get dispatchReplyWithBufferedBlockDispatcher() {
return dispatchReplyWithBufferedBlockDispatcher;
},
get loadWebMedia() {
return loadWebMedia;
},
get buildModelsProviderData() {
return buildModelsProviderData;
},
get listSkillCommandsForAgents() {
return listSkillCommandsForAgents;
},
get syncTelegramMenuCommands() {
return syncTelegramMenuCommands;
},
get wasSentByBot() {
return wasSentByBot;
},
get resolveExecApproval() {
return resolveTelegramExecApproval;
},
get createTelegramDraftStream() {
return createTelegramDraftStream;
},
get deliverReplies() {
return deliverReplies;
},
get emitInternalMessageSentHook() {
return emitInternalMessageSentHook;
},
get editMessageTelegram() {
return editMessageTelegram;
},
get createChannelReplyPipeline() {
return createChannelReplyPipeline;
},
};

View file

@ -0,0 +1,5 @@
export {
resolveAgentDir,
resolveDefaultAgentId,
resolveDefaultModelForAgent,
} from "openclaw/plugin-sdk/agent-runtime";

View file

@ -0,0 +1,9 @@
export function buildTelegramInboundDebounceKey(params: {
accountId?: string | null;
conversationKey: string;
senderId: string;
debounceLane: "default" | "forward";
}): string {
const resolvedAccountId = params.accountId?.trim() || "default";
return `telegram:${resolvedAccountId}:${params.conversationKey}:${params.senderId}:${params.debounceLane}`;
}

View file

@ -0,0 +1,40 @@
import type { Message } from "@grammyjs/types";
import { MediaFetchError } from "openclaw/plugin-sdk/media-runtime";
export const APPROVE_CALLBACK_DATA_RE =
/^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(allow-once|allow-always|deny)\b/i;
export function isMediaSizeLimitError(err: unknown): boolean {
const errMsg = String(err);
return errMsg.includes("exceeds") && errMsg.includes("MB limit");
}
export function isRecoverableMediaGroupError(err: unknown): boolean {
return err instanceof MediaFetchError || isMediaSizeLimitError(err);
}
export function hasInboundMedia(msg: Message): boolean {
return (
Boolean(msg.media_group_id) ||
(Array.isArray(msg.photo) && msg.photo.length > 0) ||
Boolean(msg.video ?? msg.video_note ?? msg.document ?? msg.audio ?? msg.voice ?? msg.sticker)
);
}
export function hasReplyTargetMedia(msg: Message): boolean {
const externalReply = (msg as Message & { external_reply?: Message }).external_reply;
const replyTarget = msg.reply_to_message ?? externalReply;
return Boolean(replyTarget && hasInboundMedia(replyTarget));
}
export function resolveInboundMediaFileId(msg: Message): string | undefined {
return (
msg.sticker?.file_id ??
msg.photo?.[msg.photo.length - 1]?.file_id ??
msg.video?.file_id ??
msg.video_note?.file_id ??
msg.document?.file_id ??
msg.audio?.file_id ??
msg.voice?.file_id
);
}

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { buildTelegramInboundDebounceKey } from "./bot-handlers.debounce-key.js";
describe("buildTelegramInboundDebounceKey", () => {
it("uses the resolved account id instead of literal default when provided", () => {
expect(
buildTelegramInboundDebounceKey({
accountId: "work",
conversationKey: "12345",
senderId: "67890",
debounceLane: "default",
}),
).toBe("telegram:work:12345:67890:default");
});
it("falls back to literal default only when account id is actually absent", () => {
expect(
buildTelegramInboundDebounceKey({
accountId: undefined,
conversationKey: "12345",
senderId: "67890",
debounceLane: "forward",
}),
).toBe("telegram:default:12345:67890:forward");
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
export * from "./bot-handlers.runtime.js";

View file

@ -0,0 +1,245 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn());
const recordInboundSessionMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const resolveTelegramConversationRouteMock = vi.hoisted(() => vi.fn());
vi.mock("./conversation-route.js", async () => {
const actual =
await vi.importActual<typeof import("./conversation-route.js")>("./conversation-route.js");
return {
...actual,
resolveTelegramConversationRoute: (...args: unknown[]) =>
resolveTelegramConversationRouteMock(...args),
};
});
let buildTelegramMessageContextForTest: typeof import("./bot-message-context.test-harness.js").buildTelegramMessageContextForTest;
const configuredBindingRuntime = {
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
ensureConfiguredBindingRouteReadyMock(...args),
} as NonNullable<
import("./bot-message-context.types.js").BuildTelegramMessageContextParams["runtime"]
>;
const configuredBindingSessionRuntime = {
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
} as NonNullable<
import("./bot-message-context.types.js").BuildTelegramMessageContextParams["sessionRuntime"]
>;
function createConfiguredTelegramBinding() {
return {
spec: {
channel: "telegram",
accountId: "work",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:telegram:work:-1001234567890:topic:42",
targetSessionKey: "agent:codex:acp:binding:telegram:work:abc123",
targetKind: "session",
conversation: {
channel: "telegram",
accountId: "work",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
status: "active",
boundAt: 0,
metadata: {
source: "config",
mode: "persistent",
agentId: "codex",
},
},
} as const;
}
function createConfiguredTelegramRoute() {
const configuredBinding = createConfiguredTelegramBinding();
return {
configuredBinding: {
conversation: {
channel: "telegram",
accountId: "work",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
compiledBinding: {
channel: "telegram",
accountPattern: "work",
binding: {
type: "acp",
agentId: "codex",
match: {
channel: "telegram",
accountId: "work",
peer: {
kind: "group",
id: "-1001234567890:topic:42",
},
},
},
bindingConversationId: "-1001234567890:topic:42",
target: {
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
agentId: "codex",
provider: {
compileConfiguredBinding: () => ({
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
}),
matchInboundConversation: () => ({
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
}),
},
targetFactory: {
driverId: "acp",
materialize: () => ({
record: configuredBinding.record,
statefulTarget: {
kind: "stateful",
driverId: "acp",
sessionKey: configuredBinding.record.targetSessionKey,
agentId: configuredBinding.spec.agentId,
},
}),
},
},
match: {
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
record: configuredBinding.record,
statefulTarget: {
kind: "stateful",
driverId: "acp",
sessionKey: configuredBinding.record.targetSessionKey,
agentId: configuredBinding.spec.agentId,
},
},
configuredBindingSessionKey: configuredBinding.record.targetSessionKey,
route: {
agentId: "codex",
accountId: "work",
channel: "telegram",
sessionKey: configuredBinding.record.targetSessionKey,
mainSessionKey: "agent:codex:main",
matchedBy: "binding.channel",
lastRoutePolicy: "bound",
},
} as const;
}
describe("buildTelegramMessageContext ACP configured bindings", () => {
beforeAll(async () => {
({ buildTelegramMessageContextForTest } =
await import("./bot-message-context.test-harness.js"));
});
beforeEach(() => {
ensureConfiguredBindingRouteReadyMock.mockReset();
recordInboundSessionMock.mockClear();
resolveTelegramConversationRouteMock.mockReset();
resolveTelegramConversationRouteMock.mockReturnValue(createConfiguredTelegramRoute());
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
});
it("treats configured topic bindings as explicit route matches on non-default accounts", async () => {
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
runtime: configuredBindingRuntime,
sessionRuntime: configuredBindingSessionRuntime,
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
text: "hello",
},
});
expect(ctx).not.toBeNull();
expect(ctx?.route.accountId).toBe("work");
expect(ctx?.route.matchedBy).toBe("binding.channel");
expect(ctx?.route.sessionKey).toBe("agent:codex:acp:binding:telegram:work:abc123");
expect(recordInboundSessionMock.mock.calls[0]?.[0]).toMatchObject({
updateLastRoute: undefined,
});
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
});
it("skips ACP session initialization when topic access is denied", async () => {
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
runtime: configuredBindingRuntime,
sessionRuntime: configuredBindingSessionRuntime,
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
text: "hello",
},
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: false },
topicConfig: { enabled: false },
}),
});
expect(ctx).toBeNull();
expect(resolveTelegramConversationRouteMock).toHaveBeenCalledTimes(1);
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
});
it("defers ACP session initialization for unauthorized control commands", async () => {
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
runtime: configuredBindingRuntime,
sessionRuntime: configuredBindingSessionRuntime,
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
text: "/new",
},
cfg: {
channels: {
telegram: {},
},
commands: {
useAccessGroups: true,
},
},
});
expect(ctx).toBeNull();
expect(resolveTelegramConversationRouteMock).toHaveBeenCalledTimes(1);
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
});
it("drops inbound processing when configured ACP binding initialization fails", async () => {
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
ok: false,
error: "gateway unavailable",
});
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
runtime: configuredBindingRuntime,
sessionRuntime: configuredBindingSessionRuntime,
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
text: "hello",
},
});
expect(ctx).toBeNull();
expect(resolveTelegramConversationRouteMock).toHaveBeenCalledTimes(1);
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const transcribeFirstAudioMock = vi.fn();
const DEFAULT_MODEL = "anthropic/claude-opus-4-5";
const DEFAULT_WORKSPACE = "/tmp/openclaw";
const DEFAULT_MENTION_PATTERN = "\\bbot\\b";
vi.mock("./media-understanding.runtime.js", () => ({
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
}));
const { buildTelegramMessageContextForTest } =
await import("./bot-message-context.test-harness.js");
async function buildGroupVoiceContext(params: {
messageId: number;
chatId: number;
title: string;
date: number;
fromId: number;
firstName: string;
fileId: string;
mediaPath: string;
groupDisableAudioPreflight?: boolean;
topicDisableAudioPreflight?: boolean;
}) {
const groupConfig = {
requireMention: true,
...(params.groupDisableAudioPreflight === undefined
? {}
: { disableAudioPreflight: params.groupDisableAudioPreflight }),
};
const topicConfig =
params.topicDisableAudioPreflight === undefined
? undefined
: { disableAudioPreflight: params.topicDisableAudioPreflight };
return buildTelegramMessageContextForTest({
message: {
message_id: params.messageId,
chat: { id: params.chatId, type: "supergroup", title: params.title },
date: params.date,
text: undefined,
from: { id: params.fromId, first_name: params.firstName },
voice: { file_id: params.fileId },
},
allMedia: [{ path: params.mediaPath, contentType: "audio/ogg" }],
options: { forceWasMentioned: true },
cfg: {
agents: { defaults: { model: DEFAULT_MODEL, workspace: DEFAULT_WORKSPACE } },
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: [DEFAULT_MENTION_PATTERN] } },
},
resolveGroupActivation: () => true,
resolveGroupRequireMention: () => true,
resolveTelegramGroupConfig: () => ({
groupConfig,
topicConfig,
}),
});
}
function expectTranscriptRendered(
ctx: Awaited<ReturnType<typeof buildGroupVoiceContext>>,
transcript: string,
) {
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.BodyForAgent).toBe(transcript);
expect(ctx?.ctxPayload?.Body).toContain(transcript);
expect(ctx?.ctxPayload?.Body).not.toContain("<media:audio>");
}
function expectAudioPlaceholderRendered(ctx: Awaited<ReturnType<typeof buildGroupVoiceContext>>) {
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.Body).toContain("<media:audio>");
}
describe("buildTelegramMessageContext audio transcript body", () => {
beforeEach(() => {
transcribeFirstAudioMock.mockReset();
});
it("uses preflight transcript as BodyForAgent for mention-gated group voice messages", async () => {
transcribeFirstAudioMock.mockResolvedValueOnce("hey bot please help");
const ctx = await buildGroupVoiceContext({
messageId: 1,
chatId: -1001234567890,
title: "Test Group",
date: 1700000000,
fromId: 42,
firstName: "Alice",
fileId: "voice-1",
mediaPath: "/tmp/voice.ogg",
});
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
expectTranscriptRendered(ctx, "hey bot please help");
});
it("skips preflight transcription when disableAudioPreflight is true", async () => {
transcribeFirstAudioMock.mockClear();
const ctx = await buildGroupVoiceContext({
messageId: 2,
chatId: -1001234567891,
title: "Test Group 2",
date: 1700000100,
fromId: 43,
firstName: "Bob",
fileId: "voice-2",
mediaPath: "/tmp/voice2.ogg",
groupDisableAudioPreflight: true,
});
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
expectAudioPlaceholderRendered(ctx);
});
it("uses topic disableAudioPreflight=false to override group disableAudioPreflight=true", async () => {
transcribeFirstAudioMock.mockResolvedValueOnce("topic override transcript");
const ctx = await buildGroupVoiceContext({
messageId: 3,
chatId: -1001234567892,
title: "Test Group 3",
date: 1700000200,
fromId: 44,
firstName: "Cara",
fileId: "voice-3",
mediaPath: "/tmp/voice3.ogg",
groupDisableAudioPreflight: true,
topicDisableAudioPreflight: false,
});
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
expectTranscriptRendered(ctx, "topic override transcript");
});
it("uses topic disableAudioPreflight=true to override group disableAudioPreflight=false", async () => {
transcribeFirstAudioMock.mockClear();
const ctx = await buildGroupVoiceContext({
messageId: 4,
chatId: -1001234567893,
title: "Test Group 4",
date: 1700000300,
fromId: 45,
firstName: "Dan",
fileId: "voice-4",
mediaPath: "/tmp/voice4.ogg",
groupDisableAudioPreflight: false,
topicDisableAudioPreflight: true,
});
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
expectAudioPlaceholderRendered(ctx);
});
});

View file

@ -0,0 +1,192 @@
import { describe, expect, it, vi } from "vitest";
import { normalizeAllowFrom } from "./bot-access.js";
const transcribeFirstAudioMock = vi.fn();
vi.mock("./media-understanding.runtime.js", () => ({
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
}));
const { resolveTelegramInboundBody } = await import("./bot-message-context.body.js");
describe("resolveTelegramInboundBody", () => {
it("keeps the media marker when a captioned video has no downloaded media", async () => {
const result = await resolveTelegramInboundBody({
cfg: {
channels: { telegram: {} },
} as never,
primaryCtx: {
me: { id: 7, username: "bot" },
} as never,
msg: {
message_id: 0,
date: 1_700_000_000,
chat: { id: 42, type: "private", first_name: "Pat" },
from: { id: 42, first_name: "Pat" },
caption: "episode caption",
video: {
file_id: "video-1",
file_unique_id: "video-u1",
duration: 10,
width: 320,
height: 240,
},
} as never,
allMedia: [],
isGroup: false,
chatId: 42,
senderId: "42",
senderUsername: "",
routeAgentId: undefined,
effectiveGroupAllow: normalizeAllowFrom([]),
effectiveDmAllow: normalizeAllowFrom([]),
groupConfig: undefined,
topicConfig: undefined,
requireMention: false,
options: undefined,
groupHistories: new Map(),
historyLimit: 0,
logger: { info: vi.fn() },
});
expect(result).toMatchObject({
rawBody: "episode caption",
bodyText: "<media:video> [file_id:video-1]\nepisode caption",
});
});
it("does not transcribe group audio for unauthorized senders", async () => {
transcribeFirstAudioMock.mockReset();
const logger = { info: vi.fn() };
const result = await resolveTelegramInboundBody({
cfg: {
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } },
} as never,
primaryCtx: {
me: { id: 7, username: "bot" },
} as never,
msg: {
message_id: 1,
date: 1_700_000_000,
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
from: { id: 46, first_name: "Eve" },
voice: { file_id: "voice-1" },
entities: [],
} as never,
allMedia: [{ path: "/tmp/voice.ogg", contentType: "audio/ogg" }],
isGroup: true,
chatId: -1001234567890,
senderId: "46",
senderUsername: "",
routeAgentId: undefined,
effectiveGroupAllow: normalizeAllowFrom(["999"]),
effectiveDmAllow: normalizeAllowFrom([]),
groupConfig: { requireMention: true } as never,
topicConfig: undefined,
requireMention: true,
options: undefined,
groupHistories: new Map(),
historyLimit: 0,
logger,
});
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
{ chatId: -1001234567890, reason: "no-mention" },
"skipping group message",
);
expect(result).toBeNull();
});
it("still transcribes when commands.useAccessGroups is false", async () => {
transcribeFirstAudioMock.mockReset();
transcribeFirstAudioMock.mockResolvedValueOnce("hey bot please help");
const result = await resolveTelegramInboundBody({
cfg: {
channels: { telegram: {} },
commands: { useAccessGroups: false },
messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } },
tools: { media: { audio: { enabled: true } } },
} as never,
primaryCtx: {
me: { id: 7, username: "bot" },
} as never,
msg: {
message_id: 2,
date: 1_700_000_001,
chat: { id: -1001234567891, type: "supergroup", title: "Test Group" },
from: { id: 46, first_name: "Eve" },
voice: { file_id: "voice-2" },
entities: [],
} as never,
allMedia: [{ path: "/tmp/voice-2.ogg", contentType: "audio/ogg" }],
isGroup: true,
chatId: -1001234567891,
senderId: "46",
senderUsername: "",
routeAgentId: undefined,
effectiveGroupAllow: normalizeAllowFrom(["999"]),
effectiveDmAllow: normalizeAllowFrom([]),
groupConfig: { requireMention: true } as never,
topicConfig: undefined,
requireMention: true,
options: undefined,
groupHistories: new Map(),
historyLimit: 0,
logger: { info: vi.fn() },
});
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
bodyText: "hey bot please help",
effectiveWasMentioned: true,
});
});
it("transcribes DM voice notes via preflight (not only groups)", async () => {
transcribeFirstAudioMock.mockReset();
transcribeFirstAudioMock.mockResolvedValueOnce("hello from a voice note");
const result = await resolveTelegramInboundBody({
cfg: {
channels: { telegram: {} },
tools: { media: { audio: { enabled: true } } },
} as never,
primaryCtx: {
me: { id: 7, username: "bot" },
} as never,
msg: {
message_id: 10,
date: 1_700_000_010,
chat: { id: 42, type: "private", first_name: "Pat" },
from: { id: 42, first_name: "Pat" },
voice: { file_id: "voice-dm-1" },
entities: [],
} as never,
allMedia: [{ path: "/tmp/voice-dm.ogg", contentType: "audio/ogg" }],
isGroup: false,
chatId: 42,
senderId: "42",
senderUsername: "",
routeAgentId: undefined,
effectiveGroupAllow: normalizeAllowFrom([]),
effectiveDmAllow: normalizeAllowFrom([]),
groupConfig: undefined,
topicConfig: undefined,
requireMention: false,
options: undefined,
groupHistories: new Map(),
historyLimit: 0,
logger: { info: vi.fn() },
});
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
bodyText: "hello from a voice note",
});
expect(result?.bodyText).not.toContain("<media:audio>");
});
});

View file

@ -0,0 +1,363 @@
import {
buildMentionRegexes,
formatLocationText,
implicitMentionKindWhen,
logInboundDrop,
matchesMentionWithExplicit,
resolveInboundMentionDecision,
type NormalizedLocation,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth-native";
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type {
TelegramDirectConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "openclaw/plugin-sdk/config-runtime";
import { resolveChannelGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
import {
createInternalHookEvent,
fireAndForgetHook,
toInternalMessageReceivedContext,
triggerInternalHook,
} from "openclaw/plugin-sdk/hook-runtime";
import {
recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
} from "openclaw/plugin-sdk/reply-history";
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
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,
buildSenderName,
expandTextLinks,
extractTelegramLocation,
getTelegramTextParts,
hasBotMention,
resolveTelegramPrimaryMedia,
} from "./bot/body-helpers.js";
import { buildTelegramGroupPeerId } from "./bot/helpers.js";
import type { TelegramContext } from "./bot/types.js";
import { isTelegramForumServiceMessage } from "./forum-service-message.js";
type StickerVisionRuntime = typeof import("./sticker-vision.runtime.js");
type MediaUnderstandingRuntime = typeof import("./media-understanding.runtime.js");
let stickerVisionRuntimePromise: Promise<StickerVisionRuntime> | undefined;
let mediaUnderstandingRuntimePromise: Promise<MediaUnderstandingRuntime> | undefined;
function loadStickerVisionRuntime(): Promise<StickerVisionRuntime> {
stickerVisionRuntimePromise ??= import("./sticker-vision.runtime.js");
return stickerVisionRuntimePromise;
}
function loadMediaUnderstandingRuntime(): Promise<MediaUnderstandingRuntime> {
mediaUnderstandingRuntimePromise ??= import("./media-understanding.runtime.js");
return mediaUnderstandingRuntimePromise;
}
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 { resolveStickerVisionSupportRuntime } = await loadStickerVisionRuntime();
return await resolveStickerVisionSupportRuntime(params);
} catch {
return false;
}
}
export async function resolveTelegramInboundBody(params: {
cfg: OpenClawConfig;
primaryCtx: TelegramContext;
msg: TelegramContext["message"];
allMedia: TelegramMediaRef[];
isGroup: boolean;
chatId: number | string;
accountId?: string;
senderId: string;
senderUsername: string;
sessionKey?: 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,
accountId,
senderId,
senderUsername,
sessionKey,
resolvedThreadId,
routeAgentId,
effectiveGroupAllow,
effectiveDmAllow,
groupConfig,
topicConfig,
requireMention,
options,
groupHistories,
historyLimit,
logger,
} = params;
const botUsername = normalizeOptionalLowercaseString(primaryCtx.me?.username);
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;
const primaryMedia = resolveTelegramPrimaryMedia(msg);
let placeholder = primaryMedia?.placeholder ?? "";
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;
if (allMedia.length === 0 && placeholder && rawBody !== placeholder) {
const mediaTag = primaryMedia?.fileRef.file_id
? `${placeholder} [file_id:${primaryMedia.fileRef.file_id}]`
: placeholder;
bodyText = `${mediaTag}\n${bodyText}`.trim();
}
const hasAudio = allMedia.some((media) => media.contentType?.startsWith("audio/"));
const disableAudioPreflight =
(topicConfig?.disableAudioPreflight ??
(groupConfig as TelegramGroupConfig | undefined)?.disableAudioPreflight) === true;
const senderAllowedForAudioPreflight =
!useAccessGroups || !allowForCommands.hasEntries || senderAllowedForCommands;
let preflightTranscript: string | undefined;
const needsPreflightTranscription =
hasAudio &&
!hasUserText &&
(!isGroup ||
(requireMention &&
mentionRegexes.length > 0 &&
!disableAudioPreflight &&
senderAllowedForAudioPreflight));
if (needsPreflightTranscription) {
try {
const { transcribeFirstAudio } = await loadMediaUnderstandingRuntime();
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 implicitMentionKinds = implicitMentionKindWhen(
"reply_to_bot",
replyToBotMessage && !isReplyToServiceMessage,
);
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
const mentionDecision = resolveInboundMentionDecision({
facts: {
canDetectMention,
wasMentioned,
hasAnyMention,
implicitMentionKinds: isGroup && Boolean(requireMention) ? implicitMentionKinds : [],
},
policy: {
isGroup,
requireMention: Boolean(requireMention),
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
},
});
const effectiveWasMentioned = mentionDecision.effectiveWasMentioned;
if (isGroup && requireMention && canDetectMention && mentionDecision.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,
});
const telegramGroupPolicy = resolveChannelGroupPolicy({
cfg,
channel: "telegram",
groupId: String(chatId),
accountId,
});
const ingestEnabled =
topicConfig?.ingest ??
telegramGroupPolicy.groupConfig?.ingest ??
telegramGroupPolicy.defaultConfig?.ingest;
if (ingestEnabled === true && sessionKey) {
fireAndForgetHook(
triggerInternalHook(
createInternalHookEvent(
"message",
"received",
sessionKey,
toInternalMessageReceivedContext({
from: `telegram:group:${historyKey ?? chatId}`,
to: `telegram:${chatId}`,
content: rawBody,
timestamp: msg.date ? msg.date * 1000 : undefined,
channelId: "telegram",
accountId,
conversationId: `telegram:${chatId}`,
messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined,
senderId: senderId || undefined,
senderName: buildSenderName(msg),
senderUsername: senderUsername || undefined,
provider: "telegram",
surface: "telegram",
threadId: resolvedThreadId,
originatingChannel: "telegram",
originatingTo: `telegram:${chatId}`,
isGroup: true,
groupId: `telegram:${chatId}`,
}),
),
),
"telegram: mention-skip message hook failed",
);
}
return null;
}
return {
bodyText,
rawBody,
historyKey,
commandAuthorized,
effectiveWasMentioned,
canDetectMention,
shouldBypassMention: mentionDecision.shouldBypassMention,
stickerCacheHit,
locationData: locationData ?? undefined,
};
}

View file

@ -0,0 +1,336 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resetTopicNameCacheForTest } from "./topic-name-cache.js";
type SessionRuntimeModule = typeof import("./bot-message-context.session.runtime.js");
type RecordInboundSessionFn = SessionRuntimeModule["recordInboundSession"];
type ResolveStorePathFn = SessionRuntimeModule["resolveStorePath"];
const { recordInboundSessionMock, resolveStorePathMock } = vi.hoisted(() => ({
recordInboundSessionMock: vi.fn<RecordInboundSessionFn>(async () => undefined),
resolveStorePathMock: vi.fn<ResolveStorePathFn>(() => "/tmp/openclaw-session-store.json"),
}));
vi.mock("./bot-message-context.session.runtime.js", async () => {
const actual = await vi.importActual<typeof import("./bot-message-context.session.runtime.js")>(
"./bot-message-context.session.runtime.js",
);
return {
...actual,
recordInboundSession: (...args: Parameters<typeof actual.recordInboundSession>) =>
recordInboundSessionMock(...args),
resolveStorePath: (...args: Parameters<typeof actual.resolveStorePath>) =>
resolveStorePathMock(...args),
};
});
vi.mock("./bot-message-context.body.js", () => ({
resolveTelegramInboundBody: async () => ({
bodyText: "hello",
rawBody: "hello",
historyKey: undefined,
commandAuthorized: false,
effectiveWasMentioned: true,
canDetectMention: false,
shouldBypassMention: false,
stickerCacheHit: false,
locationData: undefined,
}),
}));
const { buildTelegramMessageContextForTest } =
await import("./bot-message-context.test-harness.js");
const { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } =
await import("openclaw/plugin-sdk/config-runtime");
beforeEach(() => {
clearRuntimeConfigSnapshot();
resetTopicNameCacheForTest();
});
afterEach(() => {
clearRuntimeConfigSnapshot();
resetTopicNameCacheForTest();
recordInboundSessionMock.mockClear();
resolveStorePathMock.mockReset();
resolveStorePathMock.mockReturnValue("/tmp/openclaw-session-store.json");
});
describe("buildTelegramMessageContext dm thread sessions", () => {
const buildContext = async (message: Record<string, unknown>) =>
await buildTelegramMessageContextForTest({
message,
});
it("uses thread session key for dm topics", async () => {
const ctx = await buildContext({
message_id: 1,
chat: { id: 1234, type: "private" },
date: 1700000000,
text: "hello",
message_thread_id: 42,
from: { id: 42, first_name: "Alice" },
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.MessageThreadId).toBe(42);
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main:thread:1234:42");
});
it("keeps legacy dm session key when no thread id", async () => {
const ctx = await buildContext({
message_id: 2,
chat: { id: 1234, type: "private" },
date: 1700000001,
text: "hello",
from: { id: 42, first_name: "Alice" },
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined();
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main");
});
});
describe("buildTelegramMessageContext group sessions without forum", () => {
const buildContext = async (message: Record<string, unknown>) =>
await buildTelegramMessageContextForTest({
message,
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
});
it("ignores message_thread_id for regular groups (not forums)", async () => {
// When someone replies to a message in a non-forum group, Telegram sends
// message_thread_id but this should NOT create a separate session
const ctx = await buildContext({
message_id: 1,
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
date: 1700000000,
text: "@bot hello",
message_thread_id: 42, // This is a reply thread, NOT a forum topic
from: { id: 42, first_name: "Alice" },
});
expect(ctx).not.toBeNull();
// Session key should NOT include :topic:42
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890");
// MessageThreadId should be undefined (not a forum)
expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined();
});
it("keeps same session for regular group with and without message_thread_id", async () => {
const ctxWithThread = await buildContext({
message_id: 1,
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
date: 1700000000,
text: "@bot hello",
message_thread_id: 42,
from: { id: 42, first_name: "Alice" },
});
const ctxWithoutThread = await buildContext({
message_id: 2,
chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
date: 1700000001,
text: "@bot world",
from: { id: 42, first_name: "Alice" },
});
expect(ctxWithThread).not.toBeNull();
expect(ctxWithoutThread).not.toBeNull();
// Both messages should use the same session key
expect(ctxWithThread?.ctxPayload?.SessionKey).toBe(ctxWithoutThread?.ctxPayload?.SessionKey);
});
it("uses topic session for forum groups with message_thread_id", async () => {
const ctx = await buildContext({
message_id: 1,
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
date: 1700000000,
text: "@bot hello",
message_thread_id: 99,
from: { id: 42, first_name: "Alice" },
});
expect(ctx).not.toBeNull();
// Session key SHOULD include :topic:99 for forums
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:99");
expect(ctx?.ctxPayload?.MessageThreadId).toBe(99);
});
it("surfaces topic name from reply_to_message forum metadata", async () => {
const ctx = await buildContext({
message_id: 3,
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
date: 1700000002,
text: "@bot hello",
message_thread_id: 99,
from: { id: 42, first_name: "Alice" },
reply_to_message: {
message_id: 2,
forum_topic_created: { name: "Deployments", icon_color: 0x6fb9f0 },
},
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.TopicName).toBe("Deployments");
});
it("handles forum messages without session runtime overrides", async () => {
const ctx = await buildTelegramMessageContextForTest({
message: {
message_id: 3,
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
date: 1700000002,
text: "@bot hello",
message_thread_id: 99,
from: { id: 42, first_name: "Alice" },
reply_to_message: {
message_id: 2,
forum_topic_created: { name: "Deployments", icon_color: 0x6fb9f0 },
},
},
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
sessionRuntime: null,
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.TopicName).toBe("Deployments");
});
it("reloads topic name from disk after cache reset", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-topic-name-"));
const sessionStorePath = path.join(tempDir, "sessions.json");
const buildPersistedContext = async (message: Record<string, unknown>) =>
await buildTelegramMessageContextForTest({
message,
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
sessionRuntime: {
resolveStorePath: () => sessionStorePath,
},
});
try {
await buildPersistedContext({
message_id: 4,
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
date: 1700000003,
text: "@bot hello",
message_thread_id: 99,
from: { id: 42, first_name: "Alice" },
reply_to_message: {
message_id: 3,
forum_topic_created: { name: "Deployments", icon_color: 0x6fb9f0 },
},
});
resetTopicNameCacheForTest();
const ctx = await buildPersistedContext({
message_id: 5,
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
date: 1700000004,
text: "@bot again",
message_thread_id: 99,
from: { id: 42, first_name: "Alice" },
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.TopicName).toBe("Deployments");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
resetTopicNameCacheForTest();
}
});
it("persists topic names through the default session runtime path", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-topic-name-"));
const sessionStorePath = path.join(tempDir, "sessions.json");
resolveStorePathMock.mockReturnValue(sessionStorePath);
try {
await buildTelegramMessageContextForTest({
message: {
message_id: 6,
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
date: 1700000005,
text: "@bot hello",
message_thread_id: 99,
from: { id: 42, first_name: "Alice" },
reply_to_message: {
message_id: 5,
forum_topic_created: { name: "Deployments", icon_color: 0x6fb9f0 },
},
},
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
sessionRuntime: null,
});
resetTopicNameCacheForTest();
const ctx = await buildTelegramMessageContextForTest({
message: {
message_id: 7,
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
date: 1700000006,
text: "@bot again",
message_thread_id: 99,
from: { id: 42, first_name: "Alice" },
},
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
sessionRuntime: null,
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.TopicName).toBe("Deployments");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
resetTopicNameCacheForTest();
}
});
});
describe("buildTelegramMessageContext direct peer routing", () => {
it("isolates dm sessions by sender id when chat id differs", async () => {
const runtimeCfg = {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: [] } },
session: { dmScope: "per-channel-peer" as const },
};
setRuntimeConfigSnapshot(runtimeCfg);
const baseMessage = {
chat: { id: 777777777, type: "private" as const },
date: 1700000000,
text: "hello",
};
const first = await buildTelegramMessageContextForTest({
cfg: runtimeCfg,
message: {
...baseMessage,
message_id: 1,
from: { id: 123456789, first_name: "Alice" },
},
});
const second = await buildTelegramMessageContextForTest({
cfg: runtimeCfg,
message: {
...baseMessage,
message_id: 2,
from: { id: 987654321, first_name: "Bob" },
},
});
expect(first?.ctxPayload?.SessionKey).toBe("agent:main:telegram:direct:123456789");
expect(second?.ctxPayload?.SessionKey).toBe("agent:main:telegram:direct:987654321");
});
});

View file

@ -0,0 +1,119 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
getRecordedUpdateLastRoute,
loadTelegramMessageContextRouteHarness,
recordInboundSessionMock,
} from "./bot-message-context.route-test-support.js";
vi.mock("./bot-message-context.body.js", () => ({
resolveTelegramInboundBody: async () => ({
bodyText: "hello",
rawBody: "hello",
historyKey: undefined,
commandAuthorized: false,
effectiveWasMentioned: true,
canDetectMention: false,
shouldBypassMention: false,
stickerCacheHit: false,
locationData: undefined,
}),
}));
let buildTelegramMessageContextForTest: typeof import("./bot-message-context.test-harness.js").buildTelegramMessageContextForTest;
let clearRuntimeConfigSnapshot: typeof import("openclaw/plugin-sdk/config-runtime").clearRuntimeConfigSnapshot;
describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#8891)", () => {
async function buildCtx(params: {
message: Record<string, unknown>;
options?: Record<string, unknown>;
resolveGroupActivation?: () => boolean | undefined;
}) {
return await buildTelegramMessageContextForTest({
message: params.message,
options: params.options,
resolveGroupActivation: params.resolveGroupActivation,
});
}
function expectRecordedRoute(params: { to: string; threadId?: string }) {
const updateLastRoute = getRecordedUpdateLastRoute(0) as
| { threadId?: string; to?: string }
| undefined;
expect(updateLastRoute).toBeDefined();
expect(updateLastRoute?.to).toBe(params.to);
expect(updateLastRoute?.threadId).toBe(params.threadId);
}
afterEach(() => {
clearRuntimeConfigSnapshot();
});
beforeAll(async () => {
({ clearRuntimeConfigSnapshot, buildTelegramMessageContextForTest } =
await loadTelegramMessageContextRouteHarness());
});
beforeEach(() => {
recordInboundSessionMock.mockClear();
});
it("passes threadId to updateLastRoute for DM topics", async () => {
const ctx = await buildCtx({
message: {
chat: { id: 1234, type: "private" },
message_thread_id: 42, // DM Topic ID
},
});
expect(ctx).not.toBeNull();
expect(recordInboundSessionMock).toHaveBeenCalled();
expectRecordedRoute({ to: "telegram:1234", threadId: "42" });
});
it("does not pass threadId for regular DM without topic", async () => {
const ctx = await buildCtx({
message: {
chat: { id: 1234, type: "private" },
},
});
expect(ctx).not.toBeNull();
expect(recordInboundSessionMock).toHaveBeenCalled();
expectRecordedRoute({ to: "telegram:1234" });
});
it("passes threadId to updateLastRoute for forum topic group messages", async () => {
const ctx = await buildCtx({
message: {
chat: { id: -1001234567890, type: "supergroup", title: "Test Group", is_forum: true },
text: "@bot hello",
message_thread_id: 99,
},
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
});
expect(ctx).not.toBeNull();
expect(recordInboundSessionMock).toHaveBeenCalled();
expectRecordedRoute({ to: "telegram:-1001234567890:topic:99", threadId: "99" });
});
it("passes threadId to updateLastRoute for the forum General topic", async () => {
const ctx = await buildCtx({
message: {
chat: { id: -1001234567890, type: "supergroup", title: "Test Group", is_forum: true },
text: "@bot hello",
},
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
});
expect(ctx).not.toBeNull();
expect(recordInboundSessionMock).toHaveBeenCalled();
expectRecordedRoute({ to: "telegram:-1001234567890:topic:1", threadId: "1" });
});
});

View file

@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
import { TELEGRAM_FORUM_SERVICE_FIELDS } from "./forum-service-message.js";
describe("buildTelegramMessageContext implicitMention forum service messages", () => {
/**
* Build a group message context where the user sends a message inside a
* forum topic that has `reply_to_message` pointing to a message from the
* bot. Callers control whether the reply target looks like a forum service
* message (carries `forum_topic_created` etc.) or a real bot reply.
*/
async function buildGroupReplyCtx(params: {
replyToMessageText?: string;
replyToMessageCaption?: string;
replyFromIsBot?: boolean;
replyFromId?: number;
/** Extra fields on reply_to_message (e.g. forum_topic_created). */
replyToMessageExtra?: Record<string, unknown>;
}) {
const BOT_ID = 7; // matches test harness primaryCtx.me.id
return await buildTelegramMessageContextForTest({
message: {
message_id: 100,
chat: { id: -1001234567890, type: "supergroup", title: "Forum Group" },
date: 1700000000,
text: "hello everyone",
from: { id: 42, first_name: "Alice" },
reply_to_message: {
message_id: 1,
text: params.replyToMessageText ?? undefined,
...(params.replyToMessageCaption != null
? { caption: params.replyToMessageCaption }
: {}),
from: {
id: params.replyFromId ?? BOT_ID,
first_name: "OpenClaw",
is_bot: params.replyFromIsBot ?? true,
},
...params.replyToMessageExtra,
},
},
resolveGroupActivation: () => true,
resolveGroupRequireMention: () => true,
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: true },
topicConfig: undefined,
}),
});
}
it("does NOT trigger implicitMention for forum_topic_created service message", async () => {
// Bot auto-generated "Topic created" message carries forum_topic_created.
const ctx = await buildGroupReplyCtx({
replyToMessageText: undefined,
replyFromIsBot: true,
replyToMessageExtra: {
forum_topic_created: { name: "New Topic", icon_color: 0x6fb9f0 },
},
});
// With requireMention and no explicit @mention, the message should be
// skipped (null) because implicitMention should NOT fire.
expect(ctx).toBeNull();
});
it.each(TELEGRAM_FORUM_SERVICE_FIELDS)(
"does NOT trigger implicitMention for %s service message",
async (field) => {
const ctx = await buildGroupReplyCtx({
replyToMessageText: undefined,
replyFromIsBot: true,
replyToMessageExtra: { [field]: {} },
});
expect(ctx).toBeNull();
},
);
it("does NOT trigger implicitMention for forum_topic_closed service message", async () => {
const ctx = await buildGroupReplyCtx({
replyToMessageText: undefined,
replyFromIsBot: true,
replyToMessageExtra: { forum_topic_closed: {} },
});
expect(ctx).toBeNull();
});
it("does NOT trigger implicitMention for general_forum_topic_hidden service message", async () => {
const ctx = await buildGroupReplyCtx({
replyToMessageText: undefined,
replyFromIsBot: true,
replyToMessageExtra: { general_forum_topic_hidden: {} },
});
expect(ctx).toBeNull();
});
it("DOES trigger implicitMention for real bot replies (non-empty text)", async () => {
const ctx = await buildGroupReplyCtx({
replyToMessageText: "Here is my answer",
replyFromIsBot: true,
});
// Real bot reply → implicitMention fires → message is NOT skipped.
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.WasMentioned).toBe(true);
});
it("DOES trigger implicitMention for bot media messages with caption", async () => {
// Media messages from the bot have caption but no text — they should
// still count as real bot replies, not service messages.
const ctx = await buildGroupReplyCtx({
replyToMessageText: undefined,
replyToMessageCaption: "Check out this image",
replyFromIsBot: true,
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.WasMentioned).toBe(true);
});
it("DOES trigger implicitMention for bot sticker/voice (no text, no caption, no service field)", async () => {
// Stickers, voice notes, and captionless photos have neither text nor
// caption, but they are NOT service messages — they are legitimate bot
// replies that should trigger implicitMention.
const ctx = await buildGroupReplyCtx({
replyToMessageText: undefined,
replyFromIsBot: true,
// No forum_topic_* fields → not a service message
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.WasMentioned).toBe(true);
});
it("does NOT trigger implicitMention when reply is from a different user", async () => {
const ctx = await buildGroupReplyCtx({
replyToMessageText: "some message",
replyFromIsBot: false,
replyFromId: 999,
});
// Different user's message → not an implicit mention → skipped.
expect(ctx).toBeNull();
});
});

View file

@ -0,0 +1,159 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
getRecordedUpdateLastRoute,
loadTelegramMessageContextRouteHarness,
recordInboundSessionMock,
} from "./bot-message-context.route-test-support.js";
let buildTelegramMessageContextForTest: typeof import("./bot-message-context.test-harness.js").buildTelegramMessageContextForTest;
let clearRuntimeConfigSnapshot: typeof import("openclaw/plugin-sdk/config-runtime").clearRuntimeConfigSnapshot;
let setRuntimeConfigSnapshot: typeof import("openclaw/plugin-sdk/config-runtime").setRuntimeConfigSnapshot;
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();
});
beforeAll(async () => {
({ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot, buildTelegramMessageContextForTest } =
await loadTelegramMessageContextRouteHarness());
});
beforeEach(() => {
recordInboundSessionMock.mockClear();
});
function getLastUpdateLastRoute(): { sessionKey?: string } | undefined {
return getRecordedUpdateLastRoute() as { sessionKey?: string } | undefined;
}
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,88 @@
import { vi, type Mock } from "vitest";
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
type BuildTelegramMessageContextForTest =
typeof import("./bot-message-context.test-harness.js").buildTelegramMessageContextForTest;
type BuildTelegramMessageContextForTestParams = Parameters<BuildTelegramMessageContextForTest>[0];
type TelegramTestSessionRuntime = NonNullable<
import("./bot-message-context.types.js").BuildTelegramMessageContextParams["sessionRuntime"]
>;
const hoisted = vi.hoisted((): { recordInboundSessionMock: AsyncUnknownMock } => ({
recordInboundSessionMock: vi.fn().mockResolvedValue(undefined),
}));
export const recordInboundSessionMock: AsyncUnknownMock = hoisted.recordInboundSessionMock;
const finalizeInboundContextForTest = ((ctx) => {
const next = ctx as Record<string, unknown>;
const body = typeof next.Body === "string" ? next.Body : "";
next.Body = body;
next.BodyForAgent =
typeof next.BodyForAgent === "string"
? next.BodyForAgent
: typeof next.RawBody === "string"
? next.RawBody
: body;
next.BodyForCommands =
typeof next.BodyForCommands === "string"
? next.BodyForCommands
: typeof next.CommandBody === "string"
? next.CommandBody
: typeof next.RawBody === "string"
? next.RawBody
: body;
next.CommandAuthorized = Boolean(next.CommandAuthorized);
return next;
}) as NonNullable<TelegramTestSessionRuntime["finalizeInboundContext"]>;
const recordInboundSessionForTest: NonNullable<
TelegramTestSessionRuntime["recordInboundSession"]
> = async (params) => {
await recordInboundSessionMock(params);
};
export const telegramRouteTestSessionRuntime = {
finalizeInboundContext: finalizeInboundContextForTest,
readSessionUpdatedAt: () => undefined,
recordInboundSession: recordInboundSessionForTest,
resolveInboundLastRouteSessionKey: ({ route, sessionKey }) =>
route.lastRoutePolicy === "main" ? route.mainSessionKey : sessionKey,
resolvePinnedMainDmOwnerFromAllowlist: () => null,
resolveStorePath: () => "/tmp/openclaw/session-store.json",
} satisfies TelegramTestSessionRuntime;
export async function loadTelegramMessageContextRouteHarness() {
const [
{ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot },
{ buildTelegramMessageContextForTest },
] = await Promise.all([
import("../../../src/config/config.js"),
import("./bot-message-context.test-harness.js"),
]);
const buildTelegramMessageContextForRouteTest = (
params: BuildTelegramMessageContextForTestParams,
) =>
buildTelegramMessageContextForTest({
...params,
sessionRuntime: {
...telegramRouteTestSessionRuntime,
...params.sessionRuntime,
},
});
return {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
buildTelegramMessageContextForTest: buildTelegramMessageContextForRouteTest,
};
}
export function getRecordedUpdateLastRoute(callIndex = -1): unknown {
const callArgs =
callIndex === -1
? (recordInboundSessionMock.mock.calls.at(-1)?.[0] as
| { updateLastRoute?: unknown }
| undefined)
: (recordInboundSessionMock.mock.calls[callIndex]?.[0] as
| { updateLastRoute?: unknown }
| undefined);
return callArgs?.updateLastRoute;
}

View file

@ -0,0 +1,4 @@
export { createStatusReactionController } from "openclaw/plugin-sdk/channel-feedback";
export { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime";
export { loadConfig } from "openclaw/plugin-sdk/config-runtime";
export { ensureConfiguredBindingRouteReady } from "openclaw/plugin-sdk/conversation-runtime";

View file

@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
import {
isTelegramForumServiceMessage,
TELEGRAM_FORUM_SERVICE_FIELDS,
} from "./forum-service-message.js";
describe("isTelegramForumServiceMessage", () => {
it("returns true for any Telegram forum service field", () => {
for (const field of TELEGRAM_FORUM_SERVICE_FIELDS) {
expect(isTelegramForumServiceMessage({ [field]: {} })).toBe(true);
}
});
it("returns false for normal messages and non-objects", () => {
expect(isTelegramForumServiceMessage({ text: "hello" })).toBe(false);
expect(isTelegramForumServiceMessage(null)).toBe(false);
expect(isTelegramForumServiceMessage("topic created")).toBe(false);
});
});
describe("buildTelegramMessageContext sender prefix", () => {
async function buildCtx(params: { messageId: number; options?: Record<string, unknown> }) {
return await buildTelegramMessageContextForTest({
message: {
message_id: params.messageId,
chat: { id: -99, type: "supergroup", title: "Dev Chat" },
date: 1700000000,
text: "hello",
from: { id: 42, first_name: "Alice" },
},
options: params.options,
});
}
it("prefixes group bodies with sender label", async () => {
const ctx = await buildCtx({ messageId: 1 });
expect(ctx).not.toBeNull();
const body = ctx?.ctxPayload?.Body ?? "";
expect(body).toContain("Alice (42): hello");
});
it("sets MessageSid from message_id", async () => {
const ctx = await buildCtx({ messageId: 12345 });
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.MessageSid).toBe("12345");
});
it("respects messageIdOverride option", async () => {
const ctx = await buildCtx({
messageId: 12345,
options: { messageIdOverride: "67890" },
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.MessageSid).toBe("67890");
});
});

View file

@ -0,0 +1,5 @@
export { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
export { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
export { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime";
export { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
export { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";

View file

@ -0,0 +1,470 @@
import {
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
toLocationContext,
type NormalizedLocation,
} from "openclaw/plugin-sdk/channel-inbound";
import { normalizeCommandBody } from "openclaw/plugin-sdk/command-surface";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/config-runtime";
import type {
TelegramDirectConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "openclaw/plugin-sdk/config-runtime";
import {
buildPendingHistoryContextFromMap,
type HistoryEntry,
} from "openclaw/plugin-sdk/reply-history";
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import type { NormalizedAllowFrom } from "./bot-access.js";
import { isSenderAllowed, normalizeAllowFrom } from "./bot-access.js";
import type {
TelegramMediaRef,
TelegramMessageContextOptions,
TelegramMessageContextSessionRuntimeOverrides,
} from "./bot-message-context.types.js";
import {
buildGroupLabel,
buildSenderLabel,
buildSenderName,
buildTelegramGroupFrom,
describeReplyTarget,
normalizeForwardedContext,
type TelegramReplyTarget,
type TelegramThreadSpec,
} from "./bot/helpers.js";
import type { TelegramContext } from "./bot/types.js";
import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js";
type FinalizedTelegramInboundContext = ReturnType<
typeof import("./bot-message-context.session.runtime.js").finalizeInboundContext
>;
type TelegramMessageContextSessionRuntime =
typeof import("./bot-message-context.session.runtime.js");
const sessionRuntimeMethods = [
"finalizeInboundContext",
"readSessionUpdatedAt",
"recordInboundSession",
"resolveInboundLastRouteSessionKey",
"resolvePinnedMainDmOwnerFromAllowlist",
"resolveStorePath",
] as const satisfies readonly (keyof TelegramMessageContextSessionRuntime)[];
function hasCompleteSessionRuntime(
runtime: TelegramMessageContextSessionRuntimeOverrides | undefined,
): runtime is TelegramMessageContextSessionRuntime {
return Boolean(
runtime && sessionRuntimeMethods.every((method) => typeof runtime[method] === "function"),
);
}
async function loadTelegramMessageContextSessionRuntime(
runtime: TelegramMessageContextSessionRuntimeOverrides | undefined,
): Promise<TelegramMessageContextSessionRuntime> {
if (hasCompleteSessionRuntime(runtime)) {
return runtime;
}
return {
...(await import("./bot-message-context.session.runtime.js")),
...runtime,
};
}
export async function resolveTelegramMessageContextStorePath(params: {
cfg: OpenClawConfig;
agentId: string;
sessionRuntime?: TelegramMessageContextSessionRuntimeOverrides;
}): Promise<string> {
const sessionRuntime = await loadTelegramMessageContextSessionRuntime(params.sessionRuntime);
return sessionRuntime.resolveStorePath(params.cfg.session?.store, {
agentId: params.agentId,
});
}
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?: NormalizedLocation;
options?: TelegramMessageContextOptions;
dmAllowFrom?: Array<string | number>;
effectiveGroupAllow?: NormalizedAllowFrom;
topicName?: string;
sessionRuntime?: TelegramMessageContextSessionRuntimeOverrides;
}): Promise<{
ctxPayload: FinalizedTelegramInboundContext;
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,
effectiveGroupAllow,
topicName,
sessionRuntime: sessionRuntimeOverride,
} = params;
const replyTarget = describeReplyTarget(msg);
const forwardOrigin = normalizeForwardedContext(msg);
const contextVisibilityMode = resolveChannelContextVisibilityMode({
cfg,
channel: "telegram",
accountId: route.accountId,
});
const shouldIncludeGroupSupplementalContext = (params: {
kind: "quote" | "forwarded";
senderId?: string;
senderUsername?: string;
}): boolean => {
if (!isGroup) {
return true;
}
const senderAllowed = effectiveGroupAllow?.hasEntries
? isSenderAllowed({
allow: effectiveGroupAllow,
senderId: params.senderId,
senderUsername: params.senderUsername,
})
: true;
return evaluateSupplementalContextVisibility({
mode: contextVisibilityMode,
kind: params.kind,
senderAllowed,
}).include;
};
const includeReplyTarget = replyTarget
? shouldIncludeGroupSupplementalContext({
kind: "quote",
senderId: replyTarget.senderId,
senderUsername: replyTarget.senderUsername,
})
: false;
const includeForwardOrigin = forwardOrigin
? shouldIncludeGroupSupplementalContext({
kind: "forwarded",
senderId: forwardOrigin.fromId,
senderUsername: forwardOrigin.fromUsername,
})
: false;
const visibleReplyForwardedFrom =
includeReplyTarget && replyTarget?.forwardedFrom
? shouldIncludeGroupSupplementalContext({
kind: "forwarded",
senderId: replyTarget.forwardedFrom.fromId,
senderUsername: replyTarget.forwardedFrom.fromUsername,
})
? replyTarget.forwardedFrom
: undefined
: undefined;
const visibleReplyTarget: TelegramReplyTarget | null =
includeReplyTarget && replyTarget
? {
...replyTarget,
forwardedFrom: visibleReplyForwardedFrom,
}
: null;
const visibleForwardOrigin = includeForwardOrigin ? forwardOrigin : null;
const replyForwardAnnotation = visibleReplyTarget?.forwardedFrom
? `[Forwarded from ${visibleReplyTarget.forwardedFrom.from}${
visibleReplyTarget.forwardedFrom.date
? ` at ${new Date(visibleReplyTarget.forwardedFrom.date * 1000).toISOString()}`
: ""
}]\n`
: "";
const buildReplySupplementalLines = (params: { body?: string }) => {
const lines: string[] = [];
const forwardAnnotation = replyForwardAnnotation.trimEnd();
if (forwardAnnotation) {
lines.push(forwardAnnotation);
}
if (params.body) {
lines.push(params.body);
}
return lines.length > 0 ? `\n${lines.join("\n")}` : "";
};
const replySuffix = visibleReplyTarget
? visibleReplyTarget.kind === "quote"
? `\n\n[Quoting ${visibleReplyTarget.sender}${
visibleReplyTarget.id ? ` id:${visibleReplyTarget.id}` : ""
}]${buildReplySupplementalLines({
body: visibleReplyTarget.body ? `"${visibleReplyTarget.body}"` : undefined,
})}\n[/Quoting]`
: `\n\n[Replying to ${visibleReplyTarget.sender}${
visibleReplyTarget.id ? ` id:${visibleReplyTarget.id}` : ""
}]${buildReplySupplementalLines({
body: visibleReplyTarget.body,
})}\n[/Replying]`
: "";
const forwardPrefix = visibleForwardOrigin
? `[Forwarded from ${visibleForwardOrigin.from}${
visibleForwardOrigin.date
? ` at ${new Date(visibleForwardOrigin.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 sessionRuntime = await loadTelegramMessageContextSessionRuntime(sessionRuntimeOverride);
const storePath = await resolveTelegramMessageContextStorePath({
cfg,
agentId: route.agentId,
sessionRuntime: sessionRuntimeOverride,
});
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = sessionRuntime.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: normalizeOptionalLowercaseString(primaryCtx.me?.username),
});
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 = sessionRuntime.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: visibleReplyTarget?.id,
ReplyToBody: visibleReplyTarget?.body,
ReplyToSender: visibleReplyTarget?.sender,
ReplyToIsQuote: visibleReplyTarget?.kind === "quote" ? true : undefined,
ReplyToForwardedFrom: visibleReplyTarget?.forwardedFrom?.from,
ReplyToForwardedFromType: visibleReplyTarget?.forwardedFrom?.fromType,
ReplyToForwardedFromId: visibleReplyTarget?.forwardedFrom?.fromId,
ReplyToForwardedFromUsername: visibleReplyTarget?.forwardedFrom?.fromUsername,
ReplyToForwardedFromTitle: visibleReplyTarget?.forwardedFrom?.fromTitle,
ReplyToForwardedDate: visibleReplyTarget?.forwardedFrom?.date
? visibleReplyTarget.forwardedFrom.date * 1000
: undefined,
ForwardedFrom: visibleForwardOrigin?.from,
ForwardedFromType: visibleForwardOrigin?.fromType,
ForwardedFromId: visibleForwardOrigin?.fromId,
ForwardedFromUsername: visibleForwardOrigin?.fromUsername,
ForwardedFromTitle: visibleForwardOrigin?.fromTitle,
ForwardedFromSignature: visibleForwardOrigin?.fromSignature,
ForwardedFromChatType: visibleForwardOrigin?.fromChatType,
ForwardedFromMessageId: visibleForwardOrigin?.fromMessageId,
ForwardedDate: visibleForwardOrigin?.date ? visibleForwardOrigin.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,
CommandSource: options?.commandSource,
MessageThreadId: threadSpec.id,
IsForum: isForum,
TopicName: isForum && topicName ? topicName : undefined,
OriginatingChannel: "telegram" as const,
OriginatingTo: `telegram:${chatId}`,
});
const pinnedMainDmOwner = !isGroup
? sessionRuntime.resolvePinnedMainDmOwnerFromAllowlist({
dmScope: cfg.session?.dmScope,
allowFrom: dmAllowFrom,
normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0],
})
: null;
const updateLastRouteSessionKey = sessionRuntime.resolveInboundLastRouteSessionKey({
route,
sessionKey: route.sessionKey,
});
const shouldPersistGroupLastRouteThread = isGroup && route.matchedBy !== "binding.channel";
const updateLastRouteThreadId = isGroup
? shouldPersistGroupLastRouteThread && resolvedThreadId != null
? String(resolvedThreadId)
: undefined
: dmThreadId != null
? String(dmThreadId)
: undefined;
await sessionRuntime.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
updateLastRoute:
!isGroup || updateLastRouteThreadId != null
? {
sessionKey: updateLastRouteSessionKey,
channel: "telegram",
to:
isGroup && updateLastRouteThreadId != null
? `telegram:${chatId}:topic:${updateLastRouteThreadId}`
: `telegram:${chatId}`,
accountId: route.accountId,
threadId: updateLastRouteThreadId,
mainDmOwnerPin:
!isGroup &&
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 (visibleReplyTarget && shouldLogVerbose()) {
const preview = (visibleReplyTarget.body ?? "").replace(/\s+/g, " ").slice(0, 120);
logVerbose(
`telegram reply-context: replyToId=${visibleReplyTarget.id} replyToSender=${visibleReplyTarget.sender} replyToBody="${preview}"`,
);
}
if (visibleForwardOrigin && shouldLogVerbose()) {
logVerbose(
`telegram forward-context: forwardedFrom="${visibleForwardOrigin.from}" type=${visibleForwardOrigin.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

@ -0,0 +1,146 @@
import { describe, expect, it, vi } from "vitest";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
const internalHookMocks = vi.hoisted(() => ({
createInternalHookEvent: vi.fn(
(type: string, action: string, sessionKey: string, context: Record<string, unknown>) => ({
type,
action,
sessionKey,
context,
timestamp: new Date(),
messages: [],
}),
),
triggerInternalHook: vi.fn(async () => undefined),
}));
vi.mock("openclaw/plugin-sdk/hook-runtime", () => {
return {
createInternalHookEvent: internalHookMocks.createInternalHookEvent,
fireAndForgetHook: (task: Promise<unknown>) => void task,
toInternalMessageReceivedContext: (context: Record<string, unknown>) => ({
...context,
metadata: { to: context.to },
}),
triggerInternalHook: internalHookMocks.triggerInternalHook,
};
});
function makeGroupMessage(text: string) {
return {
message_id: 42,
chat: { id: -1001234567890, type: "supergroup" as const, title: "Test Group" },
date: 1_700_000_000,
text,
from: { id: 99, first_name: "Alice", username: "alice" },
};
}
describe("telegram mention-skip silent ingest", () => {
it("emits internal message:received when ingest is enabled", async () => {
internalHookMocks.createInternalHookEvent.mockClear();
internalHookMocks.triggerInternalHook.mockClear();
const result = await buildTelegramMessageContextForTest({
message: makeGroupMessage("hello without mention"),
cfg: {
agents: {
defaults: {
model: "anthropic/sonnet-4.6",
workspace: "/tmp/openclaw",
},
},
channels: {
telegram: {
groups: {
"*": {
requireMention: true,
ingest: true,
},
},
},
},
messages: {
groupChat: {
mentionPatterns: ["@bot"],
},
},
} as never,
resolveGroupRequireMention: () => true,
resolveTelegramGroupConfig: () => ({
groupConfig: {
requireMention: true,
ingest: true,
},
topicConfig: undefined,
}),
});
expect(result).toBeNull();
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith(
"message",
"received",
expect.stringContaining("telegram"),
expect.objectContaining({
channelId: "telegram",
content: "hello without mention",
}),
);
expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1);
});
it("uses wildcard ingest when a specific group override omits ingest", async () => {
internalHookMocks.createInternalHookEvent.mockClear();
internalHookMocks.triggerInternalHook.mockClear();
const result = await buildTelegramMessageContextForTest({
message: makeGroupMessage("hello without mention"),
cfg: {
agents: {
defaults: {
model: "anthropic/sonnet-4.6",
workspace: "/tmp/openclaw",
},
},
channels: {
telegram: {
groups: {
"*": {
requireMention: true,
ingest: true,
},
"-1001234567890": {
requireMention: true,
},
},
},
},
messages: {
groupChat: {
mentionPatterns: ["@bot"],
},
},
} as never,
resolveGroupRequireMention: () => true,
resolveTelegramGroupConfig: () => ({
groupConfig: {
requireMention: true,
},
topicConfig: undefined,
}),
});
expect(result).toBeNull();
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith(
"message",
"received",
expect.stringContaining("telegram"),
expect.objectContaining({
channelId: "telegram",
content: "hello without mention",
}),
);
expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,141 @@
import type { BuildTelegramMessageContextParams, TelegramMediaRef } from "./bot-message-context.js";
export const baseTelegramMessageContextConfig = {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: [] } },
} as never;
type TelegramTestSessionRuntime = NonNullable<BuildTelegramMessageContextParams["sessionRuntime"]>;
const finalizeInboundContextForTest = ((ctx) => {
const next = ctx as Record<string, unknown>;
const body = typeof next.Body === "string" ? next.Body : "";
next.Body = body;
next.BodyForAgent =
typeof next.BodyForAgent === "string"
? next.BodyForAgent
: typeof next.RawBody === "string"
? next.RawBody
: body;
next.BodyForCommands =
typeof next.BodyForCommands === "string"
? next.BodyForCommands
: typeof next.CommandBody === "string"
? next.CommandBody
: typeof next.RawBody === "string"
? next.RawBody
: body;
next.CommandAuthorized = Boolean(next.CommandAuthorized);
return next;
}) as NonNullable<TelegramTestSessionRuntime["finalizeInboundContext"]>;
type BuildTelegramMessageContextForTestParams = {
message: Record<string, unknown>;
allMedia?: TelegramMediaRef[];
options?: BuildTelegramMessageContextParams["options"];
cfg?: Record<string, unknown>;
accountId?: string;
runtime?: BuildTelegramMessageContextParams["runtime"];
sessionRuntime?: BuildTelegramMessageContextParams["sessionRuntime"] | null;
resolveGroupActivation?: BuildTelegramMessageContextParams["resolveGroupActivation"];
resolveGroupRequireMention?: BuildTelegramMessageContextParams["resolveGroupRequireMention"];
resolveTelegramGroupConfig?: BuildTelegramMessageContextParams["resolveTelegramGroupConfig"];
};
const telegramMessageContextSessionRuntimeForTest = {
finalizeInboundContext: finalizeInboundContextForTest,
readSessionUpdatedAt: () => undefined,
recordInboundSession: async () => undefined,
resolveInboundLastRouteSessionKey: ({ route, sessionKey }) =>
route.lastRoutePolicy === "main" ? route.mainSessionKey : sessionKey,
resolvePinnedMainDmOwnerFromAllowlist: () => null,
resolveStorePath: () => "/tmp/openclaw/session-store.json",
} satisfies NonNullable<BuildTelegramMessageContextParams["sessionRuntime"]>;
export async function buildTelegramMessageContextForTest(
params: BuildTelegramMessageContextForTestParams,
): Promise<
Awaited<ReturnType<typeof import("./bot-message-context.js").buildTelegramMessageContext>>
> {
const { vi } = await loadVitestModule();
const buildTelegramMessageContext = await loadBuildTelegramMessageContext();
const sessionRuntime =
params.sessionRuntime === null
? undefined
: {
...telegramMessageContextSessionRuntimeForTest,
...params.sessionRuntime,
};
return await buildTelegramMessageContext({
primaryCtx: {
message: {
message_id: 1,
date: 1_700_000_000,
text: "hello",
from: { id: 42, first_name: "Alice" },
...params.message,
},
me: { id: 7, username: "bot" },
} as never,
allMedia: params.allMedia ?? [],
storeAllowFrom: [],
options: params.options ?? {},
bot: {
api: {
sendChatAction: vi.fn(),
setMessageReaction: vi.fn(),
},
} as never,
cfg: (params.cfg ?? baseTelegramMessageContextConfig) as never,
loadFreshConfig: () => (params.cfg ?? baseTelegramMessageContextConfig) as never,
runtime: {
recordChannelActivity: () => undefined,
...params.runtime,
},
sessionRuntime,
account: { accountId: params.accountId ?? "default" } as never,
historyLimit: 0,
groupHistories: new Map(),
dmPolicy: "open",
allowFrom: [],
groupAllowFrom: [],
ackReactionScope: "off",
logger: { info: vi.fn() },
resolveGroupActivation: params.resolveGroupActivation ?? (() => undefined),
resolveGroupRequireMention: params.resolveGroupRequireMention ?? (() => false),
resolveTelegramGroupConfig:
params.resolveTelegramGroupConfig ??
(() => ({
groupConfig: { requireMention: false },
topicConfig: undefined,
})),
sendChatActionHandler: { sendChatAction: vi.fn() } as never,
});
}
let buildTelegramMessageContextLoader:
| typeof import("./bot-message-context.js").buildTelegramMessageContext
| undefined;
let vitestModuleLoader: Promise<typeof import("vitest")> | undefined;
let messageContextMocksInstalled = false;
async function loadBuildTelegramMessageContext() {
await installMessageContextTestMocks();
if (!buildTelegramMessageContextLoader) {
({ buildTelegramMessageContext: buildTelegramMessageContextLoader } =
await import("./bot-message-context.js"));
}
return buildTelegramMessageContextLoader;
}
async function loadVitestModule() {
vitestModuleLoader ??= import("vitest");
return await vitestModuleLoader;
}
async function installMessageContextTestMocks() {
if (messageContextMocksInstalled) {
return;
}
messageContextMocksInstalled = true;
}

View file

@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { telegramRouteTestSessionRuntime } from "./bot-message-context.route-test-support.js";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
const recordInboundSessionMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const resolveTelegramConversationRouteMock = vi.hoisted(() => vi.fn());
type TelegramTestSessionRuntime = NonNullable<
import("./bot-message-context.types.js").BuildTelegramMessageContextParams["sessionRuntime"]
>;
const recordInboundSessionForThreadBindingTest: NonNullable<
TelegramTestSessionRuntime["recordInboundSession"]
> = async (params) => {
await recordInboundSessionMock(params);
};
vi.mock("./conversation-route.js", async () => {
const actual =
await vi.importActual<typeof import("./conversation-route.js")>("./conversation-route.js");
return {
...actual,
resolveTelegramConversationRoute: (...args: unknown[]) =>
resolveTelegramConversationRouteMock(...args),
};
});
const threadBindingSessionRuntime = {
...telegramRouteTestSessionRuntime,
recordInboundSession: recordInboundSessionForThreadBindingTest,
} satisfies TelegramTestSessionRuntime;
function createBoundRoute(params: { accountId: string; sessionKey: string; agentId: string }) {
return {
configuredBinding: null,
configuredBindingSessionKey: "",
route: {
accountId: params.accountId,
agentId: params.agentId,
channel: "telegram",
sessionKey: params.sessionKey,
mainSessionKey: `agent:${params.agentId}:main`,
matchedBy: "binding.channel",
lastRoutePolicy: "bound",
},
} as const;
}
describe("buildTelegramMessageContext thread binding override", () => {
beforeEach(() => {
recordInboundSessionMock.mockClear();
resolveTelegramConversationRouteMock.mockReset();
});
it("passes forum topic messages through the route seam and uses the bound session", async () => {
resolveTelegramConversationRouteMock.mockReturnValue(
createBoundRoute({
accountId: "default",
sessionKey: "agent:codex-acp:session-1",
agentId: "codex-acp",
}),
);
const ctx = await buildTelegramMessageContextForTest({
sessionRuntime: threadBindingSessionRuntime,
message: {
message_id: 1,
chat: { id: -100200300, type: "supergroup", is_forum: true },
message_thread_id: 77,
date: 1_700_000_000,
text: "hello",
from: { id: 42, first_name: "Alice" },
},
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
});
expect(resolveTelegramConversationRouteMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "default",
chatId: -100200300,
isGroup: true,
resolvedThreadId: 77,
replyThreadId: 77,
senderId: "42",
}),
);
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:codex-acp:session-1");
expect(recordInboundSessionMock.mock.calls[0]?.[0]).toMatchObject({
updateLastRoute: undefined,
});
});
it("treats named-account bound conversations as explicit route matches", async () => {
resolveTelegramConversationRouteMock.mockReturnValue(
createBoundRoute({
accountId: "work",
sessionKey: "agent:codex-acp:session-2",
agentId: "codex-acp",
}),
);
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
sessionRuntime: threadBindingSessionRuntime,
message: {
message_id: 1,
chat: { id: -100200300, type: "supergroup", is_forum: true },
message_thread_id: 77,
date: 1_700_000_000,
text: "hello",
from: { id: 42, first_name: "Alice" },
},
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
});
expect(resolveTelegramConversationRouteMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "work",
chatId: -100200300,
isGroup: true,
resolvedThreadId: 77,
replyThreadId: 77,
senderId: "42",
}),
);
expect(ctx).not.toBeNull();
expect(ctx?.route.accountId).toBe("work");
expect(ctx?.route.matchedBy).toBe("binding.channel");
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:codex-acp:session-2");
});
it("passes dm messages through the route seam and uses the bound session", async () => {
resolveTelegramConversationRouteMock.mockReturnValue(
createBoundRoute({
accountId: "default",
sessionKey: "agent:codex-acp:session-dm",
agentId: "codex-acp",
}),
);
const ctx = await buildTelegramMessageContextForTest({
sessionRuntime: threadBindingSessionRuntime,
message: {
message_id: 1,
chat: { id: 1234, type: "private" },
date: 1_700_000_000,
text: "hello",
from: { id: 42, first_name: "Alice" },
},
});
expect(resolveTelegramConversationRouteMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "default",
chatId: 1234,
isGroup: false,
resolvedThreadId: undefined,
replyThreadId: undefined,
senderId: "42",
}),
);
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:codex-acp:session-dm");
});
});

View file

@ -0,0 +1,144 @@
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { defaultRouteConfig } = vi.hoisted(() => ({
defaultRouteConfig: {
agents: {
list: [{ id: "main", default: true }, { id: "zu" }, { id: "q" }, { id: "support" }],
},
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: [] } },
},
}));
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
"openclaw/plugin-sdk/config-runtime",
);
return {
...actual,
loadConfig: vi.fn(() => defaultRouteConfig),
};
});
const { buildTelegramMessageContextForTest } =
await import("./bot-message-context.test-harness.js");
describe("buildTelegramMessageContext per-topic agentId routing", () => {
function buildForumMessage(threadId = 3) {
return {
message_id: 1,
chat: {
id: -1001234567890,
type: "supergroup" as const,
title: "Forum",
is_forum: true,
},
date: 1700000000,
text: "@bot hello",
message_thread_id: threadId,
from: { id: 42, first_name: "Alice" },
};
}
async function buildForumContext(params: {
threadId?: number;
topicConfig?: Record<string, unknown>;
}) {
return await buildTelegramMessageContextForTest({
message: buildForumMessage(params.threadId),
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: false },
...(params.topicConfig ? { topicConfig: params.topicConfig } : {}),
}),
});
}
beforeEach(() => {
vi.mocked(loadConfig).mockReturnValue(defaultRouteConfig as never);
});
it("uses group-level agent when no topic agentId is set", async () => {
const ctx = await buildForumContext({ topicConfig: { systemPrompt: "Be nice" } });
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:3");
});
it("routes to topic-specific agent when agentId is set", async () => {
const ctx = await buildForumContext({
topicConfig: { agentId: "zu", systemPrompt: "I am Zu" },
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.SessionKey).toContain("agent:zu:");
expect(ctx?.ctxPayload?.SessionKey).toContain("telegram:group:-1001234567890:topic:3");
});
it("different topics route to different agents", async () => {
const buildForTopic = async (threadId: number, agentId: string) =>
await buildForumContext({ threadId, topicConfig: { agentId } });
const ctxA = await buildForTopic(1, "main");
const ctxB = await buildForTopic(3, "zu");
const ctxC = await buildForTopic(5, "q");
expect(ctxA?.ctxPayload?.SessionKey).toContain("agent:main:");
expect(ctxB?.ctxPayload?.SessionKey).toContain("agent:zu:");
expect(ctxC?.ctxPayload?.SessionKey).toContain("agent:q:");
expect(ctxA?.ctxPayload?.SessionKey).not.toBe(ctxB?.ctxPayload?.SessionKey);
expect(ctxB?.ctxPayload?.SessionKey).not.toBe(ctxC?.ctxPayload?.SessionKey);
});
it("ignores whitespace-only agentId and uses group-level agent", async () => {
const ctx = await buildForumContext({
topicConfig: { agentId: " ", systemPrompt: "Be nice" },
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.SessionKey).toContain("agent:main:");
});
it("preserves an unknown topic agentId in the session key", async () => {
vi.mocked(loadConfig).mockReturnValue({
agents: {
list: [{ id: "main", default: true }, { id: "zu" }],
},
channels: { telegram: {} },
messages: { groupChat: { mentionPatterns: [] } },
} as never);
const ctx = await buildForumContext({ topicConfig: { agentId: "ghost" } });
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.SessionKey).toContain("agent:ghost:");
});
it("routes DM topic to specific agent when agentId is set", async () => {
const ctx = await buildTelegramMessageContextForTest({
message: {
message_id: 1,
chat: {
id: 123456789,
type: "private",
},
date: 1700000000,
text: "@bot hello",
message_thread_id: 99,
from: { id: 42, first_name: "Alice" },
},
options: { forceWasMentioned: true },
resolveGroupActivation: () => true,
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: false },
topicConfig: { agentId: "support", systemPrompt: "I am support" },
}),
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.SessionKey).toContain("agent:support:");
});
});

View file

@ -0,0 +1,610 @@
import type { ReactionTypeEmoji } from "@grammyjs/types";
import {
resolveAckReaction,
shouldAckReaction as shouldAckReactionGate,
} from "openclaw/plugin-sdk/channel-feedback";
import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
import type { TelegramDirectConfig, TelegramGroupConfig } from "openclaw/plugin-sdk/config-runtime";
import { deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing";
import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import { firstDefined, normalizeAllowFrom, normalizeDmAllowFromWithStore } from "./bot-access.js";
import { resolveTelegramInboundBody } from "./bot-message-context.body.js";
import {
buildTelegramInboundContextPayload,
resolveTelegramMessageContextStorePath,
} from "./bot-message-context.session.js";
import type { BuildTelegramMessageContextParams } from "./bot-message-context.types.js";
import {
buildTypingThreadParams,
extractTelegramForumFlag,
resolveTelegramForumFlag,
resolveTelegramThreadSpec,
} from "./bot/helpers.js";
import type { TelegramGetChat } from "./bot/types.js";
import {
resolveTelegramConversationBaseSessionKey,
resolveTelegramConversationRoute,
} from "./conversation-route.js";
import { enforceTelegramDmAccess } from "./dm-access.js";
import { evaluateTelegramGroupBaseAccess } from "./group-access.js";
import {
buildTelegramStatusReactionVariants,
type TelegramReactionEmoji,
isTelegramSupportedReactionEmoji,
resolveTelegramAllowedEmojiReactions,
resolveTelegramReactionVariant,
resolveTelegramStatusReactionEmojis,
} from "./status-reaction-variants.js";
import { getTopicName, resolveTopicNameCachePath, updateTopicName } from "./topic-name-cache.js";
export type {
BuildTelegramMessageContextParams,
TelegramMediaRef,
} from "./bot-message-context.types.js";
type TelegramMessageContextRuntime = typeof import("./bot-message-context.runtime.js");
let telegramMessageContextRuntimePromise: Promise<TelegramMessageContextRuntime> | undefined;
async function loadTelegramMessageContextRuntime() {
telegramMessageContextRuntimePromise ??= import("./bot-message-context.runtime.js");
return await telegramMessageContextRuntimePromise;
}
type TelegramMessageContextPayload = Awaited<ReturnType<typeof buildTelegramInboundContextPayload>>;
type TelegramReactionApi = (
chatId: BuildTelegramMessageContextParams["primaryCtx"]["message"]["chat"]["id"],
messageId: number,
reactions: Array<{ type: "emoji"; emoji: ReactionTypeEmoji["emoji"] }>,
) => Promise<unknown>;
type TelegramStatusReactionController = {
setQueued: () => void | Promise<void>;
setThinking: () => void | Promise<void>;
setTool: (name: string) => void | Promise<void>;
setCompacting: () => void | Promise<void>;
cancelPending: () => void;
setError: () => void | Promise<void>;
setDone: () => void | Promise<void>;
};
export type TelegramMessageContext = {
ctxPayload: TelegramMessageContextPayload["ctxPayload"];
primaryCtx: BuildTelegramMessageContextParams["primaryCtx"];
msg: BuildTelegramMessageContextParams["primaryCtx"]["message"];
chatId: BuildTelegramMessageContextParams["primaryCtx"]["message"]["chat"]["id"];
isGroup: boolean;
groupConfig?: ReturnType<
BuildTelegramMessageContextParams["resolveTelegramGroupConfig"]
>["groupConfig"];
topicConfig?: ReturnType<
BuildTelegramMessageContextParams["resolveTelegramGroupConfig"]
>["topicConfig"];
resolvedThreadId?: number;
threadSpec: ReturnType<typeof resolveTelegramThreadSpec>;
replyThreadId?: number;
isForum: boolean;
historyKey?: string;
historyLimit: BuildTelegramMessageContextParams["historyLimit"];
groupHistories: BuildTelegramMessageContextParams["groupHistories"];
route: ReturnType<typeof resolveTelegramConversationRoute>["route"];
skillFilter: TelegramMessageContextPayload["skillFilter"];
sendTyping: () => Promise<void>;
sendRecordVoice: () => Promise<void>;
ackReactionPromise: Promise<boolean> | null;
reactionApi: TelegramReactionApi | null;
removeAckAfterReply: boolean;
statusReactionController: TelegramStatusReactionController | null;
accountId: string;
};
export const buildTelegramMessageContext = async ({
primaryCtx,
allMedia,
replyMedia = [],
storeAllowFrom,
options,
bot,
cfg,
account,
historyLimit,
groupHistories,
dmPolicy,
allowFrom,
groupAllowFrom,
ackReactionScope,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
resolveTelegramGroupConfig,
loadFreshConfig,
runtime,
sessionRuntime,
upsertPairingRequest,
sendChatActionHandler,
}: BuildTelegramMessageContextParams): Promise<TelegramMessageContext | null> => {
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 reactionApi =
typeof bot.api.setMessageReaction === "function"
? bot.api.setMessageReaction.bind(bot.api)
: null;
const getChatApi =
typeof bot.api.getChat === "function"
? (bot.api.getChat.bind(bot.api) as TelegramGetChat)
: undefined;
const isForum = await resolveTelegramForumFlag({
chatId,
chatType: msg.chat.type,
isGroup,
isForum: extractTelegramForumFlag(msg.chat),
getChat: getChatApi,
});
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 topicNameCachePath = resolveTopicNameCachePath(
await resolveTelegramMessageContextStorePath({
cfg,
agentId: account.accountId,
sessionRuntime,
}),
);
let topicName: string | undefined;
if (isForum && resolvedThreadId != null) {
const ftCreated = msg.forum_topic_created;
const ftEdited = msg.forum_topic_edited;
const ftClosed = msg.forum_topic_closed;
const ftReopened = msg.forum_topic_reopened;
const topicPatch = ftCreated?.name
? {
name: ftCreated.name,
iconColor: ftCreated.icon_color,
iconCustomEmojiId: ftCreated.icon_custom_emoji_id,
closed: false,
}
: ftEdited?.name
? {
name: ftEdited.name,
iconCustomEmojiId: ftEdited.icon_custom_emoji_id,
}
: ftClosed
? { closed: true }
: ftReopened
? { closed: false }
: undefined;
if (topicPatch) {
updateTopicName(chatId, resolvedThreadId, topicPatch, topicNameCachePath);
}
topicName = getTopicName(chatId, resolvedThreadId, topicNameCachePath);
if (!topicName) {
const replyFtCreated = msg.reply_to_message?.forum_topic_created;
if (replyFtCreated?.name) {
updateTopicName(
chatId,
resolvedThreadId,
{
name: replyFtCreated.name,
iconColor: replyFtCreated.icon_color,
iconCustomEmojiId: replyFtCreated.icon_custom_emoji_id,
},
topicNameCachePath,
);
topicName = replyFtCreated.name;
}
}
}
const threadIdForConfig = resolvedThreadId ?? dmThreadId;
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, threadIdForConfig);
const directConfig = !isGroup ? (groupConfig as TelegramDirectConfig | undefined) : undefined;
const telegramGroupConfig = isGroup
? (groupConfig as TelegramGroupConfig | undefined)
: undefined;
// 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 =
loadFreshConfig?.() ??
(runtime?.loadConfig ?? (await loadTelegramMessageContextRuntime()).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 = directConfig?.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,
upsertPairingRequest,
}))
) {
return null;
}
const ensureConfiguredBindingReady = async (): Promise<boolean> => {
if (!configuredBinding) {
return true;
}
const ensureConfiguredBindingRouteReady =
runtime?.ensureConfiguredBindingRouteReady ??
(await loadTelegramMessageContextRuntime()).ensureConfiguredBindingRouteReady;
const ensured = await ensureConfiguredBindingRouteReady({
cfg: freshCfg,
bindingResolution: configuredBinding,
});
if (ensured.ok) {
logVerbose(
`telegram: using configured ACP binding for ${configuredBinding.record.conversation.conversationId} -> ${configuredBindingSessionKey}`,
);
return true;
}
logVerbose(
`telegram: configured ACP binding unavailable for ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`,
);
logInboundDrop({
log: logVerbose,
channel: "telegram",
reason: "configured ACP binding unavailable",
target: configuredBinding.record.conversation.conversationId,
});
return false;
};
const baseSessionKey = resolveTelegramConversationBaseSessionKey({
cfg: freshCfg,
route,
chatId,
isGroup,
senderId,
});
// 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,
telegramGroupConfig?.requireMention,
baseRequireMention,
);
const recordChannelActivity =
runtime?.recordChannelActivity ??
(await loadTelegramMessageContextRuntime()).recordChannelActivity;
recordChannelActivity({
channel: "telegram",
accountId: account.accountId,
direction: "inbound",
});
const bodyResult = await resolveTelegramInboundBody({
cfg,
primaryCtx,
msg,
allMedia,
isGroup,
chatId,
accountId: account.accountId,
senderId,
senderUsername,
resolvedThreadId,
routeAgentId: route.agentId,
sessionKey,
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 ackReactionEmoji =
ackReaction && isTelegramSupportedReactionEmoji(ackReaction) ? ackReaction : undefined;
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,
}),
);
// 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<TelegramReactionEmoji> | null> | null = null;
const createStatusReactionController =
statusReactionsEnabled && msg.message_id
? (runtime?.createStatusReactionController ??
(await loadTelegramMessageContextRuntime()).createStatusReactionController)
: null;
const statusReactionController: TelegramStatusReactionController | null =
createStatusReactionController
? 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: Promise<boolean> | null = statusReactionController
? shouldAckReaction()
? Promise.resolve(statusReactionController.setQueued()).then(
() => true,
() => false,
)
: null
: shouldAckReaction() && msg.message_id && reactionApi && ackReactionEmoji
? withTelegramApiErrorLogging({
operation: "setMessageReaction",
fn: () =>
reactionApi(chatId, msg.message_id, [{ type: "emoji", emoji: ackReactionEmoji }]),
}).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,
effectiveGroupAllow,
commandAuthorized: bodyResult.commandAuthorized,
topicName,
sessionRuntime,
});
return {
ctxPayload,
primaryCtx,
msg,
chatId,
isGroup,
groupConfig,
topicConfig,
resolvedThreadId,
threadSpec,
replyThreadId,
isForum,
historyKey: bodyResult.historyKey ?? "",
historyLimit,
groupHistories,
route,
skillFilter,
sendTyping,
sendRecordVoice,
ackReactionPromise,
reactionApi,
removeAckAfterReply,
statusReactionController,
accountId: account.accountId,
};
};

View file

@ -0,0 +1,94 @@
import type { Bot } from "grammy";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type {
DmPolicy,
TelegramDirectConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "openclaw/plugin-sdk/config-runtime";
import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import type { StickerMetadata, TelegramContext } from "./bot/types.js";
export type TelegramMediaRef = {
path: string;
contentType?: string;
stickerMetadata?: StickerMetadata;
};
export type TelegramMessageContextOptions = {
commandSource?: "text" | "native";
forceWasMentioned?: boolean;
messageIdOverride?: string;
receivedAtMs?: number;
ingressBuffer?: "inbound-debounce" | "text-fragment";
};
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 TelegramMessageContextRuntimeOverrides = Partial<
Pick<
typeof import("./bot-message-context.runtime.js"),
| "createStatusReactionController"
| "ensureConfiguredBindingRouteReady"
| "loadConfig"
| "recordChannelActivity"
>
>;
export type TelegramMessageContextSessionRuntimeOverrides = Partial<
Pick<
typeof import("./bot-message-context.session.runtime.js"),
| "finalizeInboundContext"
| "readSessionUpdatedAt"
| "recordInboundSession"
| "resolveInboundLastRouteSessionKey"
| "resolvePinnedMainDmOwnerFromAllowlist"
| "resolveStorePath"
>
>;
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;
loadFreshConfig?: () => OpenClawConfig;
runtime?: TelegramMessageContextRuntimeOverrides;
sessionRuntime?: TelegramMessageContextSessionRuntimeOverrides;
upsertPairingRequest?: typeof import("openclaw/plugin-sdk/conversation-runtime").upsertChannelPairingRequest;
/** Global (per-account) handler for sendChatAction 401 backoff (#27092). */
sendChatActionHandler: import("./sendchataction-401-backoff.js").TelegramSendChatActionHandler;
};

View file

@ -0,0 +1,7 @@
export {
findModelInCatalog,
loadModelCatalog,
modelSupportsVision,
resolveAgentDir,
resolveDefaultModelForAgent,
} from "openclaw/plugin-sdk/agent-runtime";

View file

@ -0,0 +1,12 @@
export {
loadSessionStore,
resolveMarkdownTableMode,
resolveSessionStoreEntry,
resolveStorePath,
} from "openclaw/plugin-sdk/config-runtime";
export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
export { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
export {
generateTelegramTopicLabel as generateTopicLabel,
resolveAutoTopicLabelConfig,
} from "./auto-topic-label.js";

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { pruneStickerMediaFromContext } from "./bot-message-dispatch.js";
type MediaCtx = {
MediaPath?: string;
MediaUrl?: string;
MediaType?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
};
function expectSingleImageMedia(ctx: MediaCtx, mediaPath: string) {
expect(ctx.MediaPath).toBe(mediaPath);
expect(ctx.MediaUrl).toBe(mediaPath);
expect(ctx.MediaType).toBe("image/jpeg");
expect(ctx.MediaPaths).toEqual([mediaPath]);
expect(ctx.MediaUrls).toEqual([mediaPath]);
expect(ctx.MediaTypes).toEqual(["image/jpeg"]);
}
describe("pruneStickerMediaFromContext", () => {
it("preserves appended reply media while removing primary sticker media", () => {
const ctx: MediaCtx = {
MediaPath: "/tmp/sticker.webp",
MediaUrl: "/tmp/sticker.webp",
MediaType: "image/webp",
MediaPaths: ["/tmp/sticker.webp", "/tmp/replied.jpg"],
MediaUrls: ["/tmp/sticker.webp", "/tmp/replied.jpg"],
MediaTypes: ["image/webp", "image/jpeg"],
};
pruneStickerMediaFromContext(ctx);
expectSingleImageMedia(ctx, "/tmp/replied.jpg");
});
it("clears media fields when sticker is the only media", () => {
const ctx: MediaCtx = {
MediaPath: "/tmp/sticker.webp",
MediaUrl: "/tmp/sticker.webp",
MediaType: "image/webp",
MediaPaths: ["/tmp/sticker.webp"],
MediaUrls: ["/tmp/sticker.webp"],
MediaTypes: ["image/webp"],
};
pruneStickerMediaFromContext(ctx);
expect(ctx.MediaPath).toBeUndefined();
expect(ctx.MediaUrl).toBeUndefined();
expect(ctx.MediaType).toBeUndefined();
expect(ctx.MediaPaths).toBeUndefined();
expect(ctx.MediaUrls).toBeUndefined();
expect(ctx.MediaTypes).toBeUndefined();
});
it("does not prune when sticker media is already omitted from context", () => {
const ctx: MediaCtx = {
MediaPath: "/tmp/replied.jpg",
MediaUrl: "/tmp/replied.jpg",
MediaType: "image/jpeg",
MediaPaths: ["/tmp/replied.jpg"],
MediaUrls: ["/tmp/replied.jpg"],
MediaTypes: ["image/jpeg"],
};
pruneStickerMediaFromContext(ctx, { stickerMediaIncluded: false });
expectSingleImageMedia(ctx, "/tmp/replied.jpg");
});
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,162 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { TelegramBotDeps } from "./bot-deps.js";
const buildTelegramMessageContext = vi.hoisted(() => vi.fn());
const dispatchTelegramMessage = vi.hoisted(() => vi.fn());
const upsertChannelPairingRequest = vi.hoisted(() =>
vi.fn(async () => ({ code: "PAIRCODE", created: true })),
);
vi.mock("./bot-message-context.js", () => ({
buildTelegramMessageContext,
}));
vi.mock("./bot-message-dispatch.js", () => ({
dispatchTelegramMessage,
}));
let createTelegramMessageProcessor: typeof import("./bot-message.js").createTelegramMessageProcessor;
describe("telegram bot message processor", () => {
beforeAll(async () => {
({ createTelegramMessageProcessor } = await import("./bot-message.js"));
});
beforeEach(() => {
buildTelegramMessageContext.mockClear();
dispatchTelegramMessage.mockClear();
upsertChannelPairingRequest.mockClear();
});
const telegramDepsForTest = {
upsertChannelPairingRequest,
} as unknown as TelegramBotDeps;
const baseDeps = {
bot: {},
cfg: {},
account: {},
telegramCfg: {},
historyLimit: 0,
groupHistories: {},
dmPolicy: {},
allowFrom: [],
groupAllowFrom: [],
ackReactionScope: "none",
logger: {},
resolveGroupActivation: () => true,
resolveGroupRequireMention: () => false,
resolveTelegramGroupConfig: () => ({}),
runtime: {},
replyToMode: "auto",
streamMode: "partial",
textLimit: 4096,
telegramDeps: telegramDepsForTest,
opts: {},
} as unknown as Parameters<typeof createTelegramMessageProcessor>[0];
async function processSampleMessage(
processMessage: ReturnType<typeof createTelegramMessageProcessor>,
) {
await processMessage(
{
message: {
chat: { id: 123, type: "private", title: "chat" },
message_id: 456,
},
} as unknown as Parameters<typeof processMessage>[0],
[],
[],
{},
);
}
function createDispatchFailureHarness(
context: Record<string, unknown>,
sendMessage: ReturnType<typeof vi.fn>,
) {
const runtimeError = vi.fn();
buildTelegramMessageContext.mockResolvedValue(context);
dispatchTelegramMessage.mockRejectedValue(new Error("dispatch exploded"));
const processMessage = createTelegramMessageProcessor({
...baseDeps,
bot: { api: { sendMessage } },
runtime: { error: runtimeError },
} as unknown as Parameters<typeof createTelegramMessageProcessor>[0]);
return { processMessage, runtimeError };
}
it("dispatches when context is available", async () => {
buildTelegramMessageContext.mockResolvedValue({ route: { sessionKey: "agent:main:main" } });
const processMessage = createTelegramMessageProcessor(baseDeps);
await processSampleMessage(processMessage);
expect(dispatchTelegramMessage).toHaveBeenCalledTimes(1);
});
it("skips dispatch when no context is produced", async () => {
buildTelegramMessageContext.mockResolvedValue(null);
const processMessage = createTelegramMessageProcessor(baseDeps);
await processSampleMessage(processMessage);
expect(dispatchTelegramMessage).not.toHaveBeenCalled();
});
it("sends user-visible fallback when dispatch throws", async () => {
const sendMessage = vi.fn().mockResolvedValue(undefined);
const { processMessage, runtimeError } = createDispatchFailureHarness(
{
chatId: 123,
threadSpec: { id: 456, scope: "forum" },
route: { sessionKey: "agent:main:main" },
},
sendMessage,
);
await expect(processSampleMessage(processMessage)).resolves.toBeUndefined();
expect(sendMessage).toHaveBeenCalledWith(
123,
"Something went wrong while processing your request. Please try again.",
{ message_thread_id: 456 },
);
expect(runtimeError).toHaveBeenCalledWith(expect.stringContaining("dispatch exploded"));
});
it("omits message_thread_id for General-topic fallback replies", async () => {
const sendMessage = vi.fn().mockResolvedValue(undefined);
const { processMessage } = createDispatchFailureHarness(
{
chatId: 123,
threadSpec: { id: 1, scope: "forum" },
route: { sessionKey: "agent:main:main" },
},
sendMessage,
);
await expect(processSampleMessage(processMessage)).resolves.toBeUndefined();
expect(sendMessage).toHaveBeenCalledWith(
123,
"Something went wrong while processing your request. Please try again.",
undefined,
);
});
it("swallows fallback delivery failures after dispatch throws", async () => {
const sendMessage = vi.fn().mockRejectedValue(new Error("blocked by user"));
const { processMessage, runtimeError } = createDispatchFailureHarness(
{
chatId: 123,
route: { sessionKey: "agent:main:main" },
},
sendMessage,
);
await expect(processSampleMessage(processMessage)).resolves.toBeUndefined();
expect(sendMessage).toHaveBeenCalledWith(
123,
"Something went wrong while processing your request. Please try again.",
undefined,
);
expect(runtimeError).toHaveBeenCalledWith(expect.stringContaining("dispatch exploded"));
});
});

View file

@ -0,0 +1,142 @@
import type { ReplyToMode } from "openclaw/plugin-sdk/config-runtime";
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { TelegramBotDeps } from "./bot-deps.js";
import {
buildTelegramMessageContext,
type BuildTelegramMessageContextParams,
type TelegramMediaRef,
} from "./bot-message-context.js";
import type { TelegramMessageContextOptions } from "./bot-message-context.types.js";
import { dispatchTelegramMessage } from "./bot-message-dispatch.js";
import type { TelegramBotOptions } from "./bot.types.js";
import { buildTelegramThreadParams } from "./bot/helpers.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;
telegramDeps: TelegramBotDeps;
opts: Pick<TelegramBotOptions, "token">;
};
export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDeps) => {
const {
bot,
cfg,
account,
telegramCfg,
historyLimit,
groupHistories,
dmPolicy,
allowFrom,
groupAllowFrom,
ackReactionScope,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
resolveTelegramGroupConfig,
loadFreshConfig,
sendChatActionHandler,
runtime,
replyToMode,
streamMode,
textLimit,
telegramDeps,
opts,
} = deps;
return async (
primaryCtx: TelegramContext,
allMedia: TelegramMediaRef[],
storeAllowFrom: string[],
options?: TelegramMessageContextOptions,
replyMedia?: TelegramMediaRef[],
) => {
const ingressReceivedAtMs =
typeof options?.receivedAtMs === "number" && Number.isFinite(options.receivedAtMs)
? options.receivedAtMs
: undefined;
const ingressDebugEnabled =
shouldLogVerbose() || process.env.OPENCLAW_DEBUG_TELEGRAM_INGRESS === "1";
const ingressContextStartMs = ingressReceivedAtMs ? Date.now() : undefined;
const context = await buildTelegramMessageContext({
primaryCtx,
allMedia,
replyMedia,
storeAllowFrom,
options,
bot,
cfg,
account,
historyLimit,
groupHistories,
dmPolicy,
allowFrom,
groupAllowFrom,
ackReactionScope,
logger,
resolveGroupActivation,
resolveGroupRequireMention,
resolveTelegramGroupConfig,
sendChatActionHandler,
loadFreshConfig,
upsertPairingRequest: telegramDeps.upsertChannelPairingRequest,
});
if (!context) {
if (ingressDebugEnabled && ingressReceivedAtMs && ingressContextStartMs) {
logVerbose(
`telegram ingress: chatId=${primaryCtx.message.chat.id} dropped after ${Date.now() - ingressReceivedAtMs}ms` +
(options?.ingressBuffer ? ` buffer=${options.ingressBuffer}` : ""),
);
}
return;
}
if (ingressDebugEnabled && ingressReceivedAtMs && ingressContextStartMs) {
logVerbose(
`telegram ingress: chatId=${context.chatId} contextReadyMs=${Date.now() - ingressReceivedAtMs}` +
` preDispatchMs=${Date.now() - ingressContextStartMs}` +
(options?.ingressBuffer ? ` buffer=${options.ingressBuffer}` : ""),
);
}
try {
await dispatchTelegramMessage({
context,
bot,
cfg,
runtime,
replyToMode,
streamMode,
textLimit,
telegramCfg,
telegramDeps,
opts,
});
if (ingressDebugEnabled && ingressReceivedAtMs) {
logVerbose(
`telegram ingress: chatId=${context.chatId} dispatchCompleteMs=${Date.now() - ingressReceivedAtMs}` +
(options?.ingressBuffer ? ` buffer=${options.ingressBuffer}` : ""),
);
}
} 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.",
buildTelegramThreadParams(context.threadSpec),
);
} catch {
// Best-effort fallback; delivery may fail if the bot was blocked or the chat is invalid.
}
}
};
};

View file

@ -0,0 +1,51 @@
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
import { getPluginCommandSpecs } from "openclaw/plugin-sdk/plugin-runtime";
import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/skill-commands-runtime";
import type { TelegramBotDeps } from "./bot-deps.js";
import { syncTelegramMenuCommands } from "./bot-native-command-menu.js";
export type TelegramNativeCommandDeps = Pick<
TelegramBotDeps,
| "dispatchReplyWithBufferedBlockDispatcher"
| "editMessageTelegram"
| "listSkillCommandsForAgents"
| "loadConfig"
| "readChannelAllowFromStore"
| "syncTelegramMenuCommands"
> & {
getPluginCommandSpecs?: typeof getPluginCommandSpecs;
};
let telegramSendRuntimePromise: Promise<typeof import("./send.js")> | undefined;
async function loadTelegramSendRuntime() {
telegramSendRuntimePromise ??= import("./send.js");
return await telegramSendRuntimePromise;
}
export const defaultTelegramNativeCommandDeps: TelegramNativeCommandDeps = {
get loadConfig() {
return loadConfig;
},
get readChannelAllowFromStore() {
return readChannelAllowFromStore;
},
get dispatchReplyWithBufferedBlockDispatcher() {
return dispatchReplyWithBufferedBlockDispatcher;
},
get listSkillCommandsForAgents() {
return listSkillCommandsForAgents;
},
get syncTelegramMenuCommands() {
return syncTelegramMenuCommands;
},
get getPluginCommandSpecs() {
return getPluginCommandSpecs;
},
async editMessageTelegram(...args) {
const { editMessageTelegram } = await loadTelegramSendRuntime();
return await editMessageTelegram(...args);
},
};

View file

@ -0,0 +1,352 @@
import { describe, expect, it, vi } from "vitest";
import {
buildCappedTelegramMenuCommands,
buildPluginTelegramMenuCommands,
hashCommandList,
syncTelegramMenuCommands,
TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET,
} from "./bot-native-command-menu.js";
type SyncMenuOptions = {
deleteMyCommands: ReturnType<typeof vi.fn>;
setMyCommands: ReturnType<typeof vi.fn>;
commandsToRegister: Parameters<typeof syncTelegramMenuCommands>[0]["commandsToRegister"];
accountId: string;
botIdentity: string;
runtimeLog?: ReturnType<typeof vi.fn>;
runtimeError?: ReturnType<typeof vi.fn>;
};
function syncMenuCommandsWithMocks(options: SyncMenuOptions): void {
syncTelegramMenuCommands({
bot: {
api: { deleteMyCommands: options.deleteMyCommands, setMyCommands: options.setMyCommands },
} as unknown as Parameters<typeof syncTelegramMenuCommands>[0]["bot"],
runtime: {
log: options.runtimeLog ?? vi.fn(),
error: options.runtimeError ?? vi.fn(),
exit: vi.fn(),
} as Parameters<typeof syncTelegramMenuCommands>[0]["runtime"],
commandsToRegister: options.commandsToRegister,
accountId: options.accountId,
botIdentity: options.botIdentity,
});
}
describe("bot-native-command-menu", () => {
it("caps menu entries to Telegram limit", () => {
const allCommands = Array.from({ length: 105 }, (_, i) => ({
command: `cmd_${i}`,
description: `Command ${i}`,
}));
const result = buildCappedTelegramMenuCommands({ allCommands });
expect(result.commandsToRegister).toHaveLength(100);
expect(result.totalCommands).toBe(105);
expect(result.maxCommands).toBe(100);
expect(result.overflowCount).toBe(5);
expect(result.commandsToRegister[0]).toEqual({ command: "cmd_0", description: "Command 0" });
expect(result.commandsToRegister[99]).toEqual({
command: "cmd_99",
description: "Command 99",
});
});
it("shortens descriptions before dropping commands to fit Telegram payload budget", () => {
const allCommands = Array.from({ length: 92 }, (_, i) => ({
command: `cmd_${i}`,
description: "x".repeat(100),
}));
const result = buildCappedTelegramMenuCommands({ allCommands });
expect(result.commandsToRegister).toHaveLength(92);
expect(result.descriptionTrimmed).toBe(true);
expect(result.textBudgetDropCount).toBe(0);
const totalText = result.commandsToRegister.reduce(
(total, command) => total + command.command.length + command.description.length,
0,
);
expect(totalText).toBeLessThanOrEqual(TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET);
expect(result.commandsToRegister.every((command) => command.description.length <= 56)).toBe(
true,
);
});
it("drops tail commands only when minimal descriptions still cannot fit the payload budget", () => {
const allCommands = [
{ command: "alpha_cmd", description: "First command" },
{ command: "bravo_cmd", description: "Second command" },
{ command: "charlie_cmd", description: "Third command" },
];
const result = buildCappedTelegramMenuCommands({
allCommands,
maxTotalChars: 20,
});
expect(result.commandsToRegister).toEqual([
{ command: "alpha_cmd", description: "F" },
{ command: "bravo_cmd", description: "S" },
]);
expect(result.descriptionTrimmed).toBe(true);
expect(result.textBudgetDropCount).toBe(1);
});
it("validates plugin command specs and reports conflicts", () => {
const existingCommands = new Set(["native"]);
const result = buildPluginTelegramMenuCommands({
specs: [
{ name: "valid", description: " Works " },
{ name: "bad-name!", description: "Bad" },
{ name: "native", description: "Conflicts with native" },
{ name: "valid", description: "Duplicate plugin name" },
{ name: "empty", description: " " },
],
existingCommands,
});
expect(result.commands).toEqual([{ command: "valid", description: "Works" }]);
expect(result.issues).toContain(
'Plugin command "/bad-name!" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).',
);
expect(result.issues).toContain(
'Plugin command "/native" conflicts with an existing Telegram command.',
);
expect(result.issues).toContain('Plugin command "/valid" is duplicated.');
expect(result.issues).toContain('Plugin command "/empty" is missing a description.');
});
it("normalizes hyphenated plugin command names", () => {
const result = buildPluginTelegramMenuCommands({
specs: [{ name: "agent-run", description: "Run agent" }],
existingCommands: new Set<string>(),
});
expect(result.commands).toEqual([{ command: "agent_run", description: "Run agent" }]);
expect(result.issues).toEqual([]);
});
it("ignores malformed plugin specs without crashing", () => {
const malformedSpecs = [
{ name: "valid", description: " Works " },
{ name: "missing-description", description: undefined },
{ name: undefined, description: "Missing name" },
] as unknown as Parameters<typeof buildPluginTelegramMenuCommands>[0]["specs"];
const result = buildPluginTelegramMenuCommands({
specs: malformedSpecs,
existingCommands: new Set<string>(),
});
expect(result.commands).toEqual([{ command: "valid", description: "Works" }]);
expect(result.issues).toContain(
'Plugin command "/missing_description" is missing a description.',
);
expect(result.issues).toContain(
'Plugin command "/<unknown>" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).',
);
});
it("deletes stale commands before setting new menu", async () => {
const callOrder: string[] = [];
const deleteMyCommands = vi.fn(async () => {
callOrder.push("delete");
});
const setMyCommands = vi.fn(async () => {
callOrder.push("set");
});
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
commandsToRegister: [{ command: "cmd", description: "Command" }],
accountId: `test-delete-${Date.now()}`,
botIdentity: "bot-a",
});
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalled();
});
expect(callOrder).toEqual(["delete", "set"]);
});
it("produces a stable hash regardless of command order (#32017)", () => {
const commands = [
{ command: "bravo", description: "B" },
{ command: "alpha", description: "A" },
];
const reversed = [...commands].toReversed();
expect(hashCommandList(commands)).toBe(hashCommandList(reversed));
});
it("produces different hashes for different command lists (#32017)", () => {
const a = [{ command: "alpha", description: "A" }];
const b = [{ command: "alpha", description: "Changed" }];
expect(hashCommandList(a)).not.toBe(hashCommandList(b));
});
it("skips sync when command hash is unchanged (#32017)", async () => {
const deleteMyCommands = vi.fn(async () => undefined);
const setMyCommands = vi.fn(async () => undefined);
const runtimeLog = vi.fn();
// Use a unique accountId so cached hashes from other tests don't interfere.
const accountId = `test-skip-${Date.now()}`;
const commands = [{ command: "skip_test", description: "Skip test command" }];
// First sync — no cached hash, should call setMyCommands.
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeLog,
commandsToRegister: commands,
accountId,
botIdentity: "bot-a",
});
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalledTimes(1);
});
// Second sync with the same commands — hash is cached, should skip.
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeLog,
commandsToRegister: commands,
accountId,
botIdentity: "bot-a",
});
// setMyCommands should NOT have been called a second time.
expect(setMyCommands).toHaveBeenCalledTimes(1);
});
it("does not reuse cached hash across different bot identities", async () => {
const deleteMyCommands = vi.fn(async () => undefined);
const setMyCommands = vi.fn(async () => undefined);
const runtimeLog = vi.fn();
const accountId = `test-bot-identity-${Date.now()}`;
const commands = [{ command: "same", description: "Same" }];
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeLog,
commandsToRegister: commands,
accountId,
botIdentity: "token-bot-a",
});
await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(1));
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeLog,
commandsToRegister: commands,
accountId,
botIdentity: "token-bot-b",
});
await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(2));
});
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()}`;
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeLog,
commandsToRegister: [],
accountId,
botIdentity: "bot-a",
});
await vi.waitFor(() => expect(deleteMyCommands).toHaveBeenCalledTimes(1));
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeLog,
commandsToRegister: [],
accountId,
botIdentity: "bot-a",
});
await vi.waitFor(() => expect(deleteMyCommands).toHaveBeenCalledTimes(2));
});
it("retries with fewer commands on BOT_COMMANDS_TOO_MUCH", async () => {
const deleteMyCommands = vi.fn(async () => undefined);
const setMyCommands = vi
.fn()
.mockRejectedValueOnce(new Error("400: Bad Request: BOT_COMMANDS_TOO_MUCH"))
.mockResolvedValue(undefined);
const runtimeLog = vi.fn();
const runtimeError = vi.fn();
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeLog,
runtimeError,
commandsToRegister: Array.from({ length: 100 }, (_, i) => ({
command: `cmd_${i}`,
description: `Command ${i}`,
})),
accountId: `test-retry-${Date.now()}`,
botIdentity: "bot-a",
});
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalledTimes(2);
});
const firstPayload = setMyCommands.mock.calls[0]?.[0] as Array<unknown>;
const secondPayload = setMyCommands.mock.calls[1]?.[0] as Array<unknown>;
expect(firstPayload).toHaveLength(100);
expect(secondPayload).toHaveLength(80);
expect(runtimeLog).toHaveBeenCalledWith(
"Telegram rejected 100 commands (BOT_COMMANDS_TOO_MUCH); retrying with 80.",
);
expect(runtimeLog).toHaveBeenCalledWith(
"Telegram accepted 80 commands after BOT_COMMANDS_TOO_MUCH (started with 100; omitted 20). Reduce plugin/skill/custom commands to expose more menu entries.",
);
expect(runtimeError).not.toHaveBeenCalled();
});
it.each([
{ label: "description envelope", error: { description: "BOT_COMMANDS_TOO_MUCH" } },
{ label: "message envelope", error: { message: "BOT_COMMANDS_TOO_MUCH" } },
])("retries when Telegram returns a plain-object $label error", async ({ error }) => {
const deleteMyCommands = vi.fn(async () => undefined);
const setMyCommands = vi.fn().mockRejectedValueOnce(error).mockResolvedValue(undefined);
const runtimeLog = vi.fn();
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeLog,
commandsToRegister: Array.from({ length: 10 }, (_, i) => ({
command: `cmd_${i}`,
description: `Command ${i}`,
})),
accountId: `test-envelope-${Date.now()}`,
botIdentity: "bot-a",
});
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalledTimes(2);
});
expect(runtimeLog).toHaveBeenCalledWith(
"Telegram rejected 10 commands (BOT_COMMANDS_TOO_MUCH); retrying with 8.",
);
});
});

View file

@ -0,0 +1,320 @@
import { createHash } from "node:crypto";
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/text-runtime";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import { normalizeTelegramCommandName, TELEGRAM_COMMAND_NAME_PATTERN } from "./command-config.js";
export 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;
export type TelegramMenuCommand = {
command: string;
description: string;
};
type TelegramPluginCommandSpec = {
name: unknown;
description: unknown;
};
function countTelegramCommandText(value: string): number {
return Array.from(value).length;
}
function truncateTelegramCommandText(value: string, maxLength: number): string {
if (maxLength <= 0) {
return "";
}
const chars = Array.from(value);
if (chars.length <= maxLength) {
return value;
}
if (maxLength === 1) {
return chars[0] ?? "";
}
return `${chars.slice(0, maxLength - 1).join("")}…`;
}
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, descriptionCap);
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);
commands.push({ command: normalized, description });
}
return { commands, issues };
}
export function buildCappedTelegramMenuCommands(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 = params.maxCommands ?? TELEGRAM_MAX_COMMANDS;
const maxTotalChars = params.maxTotalChars ?? TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET;
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,
};
}
/** 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);
}
// 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);
}
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.
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;
}
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,
}),
);
}
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,4 @@
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { deliverReplies, emitTelegramMessageSentHooks } from "./bot/delivery.js";
export { createChannelReplyPipeline, deliverReplies, emitTelegramMessageSentHooks };

View file

@ -0,0 +1,108 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { vi } from "vitest";
import type { OpenClawConfig, TelegramAccountConfig } from "../runtime-api.js";
import type { RegisterTelegramNativeCommandsParams } from "./bot-native-commands.js";
export type NativeCommandTestParams = RegisterTelegramNativeCommandsParams;
export function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
export function createNativeCommandTestParams(
params: Partial<NativeCommandTestParams> = {},
): NativeCommandTestParams {
const log = vi.fn();
return {
bot:
params.bot ??
({
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
},
command: vi.fn(),
} as unknown as NativeCommandTestParams["bot"]),
cfg: params.cfg ?? ({} as OpenClawConfig),
runtime:
params.runtime ??
({
log,
error: vi.fn(),
exit: vi.fn(),
} as unknown as RuntimeEnv),
accountId: params.accountId ?? "default",
telegramCfg: params.telegramCfg ?? ({} as TelegramAccountConfig),
allowFrom: params.allowFrom ?? [],
groupAllowFrom: params.groupAllowFrom ?? [],
replyToMode: params.replyToMode ?? "off",
textLimit: params.textLimit ?? 4000,
useAccessGroups: params.useAccessGroups ?? false,
nativeEnabled: params.nativeEnabled ?? true,
nativeSkillsEnabled: params.nativeSkillsEnabled ?? false,
nativeDisabledExplicit: params.nativeDisabledExplicit ?? false,
resolveGroupPolicy:
params.resolveGroupPolicy ??
(() =>
({
allowlistEnabled: false,
allowed: true,
}) as ReturnType<NativeCommandTestParams["resolveGroupPolicy"]>),
resolveTelegramGroupConfig:
params.resolveTelegramGroupConfig ??
((_chatId, _messageThreadId) => ({ groupConfig: undefined, topicConfig: undefined })),
shouldSkipUpdate: params.shouldSkipUpdate ?? (() => false),
telegramDeps: params.telegramDeps,
opts: params.opts ?? { token: "token" },
};
}
export function createTelegramPrivateCommandContext(params?: {
match?: string;
messageId?: number;
date?: number;
chatId?: number;
userId?: number;
username?: string;
}) {
return {
match: params?.match ?? "",
message: {
message_id: params?.messageId ?? 1,
date: params?.date ?? Math.floor(Date.now() / 1000),
chat: { id: params?.chatId ?? 100, type: "private" as const },
from: { id: params?.userId ?? 200, username: params?.username ?? "bob" },
},
};
}
export function createTelegramTopicCommandContext(params?: {
match?: string;
messageId?: number;
date?: number;
chatId?: number;
title?: string;
threadId?: number;
userId?: number;
username?: string;
}) {
return {
match: params?.match ?? "",
message: {
message_id: params?.messageId ?? 2,
date: params?.date ?? Math.floor(Date.now() / 1000),
chat: {
id: params?.chatId ?? -1001234567890,
type: "supergroup" as const,
title: params?.title ?? "OpenClaw",
is_forum: true,
},
message_thread_id: params?.threadId ?? 42,
from: { id: params?.userId ?? 200, username: params?.username ?? "bob" },
},
};
}

View file

@ -0,0 +1,196 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ChannelGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
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: {
channels: {
telegram: {
groupPolicy: "disabled",
},
},
commands: {
allowFrom: {
telegram: ["12345"],
},
},
} as OpenClawConfig,
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

@ -0,0 +1,132 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { expect, vi, type Mock } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import type { TelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js";
import {
createNativeCommandTestParams as createBaseNativeCommandTestParams,
createTelegramPrivateCommandContext,
type NativeCommandTestParams as RegisterTelegramNativeCommandsParams,
} from "./bot-native-commands.fixture-test-support.js";
type RegisteredCommand = {
command: string;
description: string;
};
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
type CreateCommandBotResult = {
bot: RegisterTelegramNativeCommandsParams["bot"];
commandHandlers: Map<string, (ctx: unknown) => Promise<void>>;
sendMessage: ReturnType<typeof vi.fn>;
deleteMessage: ReturnType<typeof vi.fn>;
setMyCommands: ReturnType<typeof vi.fn>;
};
type CreateCommandBotParams = {
api?: Record<string, unknown>;
};
const skillCommandMocks = vi.hoisted(() => ({
listSkillCommandsForAgents: vi.fn<TelegramNativeCommandDeps["listSkillCommandsForAgents"]>(
() => [],
),
}));
const deliveryMocks = vi.hoisted(() => ({
deliverReplies: vi.fn(async () => ({ delivered: true })),
editMessageTelegram: vi.fn(async () => ({ ok: true as const, messageId: "999", chatId: "100" })),
emitTelegramMessageSentHooks: vi.fn(),
}));
export const listSkillCommandsForAgents = skillCommandMocks.listSkillCommandsForAgents;
export const deliverReplies = deliveryMocks.deliverReplies;
export const editMessageTelegram = deliveryMocks.editMessageTelegram;
export const emitTelegramMessageSentHooks: UnknownMock = deliveryMocks.emitTelegramMessageSentHooks;
vi.mock("./bot/delivery.js", () => ({
deliverReplies,
emitTelegramMessageSentHooks,
}));
vi.mock("./bot/delivery.replies.js", () => ({
deliverReplies,
}));
export async function waitForRegisteredCommands(
setMyCommands: ReturnType<typeof vi.fn>,
): Promise<RegisteredCommand[]> {
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalled();
});
return setMyCommands.mock.calls[0]?.[0] as RegisteredCommand[];
}
export function resetNativeCommandMenuMocks() {
listSkillCommandsForAgents.mockClear();
listSkillCommandsForAgents.mockReturnValue([]);
deliverReplies.mockClear();
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockClear();
editMessageTelegram.mockResolvedValue({ ok: true as const, messageId: "999", chatId: "100" });
emitTelegramMessageSentHooks.mockClear();
}
export function createCommandBot(params: CreateCommandBotParams = {}): CreateCommandBotResult {
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
const sendMessage = vi.fn().mockResolvedValue({ message_id: 999 });
const deleteMessage = vi.fn().mockResolvedValue(true);
const setMyCommands = vi.fn().mockResolvedValue(undefined);
const bot = {
api: {
setMyCommands,
sendMessage,
deleteMessage,
...params.api,
},
command: vi.fn((name: string, cb: (ctx: unknown) => Promise<void>) => {
commandHandlers.set(name, cb);
}),
} as unknown as RegisterTelegramNativeCommandsParams["bot"];
return { bot, commandHandlers, sendMessage, deleteMessage, setMyCommands };
}
export function createNativeCommandTestParams(
cfg: OpenClawConfig,
params: Partial<RegisterTelegramNativeCommandsParams> = {},
): RegisterTelegramNativeCommandsParams {
const dispatchResult: Awaited<
ReturnType<TelegramNativeCommandDeps["dispatchReplyWithBufferedBlockDispatcher"]>
> = {
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
};
const telegramDeps: TelegramNativeCommandDeps = {
loadConfig: vi.fn(() => cfg) as TelegramNativeCommandDeps["loadConfig"],
readChannelAllowFromStore: vi.fn(
async () => [],
) as TelegramNativeCommandDeps["readChannelAllowFromStore"],
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
async () => dispatchResult,
) as TelegramNativeCommandDeps["dispatchReplyWithBufferedBlockDispatcher"],
listSkillCommandsForAgents,
syncTelegramMenuCommands: vi.fn(({ bot, commandsToRegister }) => {
if (commandsToRegister.length === 0) {
return undefined;
}
return bot.api.setMyCommands(commandsToRegister);
}) as TelegramNativeCommandDeps["syncTelegramMenuCommands"],
editMessageTelegram,
};
return createBaseNativeCommandTestParams({
cfg,
runtime: params.runtime ?? ({} as RuntimeEnv),
nativeSkillsEnabled: true,
telegramDeps,
...params,
});
}
export function createPrivateCommandContext(
params?: Parameters<typeof createTelegramPrivateCommandContext>[0],
) {
return createTelegramPrivateCommandContext(params);
}

View file

@ -0,0 +1,271 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
let registerTelegramNativeCommands: typeof import("./bot-native-commands.js").registerTelegramNativeCommands;
let clearPluginCommands: typeof import("../../../src/plugins/commands.js").clearPluginCommands;
let registerPluginCommand: typeof import("../../../src/plugins/commands.js").registerPluginCommand;
let setActivePluginRegistry: typeof import("../../../src/plugins/runtime.js").setActivePluginRegistry;
let createCommandBot: typeof import("./bot-native-commands.menu-test-support.js").createCommandBot;
let createNativeCommandTestParams: typeof import("./bot-native-commands.menu-test-support.js").createNativeCommandTestParams;
let createPrivateCommandContext: typeof import("./bot-native-commands.menu-test-support.js").createPrivateCommandContext;
let deliverReplies: typeof import("./bot-native-commands.menu-test-support.js").deliverReplies;
let editMessageTelegram: typeof import("./bot-native-commands.menu-test-support.js").editMessageTelegram;
let resetNativeCommandMenuMocks: typeof import("./bot-native-commands.menu-test-support.js").resetNativeCommandMenuMocks;
let waitForRegisteredCommands: typeof import("./bot-native-commands.menu-test-support.js").waitForRegisteredCommands;
function createTelegramPluginRegistry() {
return {
plugins: [],
tools: [],
hooks: [],
typedHooks: [],
channels: [
{
pluginId: "telegram",
source: "test",
plugin: {
id: "telegram",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "test stub.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
},
commands: {
nativeCommandsAutoEnabled: true,
},
},
},
],
channelSetups: [
{
pluginId: "telegram",
source: "test",
enabled: true,
plugin: {
id: "telegram",
},
},
],
providers: [],
speechProviders: [],
mediaUnderstandingProviders: [],
imageGenerationProviders: [],
videoGenerationProviders: [],
webFetchProviders: [],
webSearchProviders: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],
conversationBindingResolvedHandlers: [],
diagnostics: [],
};
}
function registerPairPluginCommand(params?: {
nativeNames?: { telegram?: string; discord?: string };
nativeProgressMessages?: { telegram?: string; default?: string };
}) {
expect(
registerPluginCommand("demo-plugin", {
name: "pair",
...(params?.nativeNames ? { nativeNames: params.nativeNames } : {}),
...(params?.nativeProgressMessages
? { nativeProgressMessages: params.nativeProgressMessages }
: {}),
description: "Pair device",
acceptsArgs: true,
requireAuth: false,
handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }),
}),
).toEqual({ ok: true });
}
async function registerPairMenu(params: {
bot: ReturnType<typeof createCommandBot>["bot"];
setMyCommands: ReturnType<typeof createCommandBot>["setMyCommands"];
nativeNames?: { telegram?: string; discord?: string };
nativeProgressMessages?: { telegram?: string; default?: string };
}) {
registerPairPluginCommand({
...(params.nativeNames ? { nativeNames: params.nativeNames } : {}),
...(params.nativeProgressMessages
? { nativeProgressMessages: params.nativeProgressMessages }
: {}),
});
registerTelegramNativeCommands({
...createNativeCommandTestParams({}),
bot: params.bot,
});
return await waitForRegisteredCommands(params.setMyCommands);
}
describe("registerTelegramNativeCommands real plugin registry", () => {
beforeAll(async () => {
({ clearPluginCommands, registerPluginCommand } =
await import("../../../src/plugins/commands.js"));
({ setActivePluginRegistry } = await import("../../../src/plugins/runtime.js"));
({ registerTelegramNativeCommands } = await import("./bot-native-commands.js"));
({
createCommandBot,
createNativeCommandTestParams,
createPrivateCommandContext,
deliverReplies,
editMessageTelegram,
resetNativeCommandMenuMocks,
waitForRegisteredCommands,
} = await import("./bot-native-commands.menu-test-support.js"));
});
beforeEach(() => {
setActivePluginRegistry(createTelegramPluginRegistry() as never);
clearPluginCommands();
resetNativeCommandMenuMocks();
});
afterEach(() => {
clearPluginCommands();
});
it("registers and executes plugin commands through the real plugin registry", async () => {
const { bot, commandHandlers, sendMessage, setMyCommands } = createCommandBot();
const registeredCommands = await registerPairMenu({ bot, setMyCommands });
expect(registeredCommands).toEqual(
expect.arrayContaining([{ command: "pair", description: "Pair device" }]),
);
const handler = commandHandlers.get("pair");
expect(handler).toBeTruthy();
await handler?.(createPrivateCommandContext({ match: "now" }));
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: "paired:now" })],
}),
);
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
});
it("uses plugin command metadata to send and edit a Telegram progress placeholder", async () => {
const { bot, commandHandlers, setMyCommands, sendMessage } = createCommandBot();
await registerPairMenu({
bot,
setMyCommands,
nativeProgressMessages: {
telegram:
"Running pair now...\n\nI'll edit this message with the final result when it's ready.",
},
});
const handler = commandHandlers.get("pair");
expect(handler).toBeTruthy();
await handler?.(createPrivateCommandContext({ match: "now" }));
expect(sendMessage).toHaveBeenCalledWith(
100,
expect.stringContaining("Running pair now"),
undefined,
);
expect(editMessageTelegram).toHaveBeenCalledWith(
100,
999,
"paired:now",
expect.objectContaining({ accountId: "default" }),
);
expect(deliverReplies).not.toHaveBeenCalled();
});
it("round-trips Telegram native aliases through the real plugin registry", async () => {
const { bot, commandHandlers, sendMessage, setMyCommands } = createCommandBot();
const registeredCommands = await registerPairMenu({
bot,
setMyCommands,
nativeNames: {
telegram: "pair_device",
discord: "pairdiscord",
},
});
expect(registeredCommands).toEqual(
expect.arrayContaining([{ command: "pair_device", description: "Pair device" }]),
);
const handler = commandHandlers.get("pair_device");
expect(handler).toBeTruthy();
await handler?.(createPrivateCommandContext({ match: "now", messageId: 2 }));
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: "paired:now" })],
}),
);
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
});
it("keeps real plugin command handlers available when native menu registration is disabled", () => {
const { bot, commandHandlers, setMyCommands } = createCommandBot();
registerPairPluginCommand();
registerTelegramNativeCommands({
...createNativeCommandTestParams({}, { accountId: "default" }),
bot,
nativeEnabled: false,
});
expect(setMyCommands).not.toHaveBeenCalled();
expect(commandHandlers.has("pair")).toBe(true);
});
it("allows requireAuth:false plugin commands for unauthorized senders through the real registry", async () => {
const { bot, commandHandlers, sendMessage, setMyCommands } = createCommandBot();
registerPairPluginCommand();
registerTelegramNativeCommands({
...createNativeCommandTestParams({
commands: { allowFrom: { telegram: ["999"] } } as OpenClawConfig["commands"],
}),
bot,
allowFrom: ["999"],
nativeEnabled: false,
});
expect(setMyCommands).not.toHaveBeenCalled();
const handler = commandHandlers.get("pair");
expect(handler).toBeTruthy();
await handler?.(
createPrivateCommandContext({
match: "now",
messageId: 10,
date: 123456,
userId: 111,
username: "nope",
}),
);
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: "paired:now" })],
}),
);
expect(sendMessage).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,15 @@
export {
ensureConfiguredBindingRouteReady,
recordInboundSessionMetaSafe,
} from "openclaw/plugin-sdk/conversation-runtime";
export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
export {
executePluginCommand,
getPluginCommandSpecs,
matchPluginCommand,
} from "openclaw/plugin-sdk/plugin-runtime";
export {
finalizeInboundContext,
resolveChunkMode,
} from "openclaw/plugin-sdk/reply-dispatch-runtime";
export { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";

View file

@ -0,0 +1,731 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { TelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js";
import {
createDeferred,
createNativeCommandTestParams,
createTelegramPrivateCommandContext,
createTelegramTopicCommandContext,
type NativeCommandTestParams,
} from "./bot-native-commands.fixture-test-support.js";
import { type RegisterTelegramHandlerParams } from "./bot-native-commands.js";
// All mocks scoped to this file only — does not affect bot-native-commands.test.ts
type ResolveConfiguredBindingRouteFn =
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
type EnsureConfiguredBindingRouteReadyFn =
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
type DispatchReplyWithBufferedBlockDispatcherFn =
typeof import("../../../src/auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
type DispatchReplyWithBufferedBlockDispatcherParams =
Parameters<DispatchReplyWithBufferedBlockDispatcherFn>[0];
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
ReturnType<DispatchReplyWithBufferedBlockDispatcherFn>
>;
type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies;
type DeliverRepliesParams = Parameters<DeliverRepliesFn>[0];
const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = {
queuedFinal: false,
counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"],
};
const persistentBindingMocks = vi.hoisted(() => ({
resolveConfiguredBindingRoute: vi.fn<ResolveConfiguredBindingRouteFn>(({ route }) => ({
bindingResolution: null,
route,
})),
ensureConfiguredBindingRouteReady: vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
ok: true,
})),
}));
const sessionMocks = vi.hoisted(() => ({
recordSessionMetaFromInbound: vi.fn(),
resolveStorePath: vi.fn(),
}));
const replyMocks = vi.hoisted(() => ({
dispatchReplyWithBufferedBlockDispatcher: vi.fn<DispatchReplyWithBufferedBlockDispatcherFn>(
async () => dispatchReplyResult,
),
}));
const deliveryMocks = vi.hoisted(() => ({
deliverReplies: vi.fn<DeliverRepliesFn>(async () => ({ delivered: true })),
}));
const sessionBindingMocks = vi.hoisted(() => ({
resolveByConversation: vi.fn<
(ref: unknown) => { bindingId: string; targetSessionKey: string } | null
>(() => null),
touch: vi.fn(),
}));
const conversationStoreMocks = vi.hoisted(() => ({
readChannelAllowFromStore: vi.fn(async () => []),
upsertChannelPairingRequest: vi.fn(async () => ({ code: "PAIRCODE", created: true })),
}));
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
"openclaw/plugin-sdk/conversation-runtime",
);
return {
...actual,
resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredBindingRoute,
ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady,
recordInboundSessionMetaSafe: vi.fn(
async (params: {
cfg: OpenClawConfig;
agentId: string;
sessionKey: string;
ctx: unknown;
onError?: (error: unknown) => void;
}) => {
const storePath = sessionMocks.resolveStorePath(params.cfg.session?.store, {
agentId: params.agentId,
});
try {
await sessionMocks.recordSessionMetaFromInbound({
storePath,
sessionKey: params.sessionKey,
ctx: params.ctx,
});
} catch (error) {
params.onError?.(error);
}
},
),
readChannelAllowFromStore: conversationStoreMocks.readChannelAllowFromStore,
upsertChannelPairingRequest: conversationStoreMocks.upsertChannelPairingRequest,
getSessionBindingService: () => ({
bind: vi.fn(),
getCapabilities: vi.fn(),
listBySession: vi.fn(),
resolveByConversation: (ref: unknown) => sessionBindingMocks.resolveByConversation(ref),
touch: (bindingId: string, at?: number) => sessionBindingMocks.touch(bindingId, at),
unbind: vi.fn(),
}),
};
});
vi.mock("./bot-native-commands.runtime.js", async () => {
const actual = await vi.importActual<typeof import("./bot-native-commands.runtime.js")>(
"./bot-native-commands.runtime.js",
);
return {
...actual,
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher,
};
});
vi.mock("../../../src/pairing/pairing-store.js", () => ({
readChannelAllowFromStore: vi.fn(async () => []),
}));
vi.mock("../../../src/plugins/commands.js", () => ({
getPluginCommandSpecs: vi.fn(() => []),
matchPluginCommand: vi.fn(() => null),
executePluginCommand: vi.fn(async () => ({ text: "ok" })),
}));
vi.mock("./bot/delivery.js", () => ({
deliverReplies: deliveryMocks.deliverReplies,
}));
vi.mock("./bot/delivery.replies.js", () => ({
deliverReplies: deliveryMocks.deliverReplies,
}));
let registerTelegramNativeCommands: typeof import("./bot-native-commands.js").registerTelegramNativeCommands;
type TelegramCommandHandler = (ctx: unknown) => Promise<void>;
function registerAndResolveStatusHandler(params: {
cfg: OpenClawConfig;
allowFrom?: string[];
groupAllowFrom?: string[];
telegramCfg?: NativeCommandTestParams["telegramCfg"];
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
}): {
handler: TelegramCommandHandler;
sendMessage: ReturnType<typeof vi.fn>;
} {
const { cfg, allowFrom, groupAllowFrom, telegramCfg, resolveTelegramGroupConfig } = params;
return registerAndResolveCommandHandlerBase({
commandName: "status",
cfg,
allowFrom: allowFrom ?? ["*"],
groupAllowFrom: groupAllowFrom ?? [],
useAccessGroups: true,
telegramCfg,
resolveTelegramGroupConfig,
});
}
function registerAndResolveCommandHandlerBase(params: {
commandName: string;
cfg: OpenClawConfig;
allowFrom: string[];
groupAllowFrom: string[];
useAccessGroups: boolean;
telegramCfg?: NativeCommandTestParams["telegramCfg"];
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
}): {
handler: TelegramCommandHandler;
sendMessage: ReturnType<typeof vi.fn>;
} {
const {
commandName,
cfg,
allowFrom,
groupAllowFrom,
useAccessGroups,
telegramCfg,
resolveTelegramGroupConfig,
} = params;
const commandHandlers = new Map<string, TelegramCommandHandler>();
const sendMessage = vi.fn().mockResolvedValue(undefined);
const telegramDeps: TelegramNativeCommandDeps = {
loadConfig: vi.fn(() => cfg),
readChannelAllowFromStore: vi.fn(async () => []),
dispatchReplyWithBufferedBlockDispatcher:
replyMocks.dispatchReplyWithBufferedBlockDispatcher as TelegramNativeCommandDeps["dispatchReplyWithBufferedBlockDispatcher"],
getPluginCommandSpecs: vi.fn(() => []),
listSkillCommandsForAgents: vi.fn(() => []),
syncTelegramMenuCommands: vi.fn(),
};
registerTelegramNativeCommands({
...createNativeCommandTestParams({
bot: {
api: {
setMyCommands: vi.fn().mockResolvedValue(undefined),
sendMessage,
},
command: vi.fn((name: string, cb: TelegramCommandHandler) => {
commandHandlers.set(name, cb);
}),
} as unknown as NativeCommandTestParams["bot"],
cfg,
allowFrom,
groupAllowFrom,
useAccessGroups,
telegramCfg,
resolveTelegramGroupConfig,
telegramDeps,
}),
});
const handler = commandHandlers.get(commandName);
expect(handler).toBeTruthy();
return { handler: handler as TelegramCommandHandler, sendMessage };
}
function registerAndResolveCommandHandler(params: {
commandName: string;
cfg: OpenClawConfig;
allowFrom?: string[];
groupAllowFrom?: string[];
useAccessGroups?: boolean;
telegramCfg?: NativeCommandTestParams["telegramCfg"];
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
}): {
handler: TelegramCommandHandler;
sendMessage: ReturnType<typeof vi.fn>;
} {
const {
commandName,
cfg,
allowFrom,
groupAllowFrom,
useAccessGroups,
telegramCfg,
resolveTelegramGroupConfig,
} = params;
return registerAndResolveCommandHandlerBase({
commandName,
cfg,
allowFrom: allowFrom ?? [],
groupAllowFrom: groupAllowFrom ?? [],
useAccessGroups: useAccessGroups ?? true,
telegramCfg,
resolveTelegramGroupConfig,
});
}
function createConfiguredAcpTopicBinding(boundSessionKey: string) {
return {
spec: {
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
agentId: "codex",
mode: "persistent",
},
record: {
bindingId: "config:acp:telegram:default:-1001234567890:topic:42",
targetSessionKey: boundSessionKey,
targetKind: "session",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
status: "active",
boundAt: 0,
},
} as const;
}
function createConfiguredBindingRoute(
route: ResolvedAgentRoute,
binding: ReturnType<typeof createConfiguredAcpTopicBinding> | null,
) {
return {
bindingResolution: binding
? {
conversation: binding.record.conversation,
compiledBinding: {
channel: "telegram" as const,
binding: {
type: "acp" as const,
agentId: binding.spec.agentId,
match: {
channel: "telegram",
accountId: binding.spec.accountId,
peer: {
kind: "group" as const,
id: binding.spec.conversationId,
},
},
acp: {
mode: binding.spec.mode,
},
},
bindingConversationId: binding.spec.conversationId,
target: {
conversationId: binding.spec.conversationId,
...(binding.spec.parentConversationId
? { parentConversationId: binding.spec.parentConversationId }
: {}),
},
agentId: binding.spec.agentId,
provider: {
compileConfiguredBinding: () => ({
conversationId: binding.spec.conversationId,
...(binding.spec.parentConversationId
? { parentConversationId: binding.spec.parentConversationId }
: {}),
}),
matchInboundConversation: () => ({
conversationId: binding.spec.conversationId,
...(binding.spec.parentConversationId
? { parentConversationId: binding.spec.parentConversationId }
: {}),
}),
},
targetFactory: {
driverId: "acp" as const,
materialize: () => ({
record: binding.record,
statefulTarget: {
kind: "stateful" as const,
driverId: "acp" as const,
sessionKey: binding.record.targetSessionKey,
agentId: binding.spec.agentId,
},
}),
},
},
match: {
conversationId: binding.spec.conversationId,
...(binding.spec.parentConversationId
? { parentConversationId: binding.spec.parentConversationId }
: {}),
},
record: binding.record,
statefulTarget: {
kind: "stateful" as const,
driverId: "acp" as const,
sessionKey: binding.record.targetSessionKey,
agentId: binding.spec.agentId,
},
}
: null,
...(binding ? { boundSessionKey: binding.record.targetSessionKey } : {}),
route,
};
}
function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType<typeof vi.fn>) {
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(persistentBindingMocks.resolveConfiguredBindingRoute).not.toHaveBeenCalled();
expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith(
-1001234567890,
"You are not authorized to use this command.",
expect.objectContaining({ message_thread_id: 42 }),
);
}
describe("registerTelegramNativeCommands — session metadata", () => {
beforeAll(async () => {
({ registerTelegramNativeCommands } = await import("./bot-native-commands.js"));
});
beforeEach(() => {
persistentBindingMocks.resolveConfiguredBindingRoute.mockClear();
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
createConfiguredBindingRoute(route, null),
);
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear();
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined);
sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json");
replyMocks.dispatchReplyWithBufferedBlockDispatcher
.mockClear()
.mockResolvedValue(dispatchReplyResult);
sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null);
sessionBindingMocks.touch.mockReset();
deliveryMocks.deliverReplies.mockClear().mockResolvedValue({ delivered: true });
});
it("calls recordSessionMetaFromInbound after a native slash command", async () => {
const cfg: OpenClawConfig = {};
const { handler } = registerAndResolveStatusHandler({ cfg });
await handler(createTelegramPrivateCommandContext());
expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1);
const call = (
sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array<
[{ sessionKey?: string; ctx?: { OriginatingChannel?: string; Provider?: string } }]
>
)[0]?.[0];
expect(call?.ctx?.OriginatingChannel).toBe("telegram");
expect(call?.ctx?.Provider).toBe("telegram");
expect(call?.sessionKey).toBe("agent:main:telegram:slash:200");
});
it("awaits session metadata persistence before dispatch", async () => {
const deferred = createDeferred<void>();
sessionMocks.recordSessionMetaFromInbound.mockReturnValue(deferred.promise);
const cfg: OpenClawConfig = {};
const { handler } = registerAndResolveStatusHandler({ cfg });
const runPromise = handler(createTelegramPrivateCommandContext());
await vi.waitFor(() => {
expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1);
});
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
deferred.resolve();
await runPromise;
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
});
it("does not inject approval buttons for native command replies once the monitor owns approvals", async () => {
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => {
await dispatcherOptions.deliver(
{
text: "Mode: foreground\nRun: /approve 7f423fdc allow-once (or allow-always / deny).",
},
{ kind: "final" },
);
return dispatchReplyResult;
},
);
const { handler } = registerAndResolveStatusHandler({
cfg: {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["12345"],
target: "dm",
},
},
},
},
});
await handler(createTelegramPrivateCommandContext());
const deliveredCall = deliveryMocks.deliverReplies.mock.calls[0]?.[0] as
| DeliverRepliesParams
| undefined;
const deliveredPayload = deliveredCall?.replies?.[0];
expect(deliveredPayload).toBeTruthy();
expect(deliveredPayload?.["text"]).toContain("/approve 7f423fdc allow-once");
expect(deliveredPayload?.["channelData"]).toBeUndefined();
});
it("suppresses local structured exec approval replies for native commands", async () => {
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => {
await dispatcherOptions.deliver(
{
text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```",
channelData: {
execApproval: {
approvalId: "7f423fdc-1111-2222-3333-444444444444",
approvalSlug: "7f423fdc",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
},
{ kind: "tool" },
);
return dispatchReplyResult;
},
);
const { handler } = registerAndResolveStatusHandler({
cfg: {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["12345"],
target: "dm",
},
},
},
},
});
await handler(createTelegramPrivateCommandContext());
expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled();
});
it("sends native command error replies silently when silentErrorReplies is enabled", async () => {
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => {
await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" });
return dispatchReplyResult;
},
);
const { handler } = registerAndResolveStatusHandler({
cfg: {
channels: {
telegram: {
silentErrorReplies: true,
},
},
},
telegramCfg: { silentErrorReplies: true },
});
await handler(createTelegramPrivateCommandContext());
const deliveredCall = deliveryMocks.deliverReplies.mock.calls[0]?.[0] as
| DeliverRepliesParams
| undefined;
expect(deliveredCall).toEqual(
expect.objectContaining({
silent: true,
replies: [expect.objectContaining({ isError: true })],
}),
);
});
it("routes Telegram native commands through configured ACP topic bindings", async () => {
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
createConfiguredBindingRoute(
{
...route,
sessionKey: boundSessionKey,
agentId: "codex",
matchedBy: "binding.channel",
},
createConfiguredAcpTopicBinding(boundSessionKey),
),
);
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
const { handler } = registerAndResolveStatusHandler({
cfg: {},
allowFrom: ["200"],
groupAllowFrom: ["200"],
});
await handler(createTelegramTopicCommandContext());
expect(persistentBindingMocks.resolveConfiguredBindingRoute).toHaveBeenCalledTimes(1);
expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1);
const dispatchCall = (
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
[{ ctx?: { CommandTargetSessionKey?: string } }]
>
)[0]?.[0];
expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
const sessionMetaCall = (
sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array<
[{ sessionKey?: string }]
>
)[0]?.[0];
expect(sessionMetaCall?.sessionKey).toBe("agent:codex:telegram:slash:200");
});
it("routes Telegram native commands through topic-specific agent sessions", async () => {
const { handler } = registerAndResolveStatusHandler({
cfg: {},
allowFrom: ["200"],
groupAllowFrom: ["200"],
resolveTelegramGroupConfig: () => ({
groupConfig: { requireMention: false },
topicConfig: { agentId: "zu" },
}),
});
await handler(createTelegramTopicCommandContext());
const dispatchCall = (
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
[{ ctx?: { CommandTargetSessionKey?: string } }]
>
)[0]?.[0];
expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe(
"agent:zu:telegram:group:-1001234567890:topic:42",
);
});
it("routes Telegram native commands through bound topic sessions", async () => {
sessionBindingMocks.resolveByConversation.mockReturnValue({
bindingId: "default:-1001234567890:topic:42",
targetSessionKey: "agent:codex-acp:session-1",
});
const { handler } = registerAndResolveStatusHandler({
cfg: {},
allowFrom: ["200"],
groupAllowFrom: ["200"],
});
await handler(createTelegramTopicCommandContext());
expect(sessionBindingMocks.resolveByConversation).toHaveBeenCalledWith({
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
});
const dispatchCall = (
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
[{ ctx?: { CommandTargetSessionKey?: string } }]
>
)[0]?.[0];
expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe("agent:codex-acp:session-1");
expect(sessionBindingMocks.touch).toHaveBeenCalledWith(
"default:-1001234567890:topic:42",
undefined,
);
});
it.each(["new", "reset"] as const)(
"preserves the topic-qualified origin target for native /%s in forum topics",
async (commandName) => {
const { handler } = registerAndResolveCommandHandler({
commandName,
cfg: {},
allowFrom: ["200"],
groupAllowFrom: ["200"],
useAccessGroups: true,
});
await handler(createTelegramTopicCommandContext());
const dispatchCall = (
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
[
{
ctx?: {
CommandTargetSessionKey?: string;
MessageThreadId?: number;
OriginatingTo?: string;
};
},
]
>
)[0]?.[0];
expect(dispatchCall?.ctx).toEqual(
expect.objectContaining({
CommandTargetSessionKey: "agent:main:telegram:group:-1001234567890:topic:42",
MessageThreadId: 42,
OriginatingTo: "telegram:-1001234567890:topic:42",
}),
);
},
);
it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => {
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
createConfiguredBindingRoute(
{
...route,
sessionKey: boundSessionKey,
agentId: "codex",
matchedBy: "binding.channel",
},
createConfiguredAcpTopicBinding(boundSessionKey),
),
);
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({
ok: false,
error: "gateway unavailable",
});
const { handler, sendMessage } = registerAndResolveStatusHandler({
cfg: {},
allowFrom: ["200"],
groupAllowFrom: ["200"],
});
await handler(createTelegramTopicCommandContext());
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(sendMessage).toHaveBeenCalledWith(
-1001234567890,
"Configured ACP binding is unavailable right now. Please try again.",
expect.objectContaining({ message_thread_id: 42 }),
);
});
it("keeps /new blocked in ACP-bound Telegram topics when sender is unauthorized", async () => {
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
createConfiguredBindingRoute(
{
...route,
sessionKey: boundSessionKey,
agentId: "codex",
matchedBy: "binding.channel",
},
createConfiguredAcpTopicBinding(boundSessionKey),
),
);
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "new",
cfg: {},
allowFrom: [],
groupAllowFrom: [],
useAccessGroups: true,
});
await handler(createTelegramTopicCommandContext());
expectUnauthorizedNewCommandBlocked(sendMessage);
});
it("keeps /new blocked for unbound Telegram topics when sender is unauthorized", async () => {
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
createConfiguredBindingRoute(route, null),
);
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "new",
cfg: {},
allowFrom: [],
groupAllowFrom: [],
useAccessGroups: true,
});
await handler(createTelegramTopicCommandContext());
expectUnauthorizedNewCommandBlocked(sendMessage);
});
});

View file

@ -0,0 +1,88 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
import {
createNativeCommandTestParams,
listSkillCommandsForAgents,
resetNativeCommandMenuMocks,
waitForRegisteredCommands,
} from "./bot-native-commands.menu-test-support.js";
import { resetPluginCommandMocks } from "./test-support/plugin-command.js";
import { writeSkill } from "./test-support/write-skill.js";
const tempDirs: string[] = [];
async function makeWorkspace(prefix: string) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
describe("registerTelegramNativeCommands skill allowlist integration", () => {
afterEach(async () => {
resetNativeCommandMenuMocks();
resetPluginCommandMocks();
await Promise.all(
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
it("registers only allowlisted skills for the bound agent menu", async () => {
const workspaceDir = await makeWorkspace("openclaw-telegram-skills-");
await writeSkill({
dir: path.join(workspaceDir, "skills", "alpha-skill"),
name: "alpha-skill",
description: "Alpha skill",
});
await writeSkill({
dir: path.join(workspaceDir, "skills", "beta-skill"),
name: "beta-skill",
description: "Beta skill",
});
const setMyCommands = vi.fn().mockResolvedValue(undefined);
const cfg: OpenClawConfig = {
agents: {
list: [
{ id: "alpha", workspace: workspaceDir, skills: ["alpha-skill"] },
{ id: "beta", workspace: workspaceDir, skills: ["beta-skill"] },
],
},
bindings: [
{
agentId: "alpha",
match: { channel: "telegram", accountId: "bot-a" },
},
],
};
const actualSkillCommands = await import("../../../src/auto-reply/skill-commands.js");
listSkillCommandsForAgents.mockImplementation(
({ cfg, agentIds }: { cfg: OpenClawConfig; agentIds?: string[] }) =>
actualSkillCommands.listSkillCommandsForAgents({ cfg, agentIds }),
);
registerTelegramNativeCommands({
...createNativeCommandTestParams(cfg, {
bot: {
api: {
setMyCommands,
sendMessage: vi.fn().mockResolvedValue(undefined),
},
command: vi.fn(),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
runtime: { log: vi.fn() } as unknown as Parameters<
typeof registerTelegramNativeCommands
>[0]["runtime"],
accountId: "bot-a",
}),
});
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
expect(registeredCommands.some((entry) => entry.command === "alpha_skill")).toBe(true);
expect(registeredCommands.some((entry) => entry.command === "beta_skill")).toBe(false);
});
});

View file

@ -0,0 +1,212 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ChannelGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { MockFn } from "openclaw/plugin-sdk/testing";
import { vi } from "vitest";
import { createNativeCommandTestParams } from "./bot-native-commands.fixture-test-support.js";
import type { RegisterTelegramNativeCommandsParams } from "./bot-native-commands.js";
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
type GetPluginCommandSpecsFn =
typeof import("./bot-native-commands.runtime.js").getPluginCommandSpecs;
type MatchPluginCommandFn = typeof import("./bot-native-commands.runtime.js").matchPluginCommand;
type ExecutePluginCommandFn =
typeof import("./bot-native-commands.runtime.js").executePluginCommand;
type DispatchReplyWithBufferedBlockDispatcherFn =
typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithBufferedBlockDispatcher;
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
ReturnType<DispatchReplyWithBufferedBlockDispatcherFn>
>;
type RecordInboundSessionMetaSafeFn =
typeof import("./bot-native-commands.runtime.js").recordInboundSessionMetaSafe;
type ResolveChunkModeFn = typeof import("./bot-native-commands.runtime.js").resolveChunkMode;
type EnsureConfiguredBindingRouteReadyFn =
typeof import("./bot-native-commands.runtime.js").ensureConfiguredBindingRouteReady;
type GetAgentScopedMediaLocalRootsFn =
typeof import("./bot-native-commands.runtime.js").getAgentScopedMediaLocalRoots;
type CreateChannelReplyPipelineFn =
typeof import("./bot-native-commands.delivery.runtime.js").createChannelReplyPipeline;
type AnyMock = MockFn<(...args: unknown[]) => unknown>;
type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise<unknown>>;
type NativeCommandHarness = {
handlers: Record<string, (ctx: unknown) => Promise<void>>;
sendMessage: AnyAsyncMock;
setMyCommands: AnyAsyncMock;
log: AnyMock;
bot: RegisterTelegramNativeCommandsParams["bot"];
};
const pluginCommandMocks = vi.hoisted(() => ({
getPluginCommandSpecs: vi.fn<GetPluginCommandSpecsFn>(() => []),
matchPluginCommand: vi.fn<MatchPluginCommandFn>(() => null),
executePluginCommand: vi.fn<ExecutePluginCommandFn>(async () => ({ text: "ok" })),
}));
export const getPluginCommandSpecs = pluginCommandMocks.getPluginCommandSpecs;
export const matchPluginCommand = pluginCommandMocks.matchPluginCommand;
export const executePluginCommand = pluginCommandMocks.executePluginCommand;
vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({
getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs,
matchPluginCommand: pluginCommandMocks.matchPluginCommand,
executePluginCommand: pluginCommandMocks.executePluginCommand,
}));
const replyPipelineMocks = vi.hoisted(() => {
const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = {
queuedFinal: false,
counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"],
};
return {
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
(async () => dispatchReplyResult) as DispatchReplyWithBufferedBlockDispatcherFn,
),
createChannelReplyPipeline: vi.fn((() => ({
onModelSelected: () => {},
responsePrefixContextProvider: () => undefined,
})) as unknown as CreateChannelReplyPipelineFn),
recordInboundSessionMetaSafe: vi.fn<RecordInboundSessionMetaSafeFn>(async () => undefined),
resolveChunkMode: vi.fn((() => "length") as unknown as ResolveChunkModeFn),
ensureConfiguredBindingRouteReady: vi.fn((async () => ({
ok: true,
})) as unknown as EnsureConfiguredBindingRouteReadyFn),
getAgentScopedMediaLocalRoots: vi.fn<GetAgentScopedMediaLocalRootsFn>(() => []),
};
});
export const dispatchReplyWithBufferedBlockDispatcher =
replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher;
const deliveryMocks = vi.hoisted(() => ({
deliverReplies: vi.fn(async () => {}),
}));
export const deliverReplies = deliveryMocks.deliverReplies;
vi.mock("./bot-native-commands.runtime.js", () => ({
getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs,
matchPluginCommand: pluginCommandMocks.matchPluginCommand,
executePluginCommand: pluginCommandMocks.executePluginCommand,
finalizeInboundContext: replyPipelineMocks.finalizeInboundContext,
recordInboundSessionMetaSafe: replyPipelineMocks.recordInboundSessionMetaSafe,
resolveChunkMode: replyPipelineMocks.resolveChunkMode,
ensureConfiguredBindingRouteReady: replyPipelineMocks.ensureConfiguredBindingRouteReady,
getAgentScopedMediaLocalRoots: replyPipelineMocks.getAgentScopedMediaLocalRoots,
}));
vi.mock("./bot-native-commands.delivery.runtime.js", () => ({
createChannelReplyPipeline: replyPipelineMocks.createChannelReplyPipeline,
deliverReplies: deliveryMocks.deliverReplies,
emitTelegramMessageSentHooks: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/reply-dispatch-runtime", () => ({
dispatchReplyWithBufferedBlockDispatcher:
replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher,
}));
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
readChannelAllowFromStore: vi.fn(async () => []),
resolveConfiguredBindingRoute: vi.fn(({ route }: { route: unknown }) => ({
route,
bindingResolution: null,
boundSessionKey: "",
})),
getSessionBindingService: vi.fn(() => ({
resolveByConversation: vi.fn(() => null),
touch: vi.fn(),
})),
isPluginOwnedSessionBindingRecord: vi.fn(() => false),
}));
vi.mock("./bot/delivery.js", () => ({ deliverReplies: deliveryMocks.deliverReplies }));
vi.mock("./bot/delivery.replies.js", () => ({ deliverReplies: deliveryMocks.deliverReplies }));
export { createNativeCommandTestParams };
export function createNativeCommandsHarness(params?: {
cfg?: OpenClawConfig;
runtime?: RuntimeEnv;
telegramCfg?: TelegramAccountConfig;
allowFrom?: string[];
groupAllowFrom?: string[];
useAccessGroups?: boolean;
nativeEnabled?: boolean;
groupConfig?: Record<string, unknown>;
resolveGroupPolicy?: () => ChannelGroupPolicy;
}): NativeCommandHarness {
const handlers: Record<string, (ctx: unknown) => Promise<void>> = {};
const sendMessage: AnyAsyncMock = vi.fn(async () => undefined);
const setMyCommands: AnyAsyncMock = vi.fn(async () => undefined);
const log: AnyMock = vi.fn();
const telegramDeps = {
loadConfig: vi.fn(() => params?.cfg ?? ({} as OpenClawConfig)),
readChannelAllowFromStore: vi.fn(async () => []),
dispatchReplyWithBufferedBlockDispatcher:
replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher,
getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs,
listSkillCommandsForAgents: vi.fn(() => []),
syncTelegramMenuCommands: vi.fn(),
};
const bot = {
api: {
setMyCommands,
sendMessage,
},
command: (name: string, handler: (ctx: unknown) => Promise<void>) => {
handlers[name] = handler;
},
} as unknown as RegisterTelegramNativeCommandsParams["bot"];
registerTelegramNativeCommands({
bot,
cfg: params?.cfg ?? ({} as OpenClawConfig),
runtime: params?.runtime ?? ({ log } as unknown as RuntimeEnv),
accountId: "default",
telegramCfg: params?.telegramCfg ?? ({} as TelegramAccountConfig),
allowFrom: params?.allowFrom ?? [],
groupAllowFrom: params?.groupAllowFrom ?? [],
replyToMode: "off",
textLimit: 4000,
useAccessGroups: params?.useAccessGroups ?? false,
nativeEnabled: params?.nativeEnabled ?? true,
nativeSkillsEnabled: false,
nativeDisabledExplicit: false,
telegramDeps,
resolveGroupPolicy:
params?.resolveGroupPolicy ??
(() =>
({
allowlistEnabled: false,
allowed: true,
}) as ChannelGroupPolicy),
resolveTelegramGroupConfig: () => ({
groupConfig: params?.groupConfig as undefined,
topicConfig: undefined,
}),
shouldSkipUpdate: () => false,
opts: { token: "token" },
});
return { handlers, sendMessage, setMyCommands, log, bot };
}
export function createTelegramGroupCommandContext(params?: {
senderId?: number;
username?: string;
threadId?: number;
}) {
return {
message: {
chat: { id: -100999, type: "supergroup", is_forum: true },
from: {
id: params?.senderId ?? 12345,
username: params?.username ?? "testuser",
},
message_thread_id: params?.threadId ?? 42,
message_id: 1,
date: 1700000000,
},
match: "",
};
}
export function findNotAuthorizedCalls(sendMessage: AnyAsyncMock) {
return sendMessage.mock.calls.filter(
(call) => typeof call[1] === "string" && call[1].includes("not authorized"),
);
}

View file

@ -0,0 +1,600 @@
import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
createCommandBot,
createNativeCommandTestParams,
createPrivateCommandContext,
deliverReplies,
editMessageTelegram,
emitTelegramMessageSentHooks,
listSkillCommandsForAgents,
resetNativeCommandMenuMocks,
waitForRegisteredCommands,
} from "./bot-native-commands.menu-test-support.js";
import { TELEGRAM_COMMAND_NAME_PATTERN } from "./command-config.js";
import { pluginCommandMocks, resetPluginCommandMocks } from "./test-support/plugin-command.js";
let registerTelegramNativeCommands: typeof import("./bot-native-commands.js").registerTelegramNativeCommands;
let parseTelegramNativeCommandCallbackData: typeof import("./bot-native-commands.js").parseTelegramNativeCommandCallbackData;
let resolveTelegramNativeCommandDisableBlockStreaming: typeof import("./bot-native-commands.js").resolveTelegramNativeCommandDisableBlockStreaming;
type CommandBotHarness = ReturnType<typeof createCommandBot>;
type CommandHandler = (ctx: unknown) => Promise<void>;
type PlugCommandHarnessParams = {
botHarness?: CommandBotHarness;
cfg?: OpenClawConfig;
command?: Record<string, unknown>;
args?: string;
result?: Record<string, unknown>;
registerOverrides?: Partial<Parameters<typeof registerTelegramNativeCommands>[0]>;
};
function primePlugCommand(params: PlugCommandHarnessParams = {}) {
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{
name: "plug",
description: "Plugin command",
},
] as never);
pluginCommandMocks.matchPluginCommand.mockReturnValue({
command: {
key: "plug",
requireAuth: false,
...params.command,
},
args: params.args,
} as never);
pluginCommandMocks.executePluginCommand.mockResolvedValue(
(params.result ?? { text: "ok" }) as never,
);
}
function registerPlugCommand(params: PlugCommandHarnessParams = {}) {
const botHarness = params.botHarness ?? createCommandBot();
primePlugCommand(params);
registerTelegramNativeCommands({
...createNativeCommandTestParams(params.cfg ?? {}, {
bot: botHarness.bot,
...params.registerOverrides,
}),
});
const handler = botHarness.commandHandlers.get("plug");
expect(handler).toBeTruthy();
return {
...botHarness,
handler: handler as CommandHandler,
};
}
describe("registerTelegramNativeCommands", () => {
beforeAll(async () => {
({
registerTelegramNativeCommands,
parseTelegramNativeCommandCallbackData,
resolveTelegramNativeCommandDisableBlockStreaming,
} = await import("./bot-native-commands.js"));
});
beforeEach(() => {
resetNativeCommandMenuMocks();
resetPluginCommandMocks();
});
it("scopes skill commands when account binding exists", () => {
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "main", default: true }, { id: "butler" }],
},
bindings: [
{
agentId: "butler",
match: { channel: "telegram", accountId: "bot-a" },
},
],
};
registerTelegramNativeCommands(createNativeCommandTestParams(cfg, { accountId: "bot-a" }));
expect(listSkillCommandsForAgents).toHaveBeenCalledWith({
cfg,
agentIds: ["butler"],
});
});
it("scopes skill commands to default agent without a matching binding (#15599)", () => {
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "main", default: true }, { id: "butler" }],
},
};
registerTelegramNativeCommands(createNativeCommandTestParams(cfg, { accountId: "bot-a" }));
expect(listSkillCommandsForAgents).toHaveBeenCalledWith({
cfg,
agentIds: ["main"],
});
});
it("truncates Telegram command registration to 100 commands", async () => {
const cfg: OpenClawConfig = {
commands: { native: false },
};
const customCommands = Array.from({ length: 120 }, (_, index) => ({
command: `cmd_${index}`,
description: `Command ${index}`,
}));
const setMyCommands = vi.fn().mockResolvedValue(undefined);
const runtimeLog = vi.fn();
registerTelegramNativeCommands({
...createNativeCommandTestParams(cfg),
bot: {
api: {
setMyCommands,
sendMessage: vi.fn().mockResolvedValue(undefined),
},
command: vi.fn(),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
runtime: { log: runtimeLog } as unknown as RuntimeEnv,
telegramCfg: { customCommands } as TelegramAccountConfig,
nativeEnabled: false,
nativeSkillsEnabled: false,
});
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
expect(registeredCommands).toHaveLength(100);
expect(registeredCommands).toEqual(customCommands.slice(0, 100));
expect(runtimeLog).toHaveBeenCalledWith(
"Telegram limits bots to 100 commands. 120 configured; registering first 100. Use channels.telegram.commands.native: false to disable, or reduce plugin/skill/custom commands.",
);
});
it("keeps sub-100 commands by shortening long descriptions to fit Telegram payload budget", async () => {
const cfg: OpenClawConfig = {
commands: { native: false },
};
const customCommands = Array.from({ length: 92 }, (_, index) => ({
command: `cmd_${index}`,
description: `Command ${index} ` + "x".repeat(120),
}));
const setMyCommands = vi.fn().mockResolvedValue(undefined);
const runtimeLog = vi.fn();
registerTelegramNativeCommands({
...createNativeCommandTestParams(cfg),
bot: {
api: {
setMyCommands,
sendMessage: vi.fn().mockResolvedValue(undefined),
},
command: vi.fn(),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
runtime: { log: runtimeLog } as unknown as RuntimeEnv,
telegramCfg: { customCommands } as TelegramAccountConfig,
nativeEnabled: false,
nativeSkillsEnabled: false,
});
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
expect(registeredCommands).toHaveLength(92);
expect(
registeredCommands.some(
(entry) => entry.description.length < customCommands[0].description.length,
),
).toBe(true);
expect(runtimeLog).toHaveBeenCalledWith(
"Telegram menu text exceeded the conservative 5700-character payload budget; shortening descriptions to keep 92 commands visible.",
);
});
it("normalizes hyphenated native command names for Telegram registration", async () => {
const setMyCommands = vi.fn().mockResolvedValue(undefined);
const command = vi.fn();
registerTelegramNativeCommands({
...createNativeCommandTestParams({}),
bot: {
api: {
setMyCommands,
sendMessage: vi.fn().mockResolvedValue(undefined),
},
command,
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
});
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
expect(registeredCommands.some((entry) => entry.command === "export_session")).toBe(true);
expect(registeredCommands.some((entry) => entry.command === "export-session")).toBe(false);
const registeredHandlers = command.mock.calls.map(([name]) => name);
expect(registeredHandlers).toContain("export_session");
expect(registeredHandlers).not.toContain("export-session");
});
it("registers only Telegram-safe command names across native, custom, and plugin sources", async () => {
const setMyCommands = vi.fn().mockResolvedValue(undefined);
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
{ name: "plugin-status", description: "Plugin status" },
{ name: "plugin@bad", description: "Bad plugin command" },
] as never);
registerTelegramNativeCommands({
...createNativeCommandTestParams({}),
bot: {
api: {
setMyCommands,
sendMessage: vi.fn().mockResolvedValue(undefined),
},
command: vi.fn(),
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
telegramCfg: {
customCommands: [
{ command: "custom-backup", description: "Custom backup" },
{ command: "custom!bad", description: "Bad custom command" },
],
} as TelegramAccountConfig,
});
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
expect(registeredCommands.length).toBeGreaterThan(0);
for (const entry of registeredCommands) {
expect(entry.command.includes("-")).toBe(false);
expect(TELEGRAM_COMMAND_NAME_PATTERN.test(entry.command)).toBe(true);
}
expect(registeredCommands.some((entry) => entry.command === "export_session")).toBe(true);
expect(registeredCommands.some((entry) => entry.command === "custom_backup")).toBe(true);
expect(registeredCommands.some((entry) => entry.command === "plugin_status")).toBe(true);
expect(registeredCommands.some((entry) => entry.command === "plugin-status")).toBe(false);
expect(registeredCommands.some((entry) => entry.command === "custom-bad")).toBe(false);
});
it("prefixes native command menu callback data so callback handlers can preserve native routing", async () => {
const { bot, commandHandlers, sendMessage } = createCommandBot();
registerTelegramNativeCommands({
...createNativeCommandTestParams({}, { bot }),
});
const handler = commandHandlers.get("fast");
expect(handler).toBeTruthy();
await handler?.(createPrivateCommandContext());
const replyMarkup = sendMessage.mock.calls[0]?.[2]?.reply_markup as
| { inline_keyboard?: Array<Array<{ callback_data?: string }>> }
| undefined;
const callbackData = replyMarkup?.inline_keyboard
?.flat()
.map((button) => button.callback_data)
.filter(Boolean);
expect(callbackData).toEqual(["tgcmd:/fast status", "tgcmd:/fast on", "tgcmd:/fast off"]);
expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast status")).toBe("/fast status");
expect(parseTelegramNativeCommandCallbackData("tgcmd:fast status")).toBeNull();
});
it("passes agent-scoped media roots for plugin command replies with media", async () => {
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "main", default: true }, { id: "work" }],
},
bindings: [{ agentId: "work", match: { channel: "telegram", accountId: "default" } }],
};
const { handler, sendMessage } = registerPlugCommand({
cfg,
result: {
text: "with media",
mediaUrl: "/tmp/workspace-work/render.png",
},
});
await handler(createPrivateCommandContext());
const firstDeliverRepliesCall = deliverReplies.mock.calls.at(0) as [unknown] | undefined;
expect(firstDeliverRepliesCall?.[0]).toEqual(
expect.objectContaining({
mediaLocalRoots: expect.arrayContaining([
expect.stringMatching(/[\\/]\.openclaw[\\/]workspace-work$/),
]),
}),
);
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
});
it("uses nested streaming.block.enabled for native command block-streaming behavior", () => {
expect(
resolveTelegramNativeCommandDisableBlockStreaming({
streaming: {
block: {
enabled: false,
},
},
} as TelegramAccountConfig),
).toBe(true);
expect(
resolveTelegramNativeCommandDisableBlockStreaming({
streaming: {
block: {
enabled: true,
},
},
} as TelegramAccountConfig),
).toBe(false);
});
it("uses plugin command metadata to send and edit a Telegram progress placeholder", async () => {
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
args: "now",
command: {
nativeProgressMessages: {
telegram:
"Running this command now...\n\nI'll edit this message with the final result when it's ready.",
},
},
result: {
text: "Command completed successfully",
},
});
await handler(
createPrivateCommandContext({
match: "now",
}),
);
expect(sendMessage).toHaveBeenCalledWith(
100,
expect.stringContaining("Running this command now"),
undefined,
);
expect(editMessageTelegram).toHaveBeenCalledWith(
100,
999,
expect.stringContaining("Command completed successfully"),
expect.objectContaining({
accountId: "default",
}),
);
expect(deleteMessage).not.toHaveBeenCalled();
expect(deliverReplies).not.toHaveBeenCalled();
expect(emitTelegramMessageSentHooks).toHaveBeenCalledWith(
expect.objectContaining({
chatId: "100",
content: "Command completed successfully",
messageId: 999,
success: true,
}),
);
});
it("preserves Telegram buttons when editing a metadata-driven progress placeholder", async () => {
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
args: "now",
command: {
nativeProgressMessages: { telegram: "Working on it..." },
},
result: {
text: "Choose an option",
channelData: {
telegram: {
buttons: [[{ text: "Approve", callback_data: "approve" }]],
},
},
},
});
await handler(createPrivateCommandContext({ match: "now" }));
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
expect(editMessageTelegram).toHaveBeenCalledWith(
100,
999,
"Choose an option",
expect.objectContaining({
buttons: [[{ text: "Approve", callback_data: "approve" }]],
}),
);
expect(deleteMessage).not.toHaveBeenCalled();
expect(deliverReplies).not.toHaveBeenCalled();
});
it("falls back to a normal reply when a metadata-driven progress result is not editable", async () => {
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
args: "now",
command: {
nativeProgressMessages: { telegram: "Working on it..." },
},
result: {
text: "rich output",
mediaUrl: "/tmp/render.png",
},
});
await handler(
createPrivateCommandContext({
match: "now",
}),
);
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
expect(editMessageTelegram).not.toHaveBeenCalled();
expect(deleteMessage).toHaveBeenCalledWith(100, 999);
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ mediaUrl: "/tmp/render.png" })],
}),
);
});
it("cleans up the progress placeholder before falling back after an edit failure", async () => {
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
args: "now",
command: {
nativeProgressMessages: { telegram: "Working on it..." },
},
result: {
text: "Command completed successfully",
},
});
editMessageTelegram.mockRejectedValueOnce(new Error("message to edit not found"));
await handler(createPrivateCommandContext({ match: "now" }));
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
expect(editMessageTelegram).toHaveBeenCalledTimes(1);
expect(deleteMessage).toHaveBeenCalledWith(100, 999);
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: "Command completed successfully" })],
}),
);
});
it("cleans up the progress placeholder when Telegram suppresses a local exec approval reply", async () => {
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
args: "now",
command: {
nativeProgressMessages: { telegram: "Working on it..." },
},
result: {
text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```",
channelData: {
execApproval: {
approvalId: "7f423fdc-1111-2222-3333-444444444444",
approvalSlug: "7f423fdc",
allowedDecisions: ["allow-once", "allow-always", "deny"],
},
},
},
cfg: {
channels: {
telegram: {
execApprovals: {
enabled: true,
approvers: ["12345"],
target: "dm",
},
},
},
},
});
await handler(createPrivateCommandContext({ match: "now" }));
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
expect(deleteMessage).toHaveBeenCalledWith(100, 999);
expect(editMessageTelegram).not.toHaveBeenCalled();
expect(deliverReplies).not.toHaveBeenCalled();
});
it("sends plugin command error replies silently when silentErrorReplies is enabled", async () => {
const { handler } = registerPlugCommand({
cfg: {
channels: {
telegram: {
silentErrorReplies: true,
},
},
},
result: {
text: "plugin failed",
isError: true,
},
registerOverrides: {
telegramCfg: { silentErrorReplies: true } as TelegramAccountConfig,
},
});
await handler(createPrivateCommandContext());
const firstDeliverRepliesCall = deliverReplies.mock.calls.at(0) as [unknown] | undefined;
expect(firstDeliverRepliesCall?.[0]).toEqual(
expect.objectContaining({
silent: true,
replies: [expect.objectContaining({ isError: true })],
}),
);
});
it("forwards topic-scoped binding context to Telegram plugin commands", async () => {
const { handler } = registerPlugCommand();
await handler({
match: "",
message: {
message_id: 2,
date: Math.floor(Date.now() / 1000),
chat: {
id: -1001234567890,
type: "supergroup",
title: "Forum Group",
is_forum: true,
},
message_thread_id: 77,
from: { id: 200, username: "bob" },
},
});
expect(pluginCommandMocks.executePluginCommand).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
accountId: "default",
from: "telegram:group:-1001234567890:topic:77",
to: "telegram:-1001234567890",
messageThreadId: 77,
}),
);
});
it("treats Telegram forum #General commands as topic 1 when Telegram omits topic metadata", async () => {
const getChat = vi.fn(async () => ({ id: -1001234567890, type: "supergroup", is_forum: true }));
const { handler } = registerPlugCommand({
botHarness: createCommandBot({ api: { getChat } }),
});
await handler({
match: "",
message: {
message_id: 2,
date: Math.floor(Date.now() / 1000),
chat: {
id: -1001234567890,
type: "supergroup",
title: "Forum Group",
},
from: { id: 200, username: "bob" },
},
});
expect(getChat).toHaveBeenCalledWith(-1001234567890);
expect(pluginCommandMocks.executePluginCommand).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "default",
from: "telegram:group:-1001234567890:topic:1",
to: "telegram:-1001234567890",
messageThreadId: 1,
}),
);
});
it("forwards direct-message binding context to Telegram plugin commands", async () => {
const { handler } = registerPlugCommand();
await handler(createPrivateCommandContext({ chatId: 100, userId: 200 }));
expect(pluginCommandMocks.executePluginCommand).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
accountId: "default",
from: "telegram:100",
to: "telegram:100",
messageThreadId: undefined,
}),
);
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,67 @@
import type { Message } from "@grammyjs/types";
import { createDedupeCache } from "openclaw/plugin-sdk/infra-runtime";
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

@ -0,0 +1 @@
export { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";

View file

@ -0,0 +1,214 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
const {
getLoadConfigMock,
listSkillCommandsForAgents,
setMyCommandsSpy,
telegramBotDepsForTest,
telegramBotRuntimeForTest,
} = await import("./bot.create-telegram-bot.test-harness.js");
let listNativeCommandSpecs: typeof import("../../../src/auto-reply/commands-registry.js").listNativeCommandSpecs;
let listNativeCommandSpecsForConfig: typeof import("../../../src/auto-reply/commands-registry.js").listNativeCommandSpecsForConfig;
let normalizeTelegramCommandName: typeof import("./command-config.js").normalizeTelegramCommandName;
let createTelegramBotBase: typeof import("./bot.js").createTelegramBot;
let setTelegramBotRuntimeForTest: typeof import("./bot.js").setTelegramBotRuntimeForTest;
let createTelegramBot: (
opts: Parameters<typeof import("./bot.js").createTelegramBot>[0],
) => ReturnType<typeof import("./bot.js").createTelegramBot>;
const loadConfig = getLoadConfigMock();
function createSignal() {
let resolve!: () => void;
const promise = new Promise<void>((res) => {
resolve = res;
});
return { promise, resolve };
}
function waitForNextSetMyCommands() {
const synced = createSignal();
setMyCommandsSpy.mockImplementationOnce(async () => {
synced.resolve();
return undefined;
});
return synced.promise;
}
function resolveSkillCommands(config: Parameters<typeof listNativeCommandSpecsForConfig>[0]) {
void config;
return listSkillCommandsForAgents() as NonNullable<
Parameters<typeof listNativeCommandSpecsForConfig>[1]
>["skillCommands"];
}
describe("createTelegramBot command menu", () => {
beforeAll(async () => {
({ listNativeCommandSpecs, listNativeCommandSpecsForConfig } =
await import("../../../src/auto-reply/commands-registry.js"));
({ normalizeTelegramCommandName } = await import("./command-config.js"));
({ createTelegramBot: createTelegramBotBase, setTelegramBotRuntimeForTest } =
await import("./bot.js"));
});
beforeEach(() => {
loadConfig.mockReturnValue({
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"] },
},
});
setTelegramBotRuntimeForTest(
telegramBotRuntimeForTest as unknown as Parameters<typeof setTelegramBotRuntimeForTest>[0],
);
createTelegramBot = (opts) =>
createTelegramBotBase({
...opts,
telegramDeps: telegramBotDepsForTest,
});
});
it("merges custom commands with native commands", async () => {
const config = {
commands: {
native: true,
},
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
customCommands: [
{ command: "custom_backup", description: "Git backup" },
{ command: "/Custom_Generate", description: "Create an image" },
],
},
},
} satisfies OpenClawConfig;
loadConfig.mockReturnValue(config);
const commandsSynced = waitForNextSetMyCommands();
createTelegramBot({ token: "tok" });
await commandsSynced;
const registered = setMyCommandsSpy.mock.calls.at(-1)?.[0] as Array<{
command: string;
description: string;
}>;
const skillCommands = resolveSkillCommands(config);
const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({
command: normalizeTelegramCommandName(command.name),
description: command.description,
}));
expect(registered.slice(0, native.length)).toEqual(native);
});
it("ignores custom commands that collide with native commands", async () => {
const errorSpy = vi.fn();
const config = {
commands: {
native: true,
},
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
customCommands: [
{ command: "status", description: "Custom status" },
{ command: "custom_backup", description: "Git backup" },
],
},
},
} satisfies OpenClawConfig;
loadConfig.mockReturnValue(config);
const commandsSynced = waitForNextSetMyCommands();
createTelegramBot({
token: "tok",
runtime: {
log: vi.fn(),
error: errorSpy,
exit: ((code: number) => {
throw new Error(`exit ${code}`);
}) as (code: number) => never,
},
});
await commandsSynced;
const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{
command: string;
description: string;
}>;
const skillCommands = resolveSkillCommands(config);
const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({
command: normalizeTelegramCommandName(command.name),
description: command.description,
}));
const nativeStatus = native.find((command) => command.command === "status");
expect(nativeStatus).toBeDefined();
expect(registered).toContainEqual({ command: "custom_backup", description: "Git backup" });
expect(registered).not.toContainEqual({ command: "status", description: "Custom status" });
expect(registered.filter((command) => command.command === "status")).toEqual([nativeStatus]);
expect(errorSpy).toHaveBeenCalled();
});
it("registers custom commands when native commands are disabled", async () => {
const config = {
commands: { native: false },
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
customCommands: [
{ command: "custom_backup", description: "Git backup" },
{ command: "custom_generate", description: "Create an image" },
],
},
},
} satisfies OpenClawConfig;
loadConfig.mockReturnValue(config);
const commandsSynced = waitForNextSetMyCommands();
createTelegramBot({ token: "tok" });
await commandsSynced;
const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{
command: string;
description: string;
}>;
expect(registered).toEqual([
{ command: "custom_backup", description: "Git backup" },
{ command: "custom_generate", description: "Create an image" },
]);
const reserved = new Set(listNativeCommandSpecs().map((command) => command.name));
expect(registered.some((command) => reserved.has(command.command))).toBe(false);
});
});

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