refactor(telegram): split native commands by executor (#122419)

* refactor(telegram): split native commands by executor

* refactor(telegram): deduplicate DM-thread target session

* chore(lint): ratchet max-lines baseline after telegram commands split

* test(telegram): fix native command split checks
This commit is contained in:
Peter Steinberger
2026-08-11 21:45:17 -07:00
committed by GitHub
parent fc31a157cc
commit cb52ded58d
22 changed files with 4885 additions and 4937 deletions
-2
View File
@@ -247,8 +247,6 @@ extensions/slack/src/send.ts
extensions/telegram/src/action-runtime.test.ts
extensions/telegram/src/action-runtime.ts
extensions/telegram/src/bot-message-context.session.ts
extensions/telegram/src/bot-native-commands.session-meta.test.ts
extensions/telegram/src/bot-native-commands.ts
extensions/telegram/src/bot.create-telegram-bot.test.ts
extensions/telegram/src/bot.test.ts
extensions/telegram/src/bot/delivery.replies.ts
@@ -38,7 +38,6 @@ import type {
RegisterTelegramHandlerParams,
TelegramCallbackRouter,
} from "./bot-handlers.types.js";
import { parseTelegramNativeCommandCallbackData } from "./bot-native-commands.js";
import {
isTelegramSpooledReplayUpdate,
recordTelegramMessageProcessingResult,
@@ -63,6 +62,7 @@ import {
} from "./model-buttons.js";
import {
hasTelegramOpaqueCallbackPrefix,
parseTelegramNativeCommandCallbackData,
parseTelegramOpaqueCallbackData,
} from "./native-command-callback-data.js";
import { isTelegramMessageNotModifiedError } from "./network-errors.js";
@@ -4,7 +4,6 @@ import { formatMediaPlaceholderText } from "openclaw/plugin-sdk/channel-inbound"
import { resolveStoredModelOverride } from "openclaw/plugin-sdk/command-auth-native";
import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import {
getSessionEntry,
readAmbientTranscriptWatermark,
@@ -25,13 +24,12 @@ import {
getTelegramTextParts,
resolveTelegramPrimaryMedia,
resolveTelegramForumThreadId,
shouldUseTelegramDmThreadSession,
type TelegramThreadSpec,
} from "./bot/helpers.js";
import type { TelegramContext } from "./bot/types.js";
import {
resolveTelegramConversationBaseSessionKey,
resolveTelegramConversationRoute,
resolveTelegramTargetSession,
} from "./conversation-route.js";
import { resolveTelegramDmHistoryLimit } from "./dm-history.js";
import {
@@ -212,24 +210,15 @@ export function createTelegramMessageSessionRuntime({
senderId: params.senderId,
topicAgentId: topicConfig?.agentId,
});
const baseSessionKey = resolveTelegramConversationBaseSessionKey({
const sessionKey = resolveTelegramTargetSession({
cfg: params.runtimeCfg,
route,
chatId: params.chatId,
isGroup: params.isGroup,
senderId: params.senderId,
dmThreadId,
botHasTopicsEnabled: params.botHasTopicsEnabled,
});
const threadKeys =
shouldUseTelegramDmThreadSession({
dmThreadId,
botHasTopicsEnabled: params.botHasTopicsEnabled,
}) && dmThreadId != null
? resolveThreadSessionKeys({
baseSessionKey,
threadId: `${params.chatId}:${dmThreadId}`,
})
: null;
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
const storePath = telegramDeps.resolveStorePath(params.runtimeCfg.session?.store, {
agentId: route.agentId,
});
@@ -0,0 +1,457 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it } from "vitest";
import {
executorTestMocks,
expectRecordFields,
expectSendMessageCall,
registerAndResolveCommandHandler,
resetSessionMetaMocks,
} from "./bot-native-command-executors.test-support.js";
import { createTelegramPrivateCommandContext } from "./bot-native-commands.fixture-test-support.js";
const { agentRuntimeMocks, commandAuthMocks, replyMocks, sessionMocks } = executorTestMocks;
describe("Telegram native command built-ins", () => {
beforeEach(resetSessionMetaMocks);
it("uses the target session model when building native argument menus", async () => {
const cfg = {
agents: {
defaults: {
thinkingDefault: "low",
models: {
"anthropic/claude-opus-4-7": {
params: { thinking: "xhigh" },
},
},
},
},
} as OpenClawConfig;
sessionMocks.sessionStoreEntries.mockReturnValue({
"agent:main:main": {
providerOverride: "anthropic",
modelOverride: "claude-opus-4-7",
modelOverrideSource: "user",
thinkingLevel: "high",
updatedAt: 0,
},
});
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "think",
cfg,
allowFrom: ["*"],
});
await handler(createTelegramPrivateCommandContext());
const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find(
([params]) => params.command.key === "think" && params.provider === "anthropic",
)?.[0];
expectRecordFields(
menuCall,
{ provider: "anthropic", model: "claude-opus-4-7" },
"thinking menu call",
);
expect(sessionMocks.getSessionEntry).toHaveBeenCalledWith({
storePath: "/tmp/openclaw-sessions.json",
sessionKey: "agent:main:main",
});
expectSendMessageCall({
sendMessage,
chatId: 100,
textIncludes: "Current thinking level: high.\nChoose level for /think.",
requireReplyMarkup: true,
label: "thinking menu",
});
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it.each([
{ sessionRuntime: undefined, expectedRuntime: "codex" },
{ sessionRuntime: "openclaw", expectedRuntime: "openclaw" },
])(
"uses the effective $expectedRuntime runtime for native /think menus",
async ({ sessionRuntime, expectedRuntime }) => {
const cfg = {
agents: {
defaults: {
models: {
"openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } },
},
},
},
} as OpenClawConfig;
sessionMocks.sessionStoreEntries.mockReturnValue({
"agent:main:main": {
providerOverride: "openai",
modelOverride: "gpt-5.6-luna",
modelOverrideSource: "user",
...(sessionRuntime ? { agentRuntimeOverride: sessionRuntime } : {}),
updatedAt: 0,
},
});
const { handler } = registerAndResolveCommandHandler({
commandName: "think",
cfg,
allowFrom: ["*"],
});
await handler(createTelegramPrivateCommandContext());
const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find(
([params]) => params.command.key === "think" && params.model === "gpt-5.6-luna",
)?.[0];
expectRecordFields(
menuCall,
{
provider: "openai",
model: "gpt-5.6-luna",
agentRuntime: expectedRuntime,
},
"runtime-aware thinking menu call",
);
},
);
it("resolves /think menu choices against the runtime catalog for live-discovered models", async () => {
const cfg = {
agents: { defaults: { models: { "ollama/*": {} } } },
} as OpenClawConfig;
sessionMocks.sessionStoreEntries.mockReturnValue({
"agent:main:main": {
providerOverride: "ollama",
modelOverride: "glm-5.2:cloud",
modelOverrideSource: "user",
updatedAt: 0,
},
});
const runtimeCatalog = [
{ provider: "ollama", id: "glm-5.2:cloud", name: "glm-5.2:cloud", reasoning: true },
];
agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue(runtimeCatalog);
const { handler } = registerAndResolveCommandHandler({
commandName: "think",
cfg,
allowFrom: ["*"],
});
await handler(createTelegramPrivateCommandContext());
const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find(
([params]) => params.command.key === "think" && params.provider === "ollama",
)?.[0];
const menuRecord = expectRecordFields(
menuCall,
{ provider: "ollama", model: "glm-5.2:cloud" },
"ollama thinking menu call",
);
expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalled();
expect(menuRecord.catalog).toEqual(runtimeCatalog);
});
it("loads the runtime catalog for /think when no session model override is set", async () => {
const cfg = {
agents: { defaults: { model: "ollama/glm-5.2:cloud", models: { "ollama/*": {} } } },
} as OpenClawConfig;
sessionMocks.sessionStoreEntries.mockReturnValue({});
const runtimeCatalog = [
{ provider: "ollama", id: "glm-5.2:cloud", name: "glm-5.2:cloud", reasoning: true },
];
agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue(runtimeCatalog);
const { handler } = registerAndResolveCommandHandler({
commandName: "think",
cfg,
allowFrom: ["*"],
});
await handler(createTelegramPrivateCommandContext());
expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalled();
const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find(
([params]) => params.command.key === "think",
)?.[0];
const menuRecord = expectRecordFields(menuCall, {}, "default-model thinking menu call");
expect(menuRecord.provider).toBeUndefined();
expect(menuRecord.catalog).toEqual(runtimeCatalog);
});
it("inherits the parent session model when building DM thread native argument menus", async () => {
const cfg: OpenClawConfig = {};
sessionMocks.sessionStoreEntries.mockReturnValue({
"agent:main:main": {
providerOverride: "anthropic",
modelOverride: "claude-opus-4-7",
modelOverrideSource: "user",
updatedAt: 0,
},
});
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "think",
cfg,
allowFrom: ["*"],
});
await handler(createTelegramPrivateCommandContext({ threadId: 77 }));
const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find(
([params]) => params.command.key === "think" && params.provider === "anthropic",
)?.[0];
expectRecordFields(
menuCall,
{ provider: "anthropic", model: "claude-opus-4-7" },
"thread thinking menu call",
);
expectSendMessageCall({
sendMessage,
chatId: 100,
textIncludes: "Choose level for /think.",
requireReplyMarkup: true,
label: "thread thinking menu",
});
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("uses the configured default model instead of temporary auto fallback overrides", async () => {
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.5" },
thinkingDefault: "medium",
},
},
} as OpenClawConfig;
sessionMocks.sessionStoreEntries.mockReturnValue({
"agent:main:main": {
providerOverride: "anthropic",
modelOverride: "claude-opus-4-7",
modelOverrideSource: "auto",
modelProvider: "anthropic",
model: "claude-opus-4-7",
updatedAt: 0,
},
});
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "think",
cfg,
allowFrom: ["*"],
});
await handler(createTelegramPrivateCommandContext());
const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find(
([params]) => params.command.key === "think" && params.provider === "openai",
)?.[0];
expectRecordFields(
menuCall,
{ provider: "openai", model: "gpt-5.5" },
"default model thinking menu call",
);
expectSendMessageCall({
sendMessage,
chatId: 100,
textIncludes: "Current thinking level: medium.\nChoose level for /think.",
requireReplyMarkup: true,
label: "default model thinking menu",
});
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("uses configured model defaults instead of runtime auth metadata for the fast menu", async () => {
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.5" },
models: {
"openai/gpt-5.5": {
params: { fastMode: "auto", fastAutoOnSeconds: 30 },
},
},
},
},
} as OpenClawConfig;
sessionMocks.sessionStoreEntries.mockReturnValue({
"agent:main:main": {
modelProvider: "openai-codex",
model: "gpt-5.5",
updatedAt: 0,
},
});
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "fast",
cfg,
allowFrom: ["*"],
});
await handler(createTelegramPrivateCommandContext());
const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find(
([params]) => params.command.key === "fast",
)?.[0];
expectRecordFields(menuCall, { cfg }, "fast menu call");
expect(
commandAuthMocks.resolveCommandArgMenu.mock.calls.some(
([params]) =>
params.command.key === "fast" &&
params.provider === "openai" &&
params.model === "gpt-5.5",
),
).toBe(true);
const options = expectSendMessageCall({
sendMessage,
chatId: 100,
textIncludes:
"Current fast mode: auto (30 sec) (default: model).\nOptions: on, off, auto (30 sec), default, status.",
requireReplyMarkup: true,
label: "fast menu",
});
const replyMarkup = options.reply_markup as
| { inline_keyboard?: Array<Array<{ text?: string }>> }
| undefined;
const labels = (replyMarkup?.inline_keyboard ?? []).flatMap((row) =>
row.map((button) => button.text),
);
expect(labels).toContain("auto (30 sec)");
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("uses the read-only catalog for Claude CLI thinking menus", async () => {
const cfg = {
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-8" },
},
},
} as OpenClawConfig;
sessionMocks.sessionStoreEntries.mockReturnValue({});
agentRuntimeMocks.loadModelCatalog.mockImplementation(async (params) => {
if (!params?.readOnly) {
throw new Error("native /think must not start full model discovery");
}
return [
{
provider: "anthropic",
id: "claude-opus-4-8",
name: "Claude Opus 4.8",
reasoning: true,
},
];
});
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "think",
cfg,
allowFrom: ["*"],
});
await handler(createTelegramPrivateCommandContext());
expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalledWith(
expect.objectContaining({
config: cfg,
agentDir: expect.any(String),
readOnly: true,
}),
);
expect(agentRuntimeMocks.loadModelCatalog.mock.calls[0]?.[0]).not.toHaveProperty(
"workspaceDir",
);
expectSendMessageCall({
sendMessage,
chatId: 100,
textIncludes: "Current thinking level: off.\nChoose level for /think.",
requireReplyMarkup: true,
label: "Claude CLI thinking menu",
});
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("uses target model thinking defaults before global thinking defaults", async () => {
const cfg = {
agents: {
defaults: {
thinkingDefault: "low",
models: {
"anthropic/claude-opus-4-7": {
params: { thinking: "xhigh" },
},
},
},
},
} as OpenClawConfig;
sessionMocks.sessionStoreEntries.mockReturnValue({
"agent:main:main": {
providerOverride: "anthropic",
modelOverride: "claude-opus-4-7",
modelOverrideSource: "user",
updatedAt: 0,
},
});
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "think",
cfg,
allowFrom: ["*"],
});
await handler(createTelegramPrivateCommandContext());
expectSendMessageCall({
sendMessage,
chatId: 100,
textIncludes: "Current thinking level: xhigh.\nChoose level for /think.",
requireReplyMarkup: true,
label: "target model thinking menu",
});
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("uses per-agent thinking defaults before target model and global thinking defaults", async () => {
const cfg = {
agents: {
defaults: {
thinkingDefault: "low",
models: {
"anthropic/claude-opus-4-7": {
params: { thinking: "xhigh" },
},
},
},
list: [
{
id: "alpha",
model: { primary: "anthropic/claude-opus-4-7" },
thinkingDefault: "minimal",
},
],
},
} as OpenClawConfig;
sessionMocks.sessionStoreEntries.mockReturnValue({});
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "think",
cfg,
allowFrom: ["*"],
});
await handler(createTelegramPrivateCommandContext());
expectSendMessageCall({
sendMessage,
chatId: 100,
textIncludes: "Current thinking level: minimal.\nChoose level for /think.",
requireReplyMarkup: true,
label: "agent thinking menu",
});
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("does not load the session store when a native argument menu is skipped", async () => {
const { handler } = registerAndResolveCommandHandler({
commandName: "think",
cfg: {},
allowFrom: ["*"],
});
await handler(createTelegramPrivateCommandContext({ match: "high" }));
expect(sessionMocks.sessionStoreEntries).not.toHaveBeenCalled();
expect(agentRuntimeMocks.loadModelCatalog).not.toHaveBeenCalled();
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,375 @@
// Telegram plugin module implements built-in native command behavior.
import {
loadPreparedModelCatalog,
resolveAgentConfig,
resolveAgentDir,
resolveDefaultModelForAgent,
resolveThinkingDefaultWithRuntimeCatalog,
} from "openclaw/plugin-sdk/agent-runtime";
import {
buildCommandTextFromArgs,
findCommandByNativeName,
formatCommandArgMenuTitle,
formatFastModeCurrentStatus,
parseCommandArgs,
resolveCommandArgMenu,
resolveEffectiveAgentRuntime,
resolveFastModeState,
resolveStoredModelOverride,
type CommandArgs,
} from "openclaw/plugin-sdk/command-auth-native";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import {
getSessionEntry,
resolveStorePath,
type SessionEntry,
} from "openclaw/plugin-sdk/session-store-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import {
dispatchTelegramBuiltinTurn,
prepareTelegramCommandDispatch,
type TelegramCommandExecutorParams,
} from "./bot-native-command-dispatch.js";
import { buildInlineKeyboard } from "./inline-keyboard.js";
import { buildTelegramNativeCommandCallbackData } from "./native-command-callback-data.js";
const loadTelegramLoginCommandExecutor = createLazyRuntimeModule(
() => import("./bot-native-command-login.js"),
);
type TelegramCommandMenuModelContext = {
provider?: string;
model?: string;
agentRuntime?: string;
thinkingLevel?: string;
fastMode?: SessionEntry["fastMode"];
};
function buildTelegramCommandMenuModelContext(params: {
provider: string;
model: string;
thinkingLevel?: string;
fastMode?: SessionEntry["fastMode"];
}): TelegramCommandMenuModelContext {
return {
provider: params.provider,
model: params.model,
...(params.thinkingLevel ? { thinkingLevel: params.thinkingLevel } : {}),
...(params.fastMode !== undefined ? { fastMode: params.fastMode } : {}),
};
}
function resolveTelegramCommandMenuModelContext(params: {
cfg: OpenClawConfig;
agentId: string;
sessionKey: string;
}): TelegramCommandMenuModelContext {
if (!params.sessionKey.trim()) {
return {};
}
try {
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId });
const defaultModel = resolveDefaultModelForAgent({ cfg: params.cfg, agentId: params.agentId });
const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey });
const thinkingLevel = normalizeOptionalString(entry?.thinkingLevel);
const fastMode = entry?.fastMode;
let context: TelegramCommandMenuModelContext;
if (entry?.modelOverrideSource === "auto" && normalizeOptionalString(entry.modelOverride)) {
context = buildTelegramCommandMenuModelContext({
provider: defaultModel.provider,
model: defaultModel.model,
...(thinkingLevel ? { thinkingLevel } : {}),
...(fastMode !== undefined ? { fastMode } : {}),
});
} else {
const override = resolveStoredModelOverride({
sessionEntry: entry,
loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }),
sessionKey: params.sessionKey,
defaultProvider: defaultModel.provider,
});
if (override?.model) {
context = buildTelegramCommandMenuModelContext({
provider: override.provider || defaultModel.provider,
model: override.model,
...(thinkingLevel ? { thinkingLevel } : {}),
...(fastMode !== undefined ? { fastMode } : {}),
});
} else {
const provider =
normalizeOptionalString(entry?.providerOverride) ??
normalizeOptionalString(entry?.modelProvider);
const model =
normalizeOptionalString(entry?.modelOverride) ?? normalizeOptionalString(entry?.model);
context = {
...(provider ? { provider } : {}),
...(model ? { model } : {}),
...(thinkingLevel ? { thinkingLevel } : {}),
...(fastMode !== undefined ? { fastMode } : {}),
};
}
}
return {
...context,
agentRuntime: resolveEffectiveAgentRuntime({
cfg: params.cfg,
provider: context.provider ?? defaultModel.provider,
modelId: context.model ?? defaultModel.model,
agentId: params.agentId,
sessionKey: params.sessionKey,
sessionEntry: entry,
}),
};
} catch {
return {};
}
}
function resolveTelegramFastCommandModelContext(params: {
cfg: OpenClawConfig;
agentId: string;
sessionKey: string;
}): { provider?: string; model?: string } {
const defaultModel = resolveDefaultModelForAgent({ cfg: params.cfg, agentId: params.agentId });
const fallback = () => ({ provider: defaultModel.provider, model: defaultModel.model });
if (!params.sessionKey.trim()) {
return fallback();
}
try {
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId });
const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey });
if (entry?.modelOverrideSource === "auto" && normalizeOptionalString(entry.modelOverride)) {
return fallback();
}
const override = resolveStoredModelOverride({
sessionEntry: entry,
loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }),
sessionKey: params.sessionKey,
defaultProvider: defaultModel.provider,
});
return {
provider: override?.provider ?? defaultModel.provider,
model: override?.model ?? defaultModel.model,
};
} catch {
return fallback();
}
}
function resolveTelegramFastCommandState(params: {
cfg: OpenClawConfig;
agentId: string;
sessionKey: string;
}) {
const defaultModel = resolveDefaultModelForAgent({ cfg: params.cfg, agentId: params.agentId });
const fallback = () =>
resolveFastModeState({
cfg: params.cfg,
provider: defaultModel.provider,
model: defaultModel.model,
agentId: params.agentId,
});
if (!params.sessionKey.trim()) {
return fallback();
}
try {
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId });
const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey });
const modelContext = resolveTelegramFastCommandModelContext(params);
return resolveFastModeState({
cfg: params.cfg,
provider: modelContext.provider ?? defaultModel.provider,
model: modelContext.model ?? defaultModel.model,
agentId: params.agentId,
sessionEntry:
entry?.fastMode !== undefined
? {
fastMode: entry.fastMode,
}
: undefined,
});
} catch {
return fallback();
}
}
async function resolveTelegramThinkMenuCurrentLevel(params: {
cfg: OpenClawConfig;
agentId: string;
provider?: string;
model?: string;
agentRuntime?: string;
thinkingLevel?: string;
catalog: Awaited<ReturnType<typeof loadPreparedModelCatalog>>;
}): Promise<string> {
const explicit = normalizeOptionalString(params.thinkingLevel);
if (explicit) {
return explicit;
}
const agentThinkingDefault = normalizeOptionalString(
resolveAgentConfig(params.cfg, params.agentId)?.thinkingDefault,
);
if (agentThinkingDefault) {
return agentThinkingDefault;
}
const defaultModel = resolveDefaultModelForAgent({ cfg: params.cfg, agentId: params.agentId });
return await resolveThinkingDefaultWithRuntimeCatalog({
cfg: params.cfg,
provider: params.provider ?? defaultModel.provider,
model: params.model ?? defaultModel.model,
agentRuntime: params.agentRuntime,
loadRuntimeCatalog: async () => params.catalog,
});
}
function formatTelegramCommandArgMenuTitle(params: {
command: NonNullable<ReturnType<typeof findCommandByNativeName>>;
menu: NonNullable<ReturnType<typeof resolveCommandArgMenu>>;
currentThinkingLevel?: string;
currentFastModeStatus?: string;
}): string {
const title = formatCommandArgMenuTitle({ command: params.command, menu: params.menu });
if (params.command.key === "think" && params.currentThinkingLevel) {
return `Current thinking level: ${params.currentThinkingLevel}.\n${title}`;
}
if (params.command.key === "fast" && params.currentFastModeStatus) {
const options = params.menu.choices
.map((choice) => choice.label.trim())
.filter(Boolean)
.join(", ");
return options
? `${params.currentFastModeStatus}\nOptions: ${options}.`
: params.currentFastModeStatus;
}
return title;
}
export async function executeTelegramBuiltinCommand(
params: TelegramCommandExecutorParams & { commandName: string },
): Promise<boolean> {
const dispatch = await prepareTelegramCommandDispatch({ ...params, requireAuth: true });
if (!dispatch) {
return false;
}
const commandDefinition = findCommandByNativeName(params.commandName, "telegram");
const commandArgs = commandDefinition
? parseCommandArgs(commandDefinition, params.rawText)
: params.rawText
? ({ raw: params.rawText } satisfies CommandArgs)
: undefined;
const prompt = commandDefinition
? buildCommandTextFromArgs(commandDefinition, commandArgs)
: params.rawText
? `/${params.commandName} ${params.rawText}`
: `/${params.commandName}`;
if (commandDefinition?.key === "login") {
const { executeTelegramLoginCommand } = await loadTelegramLoginCommandExecutor();
return await executeTelegramLoginCommand({ dispatch, commandArgs });
}
const menuNeedsModelContext =
commandDefinition?.argsMenu &&
!(commandArgs?.raw && !commandArgs.values) &&
commandDefinition.args?.some(
(arg) => typeof arg.choices === "function" && commandArgs?.values?.[arg.name] == null,
);
const sessionKeyForMenu =
commandDefinition && menuNeedsModelContext ? dispatch.targetSessionKey : "";
const fastCommandState =
commandDefinition?.key === "fast" && menuNeedsModelContext
? resolveTelegramFastCommandState({
cfg: dispatch.runtimeCfg,
agentId: dispatch.route.agentId,
sessionKey: sessionKeyForMenu,
})
: undefined;
const fastMenuModelContext =
commandDefinition?.key === "fast" && menuNeedsModelContext
? resolveTelegramFastCommandModelContext({
cfg: dispatch.runtimeCfg,
agentId: dispatch.route.agentId,
sessionKey: sessionKeyForMenu,
})
: undefined;
const menuModelContext =
commandDefinition && menuNeedsModelContext
? (fastMenuModelContext ??
resolveTelegramCommandMenuModelContext({
cfg: dispatch.runtimeCfg,
agentId: dispatch.route.agentId,
sessionKey: sessionKeyForMenu,
}))
: {};
// Native /think must not wait on provider discovery; persisted rows retain its metadata.
const menuModelCatalog =
commandDefinition?.key === "think" && menuNeedsModelContext
? await loadPreparedModelCatalog({
config: dispatch.runtimeCfg,
agentId: dispatch.route.agentId,
agentDir: resolveAgentDir(dispatch.runtimeCfg, dispatch.route.agentId),
readOnly: true,
})
: undefined;
const menu = commandDefinition
? resolveCommandArgMenu({
command: commandDefinition,
args: commandArgs,
cfg: dispatch.runtimeCfg,
...menuModelContext,
...(menuModelCatalog?.length ? { catalog: menuModelCatalog } : {}),
})
: null;
if (menu && commandDefinition) {
const title = formatTelegramCommandArgMenuTitle({
command: commandDefinition,
menu,
currentThinkingLevel:
commandDefinition.key === "think"
? await resolveTelegramThinkMenuCurrentLevel({
cfg: dispatch.runtimeCfg,
agentId: dispatch.route.agentId,
...menuModelContext,
catalog: menuModelCatalog ?? [],
})
: undefined,
currentFastModeStatus:
commandDefinition.key === "fast"
? formatFastModeCurrentStatus({
...(fastCommandState ??
resolveTelegramFastCommandState({
cfg: dispatch.runtimeCfg,
agentId: dispatch.route.agentId,
sessionKey: sessionKeyForMenu,
})),
})
: undefined,
});
const rows: Array<Array<{ text: string; callback_data: string }>> = [];
for (let index = 0; index < menu.choices.length; index += 2) {
rows.push(
menu.choices.slice(index, index + 2).map((choice) => ({
text: choice.label,
callback_data: buildTelegramNativeCommandCallbackData(
buildCommandTextFromArgs(commandDefinition, {
values: { [menu.arg.name]: choice.value },
}),
),
})),
);
}
const replyMarkup = buildInlineKeyboard(rows);
await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime: dispatch.runtime,
fn: () =>
dispatch.bot.api.sendMessage(dispatch.chatId, title, {
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
...dispatch.threadParams,
}),
});
return false;
}
return await dispatchTelegramBuiltinTurn({ dispatch, prompt, commandArgs });
}
@@ -0,0 +1,421 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createChannelPartialDeliveryError,
createDeferred,
dispatchReplyResult,
dispatchChannelInboundTurnMock,
executorTestMocks,
firstMockArg,
registerAndResolveStatusHandler,
requireRecord,
requireValue,
resetSessionMetaMocks,
} from "./bot-native-command-executors.test-support.js";
import type { DispatchReplyWithBufferedBlockDispatcherParams } from "./bot-native-command-executors.test-support.js";
import { createTelegramPrivateCommandContext } from "./bot-native-commands.fixture-test-support.js";
type DeliverRepliesParams = Parameters<typeof import("./bot/delivery.js").deliverReplies>[0];
const { deliveryMocks, replyMocks, sessionMocks } = executorTestMocks;
describe("Telegram native command dispatch delivery", () => {
beforeEach(resetSessionMetaMocks);
it("awaits routed session metadata persistence before command 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);
const dispatcherOptions = requireRecord(
requireRecord(
firstMockArg(
replyMocks.dispatchReplyWithBufferedBlockDispatcher,
"dispatchReplyWithBufferedBlockDispatcher",
),
"dispatch reply params",
).dispatcherOptions,
"dispatcher options",
);
expect(dispatcherOptions.beforeDeliver).toBeTypeOf("function");
});
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 = firstMockArg(deliveryMocks.deliverReplies, "deliverReplies") as
| DeliverRepliesParams
| undefined;
const deliveredPayload = deliveredCall?.replies?.[0];
if (!deliveredPayload) {
throw new Error("expected approval reply payload to be delivered");
}
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("does not emit the empty fallback when reply-payload hooks cancel a native reply", async () => {
dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => {
await plan.delivery.onDelivered?.(
{ text: "cancelled" },
{ kind: "final" },
{
visibleReplySent: false,
suppression: { reason: "cancelled_by_reply_payload_sending_hook" },
},
);
return {
admission: { kind: "dispatch" },
dispatched: true,
ctxPayload: plan.ctxPayload,
routeSessionKey: plan.route.sessionKey,
dispatchResult: {
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
},
};
});
const { handler } = registerAndResolveStatusHandler({ cfg: {} });
await handler(createTelegramPrivateCommandContext());
expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled();
});
it("does not emit the empty fallback for a message-tool-only native reply", async () => {
dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => {
plan.dispatcherOptions?.onSkip?.({}, { kind: "final", reason: "empty" });
return {
admission: { kind: "dispatch" },
dispatched: true,
ctxPayload: plan.ctxPayload,
routeSessionKey: plan.route.sessionKey,
dispatchResult: {
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
sourceReplyDeliveryMode: "message_tool_only",
},
};
});
const { handler } = registerAndResolveStatusHandler({ cfg: {} });
await handler(createTelegramPrivateCommandContext());
expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled();
});
it("retains the native fallback when message-tool-only delivery also fails", async () => {
dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => {
plan.dispatcherOptions?.onSkip?.({}, { kind: "final", reason: "empty" });
plan.delivery.onError?.(new Error("Telegram final delivery failed"), {
kind: "final",
});
return {
admission: { kind: "dispatch" },
dispatched: true,
ctxPayload: plan.ctxPayload,
routeSessionKey: plan.route.sessionKey,
dispatchResult: {
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
sourceReplyDeliveryMode: "message_tool_only",
},
};
});
const { handler } = registerAndResolveStatusHandler({ cfg: {} });
await handler(createTelegramPrivateCommandContext());
expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce();
expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [{ text: "No response generated. Please try again." }],
}),
);
});
it("emits the fallback when a non-final suppression precedes a final failure", async () => {
dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => {
await plan.delivery.onDelivered?.(
{ text: "cancelled tool reply" },
{ kind: "tool" },
{
visibleReplySent: false,
suppression: { reason: "cancelled_by_reply_payload_sending_hook" },
},
);
plan.delivery.onError?.(new Error("Telegram final delivery failed"), {
kind: "final",
});
return {
admission: { kind: "dispatch" },
dispatched: true,
ctxPayload: plan.ctxPayload,
routeSessionKey: plan.route.sessionKey,
dispatchResult: {
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
},
};
});
const { handler } = registerAndResolveStatusHandler({ cfg: {} });
await handler(createTelegramPrivateCommandContext());
expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce();
expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [{ text: "No response generated. Please try again." }],
}),
);
});
it("emits the fallback when a suppressed block reply precedes a final failure", async () => {
dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => {
await plan.delivery.onDelivered?.(
{ text: "cancelled block reply" },
{ kind: "block" },
{
visibleReplySent: false,
suppression: { reason: "empty_after_reply_payload_sending_hook" },
},
);
plan.delivery.onError?.(new Error("Telegram final delivery failed"), {
kind: "final",
});
return {
admission: { kind: "dispatch" },
dispatched: true,
ctxPayload: plan.ctxPayload,
routeSessionKey: plan.route.sessionKey,
dispatchResult: {
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
},
};
});
const { handler } = registerAndResolveStatusHandler({ cfg: {} });
await handler(createTelegramPrivateCommandContext());
expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce();
});
it("emits the fallback when a final failure precedes a later suppressed final", async () => {
dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => {
plan.delivery.onError?.(new Error("Telegram final delivery failed"), {
kind: "final",
});
await plan.delivery.onDelivered?.(
{ text: "cancelled final reply" },
{ kind: "final" },
{
visibleReplySent: false,
suppression: { reason: "cancelled_by_reply_payload_sending_hook" },
},
);
return {
admission: { kind: "dispatch" },
dispatched: true,
ctxPayload: plan.ctxPayload,
routeSessionKey: plan.route.sessionKey,
dispatchResult: {
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
},
};
});
const { handler } = registerAndResolveStatusHandler({ cfg: {} });
await handler(createTelegramPrivateCommandContext());
expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce();
});
it("preserves a suppressed final after a non-final delivery failure", async () => {
dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => {
plan.delivery.onError?.(new Error("Telegram tool delivery failed"), {
kind: "tool",
});
await plan.delivery.onDelivered?.(
{ text: "cancelled final reply" },
{ kind: "final" },
{
visibleReplySent: false,
suppression: { reason: "cancelled_by_reply_payload_sending_hook" },
},
);
return {
admission: { kind: "dispatch" },
dispatched: true,
ctxPayload: plan.ctxPayload,
routeSessionKey: plan.route.sessionKey,
dispatchResult: {
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
},
};
});
const { handler } = registerAndResolveStatusHandler({ cfg: {} });
await handler(createTelegramPrivateCommandContext());
expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled();
});
it("does not emit the fallback after a partially delivered final", async () => {
dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => {
plan.delivery.onError?.(
createChannelPartialDeliveryError(new Error("Telegram final delivery failed"), {
visibleReplySent: true,
}),
{ kind: "final" },
);
return {
admission: { kind: "dispatch" },
dispatched: true,
ctxPayload: plan.ctxPayload,
routeSessionKey: plan.route.sessionKey,
dispatchResult: {
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
},
};
});
const { handler } = registerAndResolveStatusHandler({ cfg: {} });
await handler(createTelegramPrivateCommandContext());
expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled();
});
it("retains the empty fallback for a true non-silent metadata-only native reply", async () => {
dispatchChannelInboundTurnMock.mockImplementationOnce(async (plan) => {
plan.dispatcherOptions?.onSkip?.({}, { kind: "final", reason: "empty" });
return {
admission: { kind: "dispatch" },
dispatched: true,
ctxPayload: plan.ctxPayload,
routeSessionKey: plan.route.sessionKey,
dispatchResult: {
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
},
};
});
const { handler } = registerAndResolveStatusHandler({ cfg: {} });
await handler(createTelegramPrivateCommandContext());
expect(deliveryMocks.deliverReplies).toHaveBeenCalledOnce();
expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [{ text: "No response generated. Please try again." }],
}),
);
});
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 = firstMockArg(deliveryMocks.deliverReplies, "deliverReplies") as
| DeliverRepliesParams
| undefined;
const deliveryParams = requireValue(deliveredCall, "silent error delivery params");
expect(deliveryParams.silent).toBe(true);
expect(deliveryParams.replies).toHaveLength(1);
expect(deliveryParams.replies[0]?.isError).toBe(true);
});
});
@@ -0,0 +1,432 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createConfiguredAcpTopicBinding,
createConfiguredBindingRoute,
} from "./bot-native-command-dispatch.test-support.js";
import {
activePluginRegistry,
dispatchChannelInboundTurnMock,
executorTestMocks,
expectRecordFields,
expectSendMessageCall,
expectUnauthorizedNewCommandBlocked,
firstMockArg,
registerAndResolveCommandHandler,
registerAndResolveStatusHandler,
requireRecord,
resetSessionMetaMocks,
runWithTelegramUpdateProcessingFrame,
} from "./bot-native-command-executors.test-support.js";
import {
createTelegramGroupCommandContext,
createTelegramPrivateCommandContext,
createTelegramTopicCommandContext,
} from "./bot-native-commands.fixture-test-support.js";
const { persistentBindingMocks, replyMocks, sessionBindingMocks, sessionMocks } = executorTestMocks;
describe("Telegram native command dispatch routing", () => {
beforeEach(resetSessionMetaMocks);
it("calls recordSessionMetaFromInbound after a native slash command", async () => {
const shadowHandler = vi.fn(async () => ({ text: "wrong plugin" }));
activePluginRegistry.commands.push({
pluginId: "shadow-plugin",
source: "test",
command: {
name: "status",
description: "Shadow status",
channels: ["telegram"],
requireAuth: false,
handler: shadowHandler,
},
});
const cfg: OpenClawConfig = {};
const { handler } = registerAndResolveStatusHandler({ cfg });
await handler(createTelegramPrivateCommandContext());
expect(sessionMocks.recordSessionMetaFromInbound).toHaveBeenCalledTimes(1);
expect(shadowHandler).not.toHaveBeenCalled();
const turnPlan = dispatchChannelInboundTurnMock.mock.calls[0]?.[0];
expect(turnPlan?.replyOptions?.[Symbol.for("openclaw.pluginCommandDispatch") as never]).toEqual(
{ kind: "non-plugin" },
);
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(turnPlan?.ctxPayload.CommandTargetSessionKey);
expect(turnPlan?.record?.sessionKey).toBe(turnPlan?.ctxPayload.CommandTargetSessionKey);
});
it("leaves native-command outcomes to the update middleware owner", async () => {
const { handler } = registerAndResolveStatusHandler({ cfg: {} });
const { result } = await runWithTelegramUpdateProcessingFrame(async () => {
await handler(createTelegramPrivateCommandContext());
});
expect(result).toBeUndefined();
});
it("preserves every argument on native queue command turns", async () => {
const { handler } = registerAndResolveCommandHandler({
commandName: "queue",
cfg: {},
allowFrom: ["*"],
});
await handler(createTelegramPrivateCommandContext({ match: "Can you diagnose this?" }));
expect(dispatchChannelInboundTurnMock).toHaveBeenCalledWith(
expect.objectContaining({
ctxPayload: expect.objectContaining({
Body: "/queue Can you diagnose this?",
CommandBody: "/queue Can you diagnose this?",
CommandTurn: expect.objectContaining({
kind: "native",
body: "/queue Can you diagnose this?",
}),
}),
}),
);
});
it("keeps one live config snapshot through native command execution", async () => {
const startupCfg: OpenClawConfig = { session: { store: "/tmp/startup-sessions.json" } };
const runtimeCfg: OpenClawConfig = { session: { store: "/tmp/runtime-sessions.json" } };
const { handler } = registerAndResolveStatusHandler({ cfg: startupCfg, runtimeCfg });
await handler(createTelegramPrivateCommandContext());
const dispatchCall = requireRecord(
firstMockArg(
replyMocks.dispatchReplyWithBufferedBlockDispatcher,
"dispatchReplyWithBufferedBlockDispatcher",
),
"dispatch call",
);
expect(dispatchCall.cfg).toBe(runtimeCfg);
});
it.each([
{ blockStreamingEnabled: false, expectedDisableBlockStreaming: true },
{ blockStreamingEnabled: true, expectedDisableBlockStreaming: false },
])(
"uses nested streaming.block.enabled=$blockStreamingEnabled for native command dispatch",
async ({ blockStreamingEnabled, expectedDisableBlockStreaming }) => {
const cfg = {
channels: {
telegram: {
streaming: { block: { enabled: blockStreamingEnabled } },
},
},
} satisfies OpenClawConfig;
const { handler } = registerAndResolveStatusHandler({ cfg });
await handler(createTelegramPrivateCommandContext());
const dispatchCall = requireRecord(
firstMockArg(
replyMocks.dispatchReplyWithBufferedBlockDispatcher,
"dispatchReplyWithBufferedBlockDispatcher",
),
"dispatch call",
);
expect(dispatchCall.replyOptions).toMatchObject({
disableBlockStreaming: expectedDisableBlockStreaming,
});
},
);
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(boundSessionKey);
});
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",
);
const sessionMetaCall = (
sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array<
[{ sessionKey?: string; ctx?: { From?: string; ChatType?: string } }]
>
)[0]?.[0];
expect(sessionMetaCall?.sessionKey).toBe("agent:zu:telegram:group:-1001234567890:topic:42");
expect(sessionMetaCall?.ctx?.From).toBe("telegram:group:-1001234567890:topic:42");
expect(sessionMetaCall?.ctx?.ChatType).toBe("group");
});
it("does not mark paired Telegram DM allowlist entries as native group command owners", async () => {
const { handler, sendMessage } = registerAndResolveStatusHandler({
cfg: {},
allowFrom: [],
groupAllowFrom: [],
storeAllowFrom: ["200"],
});
await handler(createTelegramTopicCommandContext());
expectUnauthorizedNewCommandBlocked(sendMessage);
});
it("authorizes paired Telegram DMs without marking them as owners", async () => {
const { handler } = registerAndResolveStatusHandler({
cfg: {},
allowFrom: [],
groupAllowFrom: [],
storeAllowFrom: ["200"],
});
await handler(createTelegramPrivateCommandContext());
const dispatchCall = (
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
[
{
ctx?: {
CommandAuthorized?: boolean;
};
},
]
>
)[0]?.[0];
expect(dispatchCall?.ctx?.CommandAuthorized).toBe(true);
expect(dispatchCall?.ctx).not.toHaveProperty("OwnerAllowFrom");
});
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");
const sessionMetaCall = (
sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array<
[{ sessionKey?: string }]
>
)[0]?.[0];
expect(sessionMetaCall?.sessionKey).toBe("agent:codex-acp:session-1");
expect(sessionBindingMocks.touch).toHaveBeenCalledWith(
"default:-1001234567890:topic:42",
undefined,
);
});
it("routes Telegram native commands through bound top-level group sessions", async () => {
sessionBindingMocks.resolveByConversation.mockReturnValue({
bindingId: "default:-1001234567890",
targetSessionKey: "agent:codex-acp:session-group",
});
const { handler } = registerAndResolveStatusHandler({
cfg: {},
allowFrom: ["200"],
groupAllowFrom: ["200"],
});
await handler(createTelegramGroupCommandContext());
expect(sessionBindingMocks.resolveByConversation).toHaveBeenCalledWith({
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890",
});
const dispatchCall = (
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
[{ ctx?: { CommandTargetSessionKey?: string; OriginatingTo?: string } }]
>
)[0]?.[0];
expect(dispatchCall?.ctx?.CommandTargetSessionKey).toBe("agent:codex-acp:session-group");
expect(dispatchCall?.ctx?.OriginatingTo).toBe("telegram:-1001234567890");
const sessionMetaCall = (
sessionMocks.recordSessionMetaFromInbound.mock.calls as unknown as Array<
[{ sessionKey?: string }]
>
)[0]?.[0];
expect(sessionMetaCall?.sessionKey).toBe("agent:codex-acp:session-group");
expect(sessionBindingMocks.touch).toHaveBeenCalledWith("default:-1001234567890", 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"],
});
await handler(createTelegramTopicCommandContext());
const dispatchCall = (
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mock.calls as unknown as Array<
[
{
ctx?: {
CommandTargetSessionKey?: string;
MessageThreadId?: number;
OriginatingTo?: string;
};
},
]
>
)[0]?.[0];
expectRecordFields(
dispatchCall?.ctx,
{
CommandTargetSessionKey: "agent:main:telegram:group:-1001234567890:topic:42",
MessageThreadId: 42,
OriginatingTo: "telegram:-1001234567890:topic:42",
},
"topic dispatch context",
);
},
);
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();
expectSendMessageCall({
sendMessage,
chatId: -1001234567890,
text: "Configured ACP binding is unavailable right now. Please try again.",
optionFields: { message_thread_id: 42 },
label: "unavailable ACP binding",
});
});
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: [],
});
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: [],
});
await handler(createTelegramTopicCommandContext());
expectUnauthorizedNewCommandBlocked(sendMessage);
});
});
@@ -0,0 +1,107 @@
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
export 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;
}
export 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,
};
}
@@ -0,0 +1,699 @@
// Telegram plugin module implements native command admission and dispatch behavior.
import type { Bot, Context } from "grammy";
import {
isChannelPartialDeliveryError,
type ChannelInboundTurnPlan,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native";
import type {
ChannelGroupPolicy,
OpenClawConfig,
TelegramAccountConfig,
} from "openclaw/plugin-sdk/config-contracts";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import {
PLUGIN_COMMAND_DISPATCH,
type PluginCommandCatalogDecision,
} from "openclaw/plugin-sdk/plugin-command-runtime";
import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js";
import { resolveTelegramAccount } from "./accounts.js";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import { normalizeDmAllowFromWithStore, resolveTelegramEffectiveDmPolicy } from "./bot-access.js";
import type { TelegramBotDeps } from "./bot-deps.js";
import type { TelegramResolvedGroupConfig } from "./bot-handlers.types.js";
import { resolveTelegramMessageTurnSettings } from "./bot-message.js";
import {
defaultTelegramNativeCommandDeps,
type TelegramNativeCommandDeps,
} from "./bot-native-command-deps.runtime.js";
import type { TelegramBotOptions } from "./bot.types.js";
import {
buildSenderName,
buildTelegramGroupFrom,
buildTelegramRoutingTarget,
buildTelegramThreadParams,
extractTelegramForumFlag,
isTelegramCommandsAllowFromConfigured,
resolveTelegramBotHasTopicsEnabled,
resolveTelegramCommandAuthorization,
resolveTelegramForumFlag,
resolveTelegramGroupAllowFromContext,
resolveTelegramMessageThreadSpec,
resolveTelegramThreadSpec,
} from "./bot/helpers.js";
import type { TelegramGetChat } from "./bot/types.js";
import {
resolveTelegramConversationRoute,
resolveTelegramTargetSession,
} from "./conversation-route.js";
import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
import {
evaluateTelegramGroupBaseAccess,
evaluateTelegramGroupPolicyAccess,
} from "./group-access.js";
import {
resolveTelegramDirectToolPolicy,
resolveTelegramGroupPromptSettings,
} from "./group-config-helpers.js";
import { resolveTelegramCommandIngressAuthorization } from "./ingress.js";
import { getTopicName, resolveTopicNameCacheScope } from "./topic-name-cache.js";
const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
const NON_PLUGIN_COMMAND_DISPATCH = Object.freeze({
kind: "non-plugin",
}) satisfies PluginCommandCatalogDecision;
const loadTelegramNativeCommandDeliveryRuntime = createLazyRuntimeModule(
() => import("./bot-native-commands.delivery.runtime.js"),
);
const loadTelegramNativeCommandRuntime = createLazyRuntimeModule(
() => import("./bot-native-commands.runtime.js"),
);
type TelegramNativeCommandRuntime = Awaited<ReturnType<typeof loadTelegramNativeCommandRuntime>>;
type TelegramNativeCommandDeliveryRuntime = Awaited<
ReturnType<typeof loadTelegramNativeCommandDeliveryRuntime>
>;
type DeliveryBaseOptions = Omit<
Parameters<TelegramNativeCommandDeliveryRuntime["deliverReplies"]>[0],
"replies" | "silent"
>;
export type TelegramCommandExecutorParams = {
botUser: Context["me"];
msg: NonNullable<Context["message"]>;
rawText: string;
bot: Bot;
runtime: RuntimeEnv;
accountId: string;
mediaMaxBytes?: number;
resolveGroupPolicy: (chatId: string | number, cfg: OpenClawConfig) => ChannelGroupPolicy;
resolveTelegramGroupConfig: (
chatId: string | number,
messageThreadId: number | undefined,
cfg: OpenClawConfig,
) => TelegramResolvedGroupConfig;
telegramDeps?: TelegramNativeCommandDeps;
opts: Pick<
TelegramBotOptions,
"token" | "botInfo" | "allowFrom" | "groupAllowFrom" | "replyToMode" | "accountAbortSignal"
>;
};
type TelegramCommandAuthResult = NonNullable<
Awaited<ReturnType<typeof resolveTelegramCommandAuth>>
>;
export type TelegramCommandDispatch = TelegramCommandExecutorParams &
TelegramCommandAuthResult & {
telegramDeps: TelegramNativeCommandDeps;
runtimeCfg: OpenClawConfig;
runtimeTelegramCfg: TelegramAccountConfig;
turnSettings: ReturnType<typeof resolveTelegramMessageTurnSettings>;
threadSpec: ReturnType<typeof resolveTelegramThreadSpec>;
threadParams: ReturnType<typeof buildTelegramThreadParams>;
route: ReturnType<typeof resolveTelegramConversationRoute>["route"];
mediaLocalRoots: readonly string[] | undefined;
targetSessionKey: string;
nativeCommandRuntime: TelegramNativeCommandRuntime;
buildDeliveryBaseOptions: (params?: {
sessionKeyForInternalHooks?: string;
policySessionKey?: string;
}) => DeliveryBaseOptions;
loadDeliveryRuntime: () => Promise<TelegramNativeCommandDeliveryRuntime>;
};
async function resolveTelegramNativeCommandThreadContext(params: {
msg: NonNullable<Context["message"]>;
bot: Bot;
}) {
const { msg, bot } = params;
const chatId = msg.chat.id;
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const getChat =
typeof bot.api.getChat === "function"
? (bot.api.getChat.bind(bot.api) as TelegramGetChat)
: undefined;
const isForum =
msg.chat.is_direct_messages === true
? false
: await resolveTelegramForumFlag({
chatId,
chatType: msg.chat.type,
isGroup,
isForum: extractTelegramForumFlag(msg.chat),
isTopicMessage: msg.is_topic_message,
getChat,
});
const threadSpec = resolveTelegramMessageThreadSpec(msg, isForum);
return {
chatId,
isGroup,
isForum,
threadSpec,
threadParams: buildTelegramThreadParams(threadSpec),
};
}
async function resolveTelegramCommandAuth(params: {
msg: NonNullable<Context["message"]>;
bot: Bot;
cfg: OpenClawConfig;
accountId: string;
telegramCfg: TelegramAccountConfig;
readChannelAllowFromStore: TelegramBotDeps["readChannelAllowFromStore"];
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
resolveGroupPolicy: TelegramCommandExecutorParams["resolveGroupPolicy"];
resolveTelegramGroupConfig: TelegramCommandExecutorParams["resolveTelegramGroupConfig"];
requireAuth: boolean;
}) {
const { msg, bot, cfg, accountId, telegramCfg, requireAuth } = params;
const { chatId, isGroup, isForum, threadSpec, threadParams } =
await resolveTelegramNativeCommandThreadContext({ msg, bot });
const senderId = msg.from?.id ? String(msg.from.id) : "";
const senderUsername = msg.from?.username ?? "";
const commandsAllowFromConfigured = isTelegramCommandsAllowFromConfigured(cfg);
const preContextCommandsAllowFromAccess = commandsAllowFromConfigured
? resolveTelegramCommandAuthorization({
cfg,
accountId,
chatId,
isGroup,
senderId,
senderUsername,
})
: null;
const groupAllowContext = await resolveTelegramGroupAllowFromContext({
cfg,
chatId,
accountId,
dmPolicy: telegramCfg.dmPolicy,
allowFrom: params.allowFrom,
senderId,
isGroup,
threadSpec,
groupAllowFrom: params.groupAllowFrom,
skipPairingStoreRead: Boolean(preContextCommandsAllowFromAccess?.isAuthorizedSender),
readChannelAllowFromStore: params.readChannelAllowFromStore,
resolveTelegramGroupConfig: params.resolveTelegramGroupConfig,
});
const {
resolvedThreadId,
dmThreadId,
storeAllowFrom,
groupConfig,
topicConfig,
groupAllowOverride,
effectiveGroupAllow,
hasGroupAllowOverride,
} = groupAllowContext;
const effectiveDmPolicy = resolveTelegramEffectiveDmPolicy({
isGroup,
groupConfig,
dmPolicy: telegramCfg.dmPolicy,
});
const requireTopic =
!isGroup && groupConfig && "requireTopic" in groupConfig ? groupConfig.requireTopic : undefined;
if (!isGroup && requireTopic === true && dmThreadId == null) {
logVerbose(`Blocked telegram command in DM ${chatId}: requireTopic=true but no topic present`);
return null;
}
const commandsAllowFromAccess = commandsAllowFromConfigured
? resolveTelegramCommandAuthorization({
cfg,
accountId,
chatId,
isGroup,
resolvedThreadId,
senderId,
senderUsername,
})
: null;
const ownerAccess = resolveTelegramCommandAuthorization({
cfg,
accountId,
chatId,
isGroup,
resolvedThreadId,
senderId,
senderUsername,
});
const sendAuthMessage = async (text: string) => {
await withTelegramApiErrorLogging({
operation: "sendMessage",
fn: () => bot.api.sendMessage(chatId, text, threadParams ?? {}),
});
return null;
};
const rejectNotAuthorized = async () =>
await sendAuthMessage("You are not authorized to use this command.");
const baseAccess = evaluateTelegramGroupBaseAccess({
isGroup,
groupConfig,
topicConfig,
hasGroupAllowOverride,
effectiveGroupAllow,
senderId,
senderUsername,
enforceAllowOverride: requireAuth,
requireSenderForAllowOverride: true,
});
if (!baseAccess.allowed) {
if (baseAccess.reason === "group-disabled") {
logVerbose(`Blocked telegram command in group ${chatId} (group disabled)`);
return null;
}
if (baseAccess.reason === "topic-disabled") {
logVerbose(
`Blocked telegram command in topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
);
return null;
}
return await rejectNotAuthorized();
}
const policyAccess = evaluateTelegramGroupPolicyAccess({
isGroup,
chatId,
cfg,
telegramCfg,
topicConfig,
groupConfig,
effectiveGroupAllow,
senderId,
senderUsername,
resolveGroupPolicy: params.resolveGroupPolicy,
enforcePolicy: true,
enforceAllowlistAuthorization: requireAuth && !commandsAllowFromConfigured,
allowEmptyAllowlistEntries: true,
requireSenderForAllowlistAuthorization: true,
checkChatAllowlist: true,
});
if (!policyAccess.allowed) {
if (policyAccess.reason === "group-policy-disabled") {
logVerbose("Blocked telegram command (groupPolicy: disabled)");
return null;
}
if (
policyAccess.reason === "group-policy-allowlist-no-sender" ||
policyAccess.reason === "group-policy-allowlist-unauthorized"
) {
return await rejectNotAuthorized();
}
if (policyAccess.reason === "group-chat-not-allowed") {
logVerbose(`Blocked telegram command in group ${chatId} (group not allowed)`);
return null;
}
}
const expandedDmAllowFrom = await expandTelegramAllowFromWithAccessGroups({
cfg,
allowFrom: groupAllowOverride ?? params.allowFrom,
accountId,
senderId,
});
const dmAllow = normalizeDmAllowFromWithStore({
allowFrom: expandedDmAllowFrom,
storeAllowFrom: isGroup ? [] : storeAllowFrom,
dmPolicy: effectiveDmPolicy,
});
const commandAuthorized = commandsAllowFromConfigured
? Boolean(commandsAllowFromAccess?.isAuthorizedSender)
: (
await resolveTelegramCommandIngressAuthorization({
accountId,
cfg,
dmPolicy: effectiveDmPolicy,
isGroup,
chatId,
resolvedThreadId,
senderId,
effectiveDmAllow: dmAllow,
effectiveGroupAllow,
ownerAccess,
eventKind: "native-command",
})
).authorized;
if (requireAuth && !commandAuthorized) {
return await rejectNotAuthorized();
}
return {
chatId,
isGroup,
isForum,
resolvedThreadId,
senderId,
senderUsername,
groupConfig,
topicConfig,
commandAuthorized,
senderIsOwner: ownerAccess.senderIsOwner,
};
}
export async function prepareTelegramCommandDispatch(
params: TelegramCommandExecutorParams & { requireAuth: boolean },
): Promise<TelegramCommandDispatch | null> {
const telegramDeps = params.telegramDeps ?? defaultTelegramNativeCommandDeps;
const runtimeCfg = telegramDeps.getRuntimeConfig();
const runtimeTelegramCfg = resolveTelegramAccount({
cfg: runtimeCfg,
accountId: params.accountId,
}).config;
const turnSettings = resolveTelegramMessageTurnSettings({
accountId: params.accountId,
cfg: runtimeCfg,
telegramCfg: runtimeTelegramCfg,
opts: params.opts,
});
const auth = await resolveTelegramCommandAuth({
msg: params.msg,
bot: params.bot,
cfg: runtimeCfg,
accountId: params.accountId,
telegramCfg: runtimeTelegramCfg,
readChannelAllowFromStore: telegramDeps.readChannelAllowFromStore,
allowFrom: turnSettings.allowFrom,
groupAllowFrom: turnSettings.groupAllowFrom,
resolveGroupPolicy: params.resolveGroupPolicy,
resolveTelegramGroupConfig: params.resolveTelegramGroupConfig,
requireAuth: params.requireAuth,
});
if (!auth) {
return null;
}
const threadSpec = resolveTelegramMessageThreadSpec(params.msg, auth.isForum);
const { route, bindingMode } = resolveTelegramConversationRoute({
cfg: runtimeCfg,
accountId: params.accountId,
chatId: auth.chatId,
isGroup: auth.isGroup,
resolvedThreadId: auth.resolvedThreadId,
replyThreadId: threadSpec.id,
senderId: auth.senderId,
topicAgentId: auth.topicConfig?.agentId,
});
const nativeCommandRuntime = await loadTelegramNativeCommandRuntime();
if (bindingMode.kind === "configured") {
const ensured = await nativeCommandRuntime.ensureConfiguredBindingRouteReady({
cfg: runtimeCfg,
bindingResolution: bindingMode.binding,
});
if (!ensured.ok) {
logVerbose(
`telegram native command: configured ACP binding unavailable for topic ${bindingMode.binding.record.conversation.conversationId}: ${ensured.error}`,
);
await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime: params.runtime,
fn: () =>
params.bot.api.sendMessage(
auth.chatId,
"Configured ACP binding is unavailable right now. Please try again.",
buildTelegramThreadParams(threadSpec) ?? {},
),
});
return null;
}
}
const mediaLocalRoots = nativeCommandRuntime.getAgentScopedMediaLocalRoots(
runtimeCfg,
route.agentId,
);
const tableMode = resolveMarkdownTableMode({
cfg: runtimeCfg,
channel: "telegram",
accountId: route.accountId,
supportsBlockTables: true,
});
const chunkMode = nativeCommandRuntime.resolveChunkMode(runtimeCfg, "telegram", route.accountId);
const targetSessionKey = resolveTelegramTargetSession({
cfg: runtimeCfg,
route,
chatId: auth.chatId,
isGroup: auth.isGroup,
senderId: auth.senderId,
dmThreadId: threadSpec.scope === "dm" ? threadSpec.id : undefined,
botHasTopicsEnabled: resolveTelegramBotHasTopicsEnabled(params.botUser),
});
const buildDeliveryBaseOptions = (keys?: {
sessionKeyForInternalHooks?: string;
policySessionKey?: string;
}): DeliveryBaseOptions => ({
cfg: runtimeCfg,
chatId: String(auth.chatId),
accountId: route.accountId,
sessionKeyForInternalHooks: keys?.sessionKeyForInternalHooks,
policySessionKey: keys?.policySessionKey,
mirrorIsGroup: auth.isGroup,
mirrorGroupId: auth.isGroup ? String(auth.chatId) : undefined,
token: params.opts.token,
runtime: params.runtime,
bot: params.bot,
mediaLocalRoots,
mediaMaxBytes: params.mediaMaxBytes,
replyToMode: turnSettings.replyToMode,
textLimit: turnSettings.textLimit,
thread: threadSpec,
tableMode,
chunkMode,
linkPreview: runtimeTelegramCfg.linkPreview,
richMessages: runtimeTelegramCfg.richMessages,
});
return {
...params,
telegramDeps,
runtimeCfg,
runtimeTelegramCfg,
turnSettings,
...auth,
threadSpec,
threadParams: buildTelegramThreadParams(threadSpec),
route,
mediaLocalRoots,
targetSessionKey,
nativeCommandRuntime,
buildDeliveryBaseOptions,
loadDeliveryRuntime: loadTelegramNativeCommandDeliveryRuntime,
};
}
export async function dispatchTelegramBuiltinTurn(params: {
dispatch: TelegramCommandDispatch;
prompt: string;
commandArgs?: import("openclaw/plugin-sdk/command-auth-native").CommandArgs;
}): Promise<boolean> {
const { dispatch } = params;
const { skillFilter, groupSystemPrompt } = resolveTelegramGroupPromptSettings({
groupConfig: dispatch.groupConfig,
topicConfig: dispatch.topicConfig,
});
const { sessionKey: commandSessionKey, commandTargetSessionKey } =
resolveNativeCommandSessionTargets({
agentId: dispatch.route.agentId,
sessionPrefix: "telegram:slash",
userId: String(dispatch.senderId || dispatch.chatId),
targetSessionKey: dispatch.targetSessionKey,
});
let topicName: string | undefined;
if (dispatch.isForum && dispatch.resolvedThreadId != null) {
try {
const storePath = resolveStorePath(dispatch.runtimeCfg.session?.store, {
agentId: dispatch.route.accountId,
});
topicName = await getTopicName(
dispatch.chatId,
dispatch.resolvedThreadId,
resolveTopicNameCacheScope(storePath),
);
} catch {
// best-effort: topic name is supplementary metadata
}
}
const conversationLabel = dispatch.isGroup
? dispatch.msg.chat.title
? `${dispatch.msg.chat.title} id:${dispatch.chatId}`
: `group:${dispatch.chatId}`
: (buildSenderName(dispatch.msg) ?? String(dispatch.senderId || dispatch.chatId));
const ctxPayload = dispatch.nativeCommandRuntime.finalizeInboundContext({
Body: params.prompt,
BodyForAgent: params.prompt,
RawBody: params.prompt,
CommandBody: params.prompt,
CommandArgs: params.commandArgs,
From: dispatch.isGroup
? buildTelegramGroupFrom(dispatch.chatId, dispatch.resolvedThreadId)
: `telegram:${dispatch.chatId}`,
To: `slash:${dispatch.senderId || dispatch.chatId}`,
ChatType: dispatch.isGroup ? "group" : "direct",
ConversationToolPolicy: dispatch.isGroup
? undefined
: resolveTelegramDirectToolPolicy({
directConfig: dispatch.groupConfig,
senderId: dispatch.senderId,
senderName: buildSenderName(dispatch.msg),
senderUsername: dispatch.senderUsername,
}),
ConversationLabel: conversationLabel,
GroupSubject: dispatch.isGroup ? (dispatch.msg.chat.title ?? undefined) : undefined,
GroupSystemPrompt:
dispatch.isGroup || (!dispatch.isGroup && dispatch.groupConfig)
? groupSystemPrompt
: undefined,
SenderName: buildSenderName(dispatch.msg),
SenderId: dispatch.senderId || undefined,
SenderUsername: dispatch.senderUsername || undefined,
Surface: "telegram",
Provider: "telegram",
MessageSid: String(dispatch.msg.message_id),
Timestamp: dispatch.msg.date ? dispatch.msg.date * 1000 : undefined,
WasMentioned: true,
CommandAuthorized: dispatch.commandAuthorized,
CommandTurn: {
kind: "native" as const,
source: "native" as const,
authorized: dispatch.commandAuthorized,
body: params.prompt,
},
CommandSource: "native" as const,
SessionKey: commandSessionKey,
AccountId: dispatch.route.accountId,
CommandTargetSessionKey: commandTargetSessionKey,
MessageThreadId: dispatch.threadSpec.id,
IsForum: dispatch.isForum,
TopicName: dispatch.isForum && topicName ? topicName : undefined,
OriginatingChannel: "telegram" as const,
OriginatingTo: buildTelegramRoutingTarget(dispatch.chatId, dispatch.threadSpec),
});
const deliveryState = { delivered: false, skippedNonSilent: 0, failedNonSilent: 0 };
let finalReplyOutcome: "accepted" | "failed" | "suppressed" | undefined;
let recordSessionMetaTask: Promise<unknown> | undefined;
const deliveryBaseOptions = dispatch.buildDeliveryBaseOptions({
sessionKeyForInternalHooks: commandSessionKey,
policySessionKey: commandTargetSessionKey,
});
const { deliverReplies } = await dispatch.loadDeliveryRuntime();
const turnPlan: ChannelInboundTurnPlan<"provider_message_sending"> = {
cfg: dispatch.runtimeCfg,
channel: "telegram",
accountId: dispatch.route.accountId,
route: { agentId: dispatch.route.agentId, sessionKey: commandSessionKey },
ctxPayload,
record: {
sessionKey: commandTargetSessionKey,
trackSessionMetaTask: (task) => {
recordSessionMetaTask = task;
},
onRecordError: (error) =>
dispatch.runtime.error?.(
danger(`telegram slash: failed updating session meta: ${String(error)}`),
),
},
afterRecord: async () => {
await recordSessionMetaTask;
},
replyPipeline: {},
dispatcherOptions: {
beforeDeliver: async (payload) => payload,
onSkip: (_payload, info) => {
if (info.reason !== "silent") {
deliveryState.skippedNonSilent += 1;
}
},
},
delivery: {
deliverWithProviderMessageSending: async (payload, info) => {
if (
shouldSuppressLocalTelegramExecApprovalPrompt({
cfg: dispatch.runtimeCfg,
accountId: dispatch.route.accountId,
payload,
})
) {
deliveryState.delivered = true;
return { visibleReplySent: false, suppression: { reason: "no_visible_result" } };
}
const targetedPayload = payload.replyToId
? payload
: { ...payload, replyToId: String(dispatch.msg.message_id) };
const result = await deliverReplies({
replies: [
info.bindPendingFinalDelivery
? info.bindPendingFinalDelivery(targetedPayload)
: targetedPayload,
],
...deliveryBaseOptions,
silent:
dispatch.runtimeTelegramCfg.silentErrorReplies === true && payload.isError === true,
onPlatformSendDispatch: info.onPlatformSendDispatch,
});
if (result.delivered) {
deliveryState.delivered = true;
}
return result.delivered
? { visibleReplySent: true }
: { visibleReplySent: false, suppression: { reason: "no_visible_result" as const } };
},
onDelivered: (_payload, info, result) => {
const reason = result?.suppression?.reason;
if (info.kind === "final" && result?.visibleReplySent) {
finalReplyOutcome = "accepted";
}
if (
info.kind === "final" &&
finalReplyOutcome !== "failed" &&
(reason === "cancelled_by_reply_payload_sending_hook" ||
reason === "empty_after_reply_payload_sending_hook")
) {
finalReplyOutcome = "suppressed";
}
},
onError: (error, info) => {
deliveryState.failedNonSilent += 1;
const partialDelivery = isChannelPartialDeliveryError(error);
if (partialDelivery) {
deliveryState.delivered = true;
logVerbose("telegram slash reply partially delivered before failure");
}
if (info.kind === "final") {
finalReplyOutcome = partialDelivery ? "accepted" : "failed";
}
dispatch.runtime.error?.(
danger(`telegram slash ${info.kind} reply failed: ${String(error)}`),
);
},
},
replyOptions: {
skillFilter,
disableBlockStreaming: (() => {
const enabled = resolveChannelStreamingBlockEnabled(dispatch.runtimeTelegramCfg);
return typeof enabled === "boolean" ? !enabled : undefined;
})(),
[PLUGIN_COMMAND_DISPATCH]: NON_PLUGIN_COMMAND_DISPATCH,
},
};
const turnResult = await (
dispatch.telegramDeps.dispatchChannelInboundTurn ??
defaultTelegramNativeCommandDeps.dispatchChannelInboundTurn
)(turnPlan);
if (
!deliveryState.delivered &&
finalReplyOutcome !== "suppressed" &&
(deliveryState.skippedNonSilent > 0 || deliveryState.failedNonSilent > 0) &&
(!turnResult.dispatched ||
turnResult.dispatchResult.sourceReplyDeliveryMode !== "message_tool_only" ||
deliveryState.failedNonSilent > 0)
) {
await deliverReplies({
replies: [{ text: EMPTY_RESPONSE_FALLBACK }],
...deliveryBaseOptions,
});
}
return false;
}
@@ -0,0 +1,580 @@
export { createChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound";
import {
createEmptyPluginRegistry,
withPluginRuntimeRegistryScope,
} from "openclaw/plugin-sdk/channel-test-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
export { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime";
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
// Telegram tests cover bot native commands.session meta plugin behavior.
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { expect, vi } from "vitest";
import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js";
import type { TelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js";
import { createConfiguredBindingRoute } from "./bot-native-command-dispatch.test-support.js";
import {
createNativeCommandTestParams,
createTelegramPrivateCommandContext,
type NativeCommandTestParams,
} from "./bot-native-commands.fixture-test-support.js";
export { runWithTelegramUpdateProcessingFrame } from "./bot-processing-outcome.js";
// Shared executor test harness; each importing suite resets the state before use.
type ResolveConfiguredBindingRouteFn =
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
type EnsureConfiguredBindingRouteReadyFn =
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
type DispatchReplyWithBufferedBlockDispatcherFn =
typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithBufferedBlockDispatcher;
export type DispatchReplyWithBufferedBlockDispatcherParams =
Parameters<DispatchReplyWithBufferedBlockDispatcherFn>[0];
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
ReturnType<DispatchReplyWithBufferedBlockDispatcherFn>
>;
type DispatchChannelInboundTurnFn =
typeof import("openclaw/plugin-sdk/channel-inbound").dispatchChannelInboundTurn;
type ResolveCommandArgMenuFn =
typeof import("openclaw/plugin-sdk/command-auth-native").resolveCommandArgMenu;
type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies;
type LoadModelCatalogFn = typeof import("openclaw/plugin-sdk/agent-runtime").loadModelCatalog;
type ResolveDefaultModelForAgentFn =
typeof import("openclaw/plugin-sdk/agent-runtime").resolveDefaultModelForAgent;
export 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(() => ({
getSessionEntry: vi.fn(),
sessionStoreEntries: vi.fn(),
recordSessionMetaFromInbound: vi.fn(),
resolveStorePath: vi.fn(),
updateSessionStoreEntry: vi.fn(),
}));
const commandAuthMocks = vi.hoisted(() => ({
resolveCommandArgMenu: vi.fn<ResolveCommandArgMenuFn>(),
}));
const agentRuntimeMocks = vi.hoisted(() => ({
loadModelCatalog: vi.fn<LoadModelCatalogFn>(async () => [
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
reasoning: true,
},
]),
resolveDefaultModelForAgent: vi.fn<ResolveDefaultModelForAgentFn>(),
}));
const pluginRuntimeMocks = vi.hoisted(() => ({
executePluginCommand: vi.fn(async (_params?: unknown) => ({ text: "ok" })),
}));
const replyMocks = vi.hoisted(() => ({
dispatchReplyWithBufferedBlockDispatcher: vi.fn<DispatchReplyWithBufferedBlockDispatcherFn>(
async () => dispatchReplyResult,
),
}));
const deliveryMocks = vi.hoisted(() => ({
deliverReplies: vi.fn<DeliverRepliesFn>(async () => ({ delivered: true })),
}));
export const dispatchChannelInboundTurnMock = vi.fn<DispatchChannelInboundTurnFn>(async (plan) => {
const recordTask = sessionMocks.recordSessionMetaFromInbound({
storePath: sessionMocks.resolveStorePath(plan.cfg.session?.store, {
agentId: plan.route.agentId,
}),
sessionKey: plan.record?.sessionKey ?? plan.ctxPayload.SessionKey ?? plan.route.sessionKey,
ctx: plan.ctxPayload,
});
const trackedRecordTask = Promise.resolve(recordTask).catch((error: unknown) =>
plan.record?.onRecordError?.(error),
);
plan.record?.trackSessionMetaTask?.(trackedRecordTask);
await plan.afterRecord?.();
const deliver = async (
payload: Parameters<
DispatchReplyWithBufferedBlockDispatcherParams["dispatcherOptions"]["deliver"]
>[0],
info: Parameters<
DispatchReplyWithBufferedBlockDispatcherParams["dispatcherOptions"]["deliver"]
>[1],
) => {
const providerInfo = {
...info,
onPlatformSendDispatch: async () => undefined,
};
const result =
"deliverWithProviderMessageSending" in plan.delivery
? await plan.delivery.deliverWithProviderMessageSending(payload, providerInfo)
: await plan.delivery.deliver(payload, info);
await plan.delivery.onDelivered?.(payload, info, result);
return result;
};
const dispatchResult = await replyMocks.dispatchReplyWithBufferedBlockDispatcher({
ctx: plan.ctxPayload,
cfg: plan.cfg,
dispatcherOptions: {
...plan.dispatcherOptions,
deliver,
onError: plan.delivery.onError,
},
replyOptions: plan.replyOptions,
});
return {
admission: { kind: "dispatch" },
dispatched: true,
ctxPayload: plan.ctxPayload,
routeSessionKey: plan.route.sessionKey,
dispatchResult,
};
});
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 })),
}));
export const executorTestMocks = {
agentRuntimeMocks,
commandAuthMocks,
conversationStoreMocks,
deliveryMocks,
persistentBindingMocks,
pluginRuntimeMocks,
replyMocks,
sessionBindingMocks,
sessionMocks,
};
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,
resolveRuntimeConversationBindingRoute: (
params: Parameters<typeof actual.resolveRuntimeConversationBindingRoute>[0],
) => {
const conversation =
"conversation" in params
? params.conversation
: {
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
};
const bindingRecord = sessionBindingMocks.resolveByConversation(conversation);
const boundSessionKey = bindingRecord?.targetSessionKey?.trim();
if (!bindingRecord || !boundSessionKey) {
return { bindingRecord: null, route: params.route };
}
sessionBindingMocks.touch(bindingRecord.bindingId, undefined);
return {
bindingRecord,
boundSessionKey,
boundAgentId: params.route.agentId,
route: {
...params.route,
sessionKey: boundSessionKey,
lastRoutePolicy: boundSessionKey === params.route.mainSessionKey ? "main" : "session",
matchedBy: "binding.channel",
},
};
},
ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady,
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("openclaw/plugin-sdk/session-store-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/session-store-runtime")>(
"openclaw/plugin-sdk/session-store-runtime",
);
return {
...actual,
getSessionEntry: sessionMocks.getSessionEntry,
sessionStoreEntries: sessionMocks.sessionStoreEntries,
resolveStorePath: sessionMocks.resolveStorePath,
updateSessionStoreEntry: sessionMocks.updateSessionStoreEntry,
};
});
vi.mock("openclaw/plugin-sdk/command-auth-native", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/command-auth-native")>(
"openclaw/plugin-sdk/command-auth-native",
);
commandAuthMocks.resolveCommandArgMenu.mockImplementation(actual.resolveCommandArgMenu);
return {
...actual,
resolveCommandArgMenu: commandAuthMocks.resolveCommandArgMenu,
};
});
vi.mock("openclaw/plugin-sdk/agent-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/agent-runtime")>(
"openclaw/plugin-sdk/agent-runtime",
);
agentRuntimeMocks.resolveDefaultModelForAgent.mockImplementation(
actual.resolveDefaultModelForAgent,
);
return {
...actual,
loadPreparedModelCatalog: agentRuntimeMocks.loadModelCatalog,
resolveDefaultModelForAgent: agentRuntimeMocks.resolveDefaultModelForAgent,
};
});
vi.mock("./bot-native-commands.runtime.js", () => {
return {
ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady,
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
getAgentScopedMediaLocalRoots,
getSessionEntry: sessionMocks.getSessionEntry,
resolveChunkMode,
resolveThreadSessionKeys,
dispatchChannelInboundTurn: dispatchChannelInboundTurnMock as unknown as NonNullable<
TelegramNativeCommandDeps["dispatchChannelInboundTurn"]
>,
};
});
vi.mock("./bot/delivery.js", () => ({
deliverReplies: deliveryMocks.deliverReplies,
}));
vi.mock("./bot/delivery.replies.js", () => ({
deliverReplies: deliveryMocks.deliverReplies,
}));
export let activePluginRegistry: ReturnType<typeof createEmptyPluginRegistry>;
type TelegramCommandHandler = (ctx: unknown) => Promise<void>;
type TelegramPluginCommandSpecs = Array<{
name: string;
description: string;
acceptsArgs?: boolean;
}>;
type TelegramLoginFlow = NonNullable<TelegramNativeCommandDeps["runModelsAuthLoginFlow"]>;
export function registerAndResolveStatusHandler(params: {
cfg: OpenClawConfig;
runtimeCfg?: OpenClawConfig;
allowFrom?: string[];
groupAllowFrom?: string[];
storeAllowFrom?: string[];
telegramCfg?: NativeCommandTestParams["telegramCfg"];
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
}): {
handler: TelegramCommandHandler;
sendMessage: ReturnType<typeof vi.fn>;
} {
const {
cfg,
runtimeCfg,
allowFrom,
groupAllowFrom,
storeAllowFrom,
telegramCfg,
resolveTelegramGroupConfig,
} = params;
return registerAndResolveCommandHandlerBase({
commandName: "status",
cfg,
runtimeCfg,
allowFrom: allowFrom ?? ["*"],
groupAllowFrom: groupAllowFrom ?? [],
storeAllowFrom,
telegramCfg,
resolveTelegramGroupConfig,
});
}
function registerAndResolveCommandHandlerBase(params: {
commandName: string;
cfg: OpenClawConfig;
runtimeCfg?: OpenClawConfig;
allowFrom: string[];
groupAllowFrom: string[];
storeAllowFrom?: string[];
telegramCfg?: NativeCommandTestParams["telegramCfg"];
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
pluginCommandSpecs?: TelegramPluginCommandSpecs;
runModelsAuthLoginFlow?: TelegramLoginFlow;
}): {
handler: TelegramCommandHandler;
sendMessage: ReturnType<typeof vi.fn>;
} {
const {
commandName,
cfg,
runtimeCfg,
allowFrom,
groupAllowFrom,
storeAllowFrom,
telegramCfg,
resolveTelegramGroupConfig,
pluginCommandSpecs,
runModelsAuthLoginFlow,
} = params;
const commandHandlers = new Map<string, TelegramCommandHandler>();
const sendMessage = vi.fn().mockResolvedValue(undefined);
const baseRuntimeCfg = runtimeCfg ?? cfg;
const commandRuntimeCfg = baseRuntimeCfg;
const telegramDeps: TelegramNativeCommandDeps = {
getRuntimeConfig: vi.fn(() => commandRuntimeCfg),
readChannelAllowFromStore: vi.fn(async () => storeAllowFrom ?? []),
dispatchChannelInboundTurn: dispatchChannelInboundTurnMock as unknown as NonNullable<
TelegramNativeCommandDeps["dispatchChannelInboundTurn"]
>,
listSkillCommandsForAgents: vi.fn(() => []),
syncTelegramMenuCommands: vi.fn(),
sendMessageTelegram: vi.fn(async (_to, text) => {
await sendMessage(100, text, {});
return { messageId: "999", chatId: "100" };
}),
...(runModelsAuthLoginFlow ? { runModelsAuthLoginFlow } : {}),
};
withPluginRuntimeRegistryScope(activePluginRegistry, () => {
for (const spec of pluginCommandSpecs ?? []) {
expect(
registerPluginCommand(`test-${spec.name}`, {
...spec,
requireAuth: true,
handler: pluginRuntimeMocks.executePluginCommand,
}),
).toEqual({ ok: true });
}
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,
telegramCfg,
resolveTelegramGroupConfig,
telegramDeps,
}),
});
});
const handler = commandHandlers.get(commandName);
if (!handler) {
throw new Error(`expected ${commandName} command handler to be registered`);
}
return { handler, sendMessage };
}
export function registerAndResolveCommandHandler(params: {
commandName: string;
cfg: OpenClawConfig;
allowFrom?: string[];
groupAllowFrom?: string[];
storeAllowFrom?: string[];
telegramCfg?: NativeCommandTestParams["telegramCfg"];
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
pluginCommandSpecs?: TelegramPluginCommandSpecs;
runModelsAuthLoginFlow?: TelegramLoginFlow;
}): {
handler: TelegramCommandHandler;
sendMessage: ReturnType<typeof vi.fn>;
} {
const {
commandName,
cfg,
allowFrom,
groupAllowFrom,
storeAllowFrom,
telegramCfg,
resolveTelegramGroupConfig,
pluginCommandSpecs,
runModelsAuthLoginFlow,
} = params;
return registerAndResolveCommandHandlerBase({
commandName,
cfg,
allowFrom: allowFrom ?? [],
groupAllowFrom: groupAllowFrom ?? [],
storeAllowFrom,
telegramCfg,
resolveTelegramGroupConfig,
pluginCommandSpecs,
runModelsAuthLoginFlow,
});
}
export function requireValue<T>(value: T | null | undefined, label: string): T {
if (value == null) {
throw new Error(`expected ${label}`);
}
return value;
}
export const requireRecord = createRequireRecord("record", "expected-label-object");
export function firstMockArg(
mockFn: ReturnType<typeof vi.fn>,
label: string,
callIndex = 0,
): unknown {
const call = mockFn.mock.calls.at(callIndex);
if (!call) {
throw new Error(`expected ${label} call ${callIndex}`);
}
return call.at(0);
}
export function expectRecordFields(
value: unknown,
expected: Record<string, unknown>,
label: string,
): Record<string, unknown> {
const record = requireRecord(value, label);
for (const [key, expectedValue] of Object.entries(expected)) {
expect(record[key], `${label}.${key}`).toEqual(expectedValue);
}
return record;
}
export function expectSendMessageCall(params: {
sendMessage: ReturnType<typeof vi.fn>;
callIndex?: number;
chatId: unknown;
text?: string;
textIncludes?: string;
optionFields?: Record<string, unknown>;
requireReplyMarkup?: boolean;
label: string;
}): Record<string, unknown> {
const call = requireValue(
params.sendMessage.mock.calls[params.callIndex ?? 0],
`${params.label} sendMessage call`,
);
expect(call[0]).toBe(params.chatId);
if (params.text !== undefined) {
expect(call[1]).toBe(params.text);
}
if (params.textIncludes !== undefined) {
expect(String(call[1])).toContain(params.textIncludes);
}
const options = params.optionFields
? expectRecordFields(call[2], params.optionFields, `${params.label} sendMessage options`)
: requireRecord(call[2], `${params.label} sendMessage options`);
if (params.requireReplyMarkup) {
requireRecord(options.reply_markup, `${params.label} reply markup`);
}
return options;
}
export function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType<typeof vi.fn>) {
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(persistentBindingMocks.resolveConfiguredBindingRoute).not.toHaveBeenCalled();
expect(persistentBindingMocks.ensureConfiguredBindingRouteReady).not.toHaveBeenCalled();
expectSendMessageCall({
sendMessage,
chatId: -1001234567890,
text: "You are not authorized to use this command.",
optionFields: { message_thread_id: 42 },
label: "unauthorized /new",
});
}
export function resetSessionMetaMocks() {
persistentBindingMocks.resolveConfiguredBindingRoute.mockClear();
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
createConfiguredBindingRoute(route, null),
);
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear();
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
commandAuthMocks.resolveCommandArgMenu.mockClear().mockImplementation(({ command, args }) => {
if (args?.raw || (args?.values && Object.keys(args.values).length > 0)) {
return null;
}
const arg = command.args?.[0];
if (!arg) {
return null;
}
if (command.key === "think") {
return {
arg,
choices: ["low", "medium", "high"].map((value) => ({ label: value, value })),
};
}
if (command.key === "fast") {
const choices = ["on", "off", "auto (30 sec)", "default", "status"];
return {
arg,
choices: choices.map((value) => ({ label: value, value })),
};
}
return null;
});
agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue([
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
reasoning: true,
},
]);
sessionMocks.getSessionEntry.mockClear().mockReturnValue(undefined);
sessionMocks.sessionStoreEntries.mockClear().mockReturnValue({});
sessionMocks.getSessionEntry.mockImplementation(
({ storePath, sessionKey }: { storePath: string; sessionKey: string }) =>
sessionMocks.sessionStoreEntries(storePath)[sessionKey],
);
sessionMocks.updateSessionStoreEntry.mockClear().mockImplementation(async (params) => {
const current = sessionMocks.sessionStoreEntries(params.storePath)[params.sessionKey];
if (!current) {
return null;
}
const patch = await params.update({ ...current });
return patch ? { ...current, ...patch } : current;
});
sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined);
sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json");
pluginRuntimeMocks.executePluginCommand.mockClear().mockResolvedValue({ text: "ok" });
activePluginRegistry = createEmptyPluginRegistry();
replyMocks.dispatchReplyWithBufferedBlockDispatcher
.mockClear()
.mockResolvedValue(dispatchReplyResult);
dispatchChannelInboundTurnMock.mockClear();
sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null);
sessionBindingMocks.touch.mockReset();
deliveryMocks.deliverReplies.mockClear().mockResolvedValue({ delivered: true });
}
activePluginRegistry = createEmptyPluginRegistry();
const { registerTelegramNativeCommands } = await import("./bot-native-commands.js");
resetSessionMetaMocks();
const warmStatusHandler = registerAndResolveStatusHandler({ cfg: {} });
await warmStatusHandler.handler(createTelegramPrivateCommandContext());
@@ -7,7 +7,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import type { ModelsAuthLoginFlowOptions } from "openclaw/plugin-sdk/provider-auth-login-flow-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { SessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js";
import { createTelegramGroupCommandContext } from "./bot-native-commands.fixture-test-support.js";
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
import {
@@ -19,12 +21,18 @@ import {
import { telegramBotInfoForTest } from "./bot.create-telegram-bot.test-support.js";
import { resetTelegramForumFlagCacheForTest } from "./bot/helpers.js";
const loginSessionMocks = vi.hoisted(() => ({
getSessionEntry: vi.fn(),
loadSessionStore: vi.fn(),
resolveStorePath: vi.fn(),
updateSessionStoreEntry: vi.fn(),
}));
vi.mock("./bot-native-commands.runtime.js", () => ({
ensureConfiguredBindingRouteReady: vi.fn(async () => ({ ok: true })),
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
getAgentScopedMediaLocalRoots: vi.fn(() => []),
getSessionEntry: vi.fn(() => undefined),
recordInboundSessionMetaSafe: vi.fn(async () => undefined),
getSessionEntry: loginSessionMocks.getSessionEntry,
resolveChunkMode: vi.fn(() => "length"),
resolveThreadSessionKeys: vi.fn(
({
@@ -39,26 +47,33 @@ vi.mock("./bot-native-commands.runtime.js", () => ({
}),
),
}));
vi.mock("openclaw/plugin-sdk/session-store-runtime", () => ({
formatSqliteSessionFileMarker: vi.fn(() => "sqlite:test"),
getSessionEntry: vi.fn(() => undefined),
resolveStorePath: vi.fn(() => "/tmp/openclaw-login-test.sqlite"),
updateSessionStoreEntry: vi.fn(async () => undefined),
}));
vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/session-store-runtime")>(
"openclaw/plugin-sdk/session-store-runtime",
);
return {
...actual,
getSessionEntry: loginSessionMocks.getSessionEntry,
resolveStorePath: loginSessionMocks.resolveStorePath,
updateSessionStoreEntry: loginSessionMocks.updateSessionStoreEntry,
};
});
type LoginFlowMock = ReturnType<typeof vi.fn>;
type TelegramLoginFlow = NonNullable<TelegramNativeCommandDeps["runModelsAuthLoginFlow"]>;
let loginAccountIndex = 0;
function registerLoginCommand(params: {
cfg: OpenClawConfig;
loginFlow: LoginFlowMock;
accountId?: string;
allowFrom?: string[];
abortSignal?: AbortSignal;
runtime?: RuntimeEnv;
}) {
const botHarness = createCommandBot();
const accountId = `login-test-${++loginAccountIndex}`;
const accountId = params.accountId ?? `login-test-${++loginAccountIndex}`;
const nativeParams = createNativeCommandTestParams(params.cfg, {
accountId,
bot: botHarness.bot,
@@ -106,6 +121,22 @@ describe("registerTelegramNativeCommands /login", () => {
beforeEach(() => {
resetTelegramForumFlagCacheForTest();
resetNativeCommandMenuMocks();
loginSessionMocks.loadSessionStore.mockReset().mockReturnValue({});
loginSessionMocks.getSessionEntry
.mockReset()
.mockImplementation(
({ storePath, sessionKey }: { storePath: string; sessionKey: string }) =>
loginSessionMocks.loadSessionStore(storePath)[sessionKey],
);
loginSessionMocks.resolveStorePath.mockReset().mockReturnValue("/tmp/openclaw-sessions.json");
loginSessionMocks.updateSessionStoreEntry.mockReset().mockImplementation(async (params) => {
const current = loginSessionMocks.loadSessionStore(params.storePath)[params.sessionKey];
if (!current) {
return null;
}
const patch = await params.update({ ...current });
return patch ? { ...current, ...patch } : current;
});
});
it("handles /login codex by sending the device code before login completes", async () => {
@@ -532,4 +563,366 @@ describe("registerTelegramNativeCommands /login", () => {
);
expect(sendMessage).toHaveBeenCalledTimes(1);
});
it("moves the target session to the profile returned by Telegram /login codex", async () => {
const finishLogin = createDeferred<void>();
loginSessionMocks.loadSessionStore.mockReturnValue({
"agent:main:main": {
authProfileOverride: "openai:owner@example.com",
sessionId: "sess-main",
updatedAt: 1,
},
});
const runModelsAuthLoginFlow = vi.fn<TelegramLoginFlow>(async (opts) => {
await opts.prompter.deviceCode?.({
title: "OpenAI Codex device code",
code: "ABCD-EFGH",
expiresInMinutes: 15,
message: "URL: https://auth.openai.com/codex/device",
});
await finishLogin.promise;
return {
providerId: "openai",
methodId: "device-code",
profiles: [
{ profileId: "openai:new-owner@example.com", provider: "openai", mode: "oauth" },
],
};
});
const { handler, sendMessage } = registerLoginCommand({
accountId: "default",
cfg: {
commands: { native: true, ownerAllowFrom: ["200"] },
} as OpenClawConfig,
allowFrom: ["200"],
loginFlow: runModelsAuthLoginFlow,
});
await handler(createPrivateCommandContext({ match: "codex", userId: 200 }));
expect(loginSessionMocks.updateSessionStoreEntry).not.toHaveBeenCalled();
finishLogin.resolve();
expect(runModelsAuthLoginFlow).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
method: "device-code",
agent: "main",
}),
);
expect(
(runModelsAuthLoginFlow.mock.calls[0]?.[0] as { profileId?: string } | undefined)?.profileId,
).toBeUndefined();
await vi.waitFor(() =>
expect(loginSessionMocks.updateSessionStoreEntry).toHaveBeenCalledWith({
sessionKey: "agent:main:main",
storePath: "/tmp/openclaw-sessions.json",
requireWriteSuccess: true,
skipMaintenance: true,
update: expect.any(Function),
}),
);
const patchUpdate = (
loginSessionMocks.updateSessionStoreEntry.mock.calls[0]?.[0] as {
update?: (entry: Record<string, unknown>) => Record<string, unknown>;
}
)?.update?.({
authProfileOverride: "openai:owner@example.com",
sessionId: "sess-main",
updatedAt: 1,
});
expect(patchUpdate).toEqual({
authProfileOverride: "openai:new-owner@example.com",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: undefined,
});
await vi.waitFor(() =>
expect(sendMessage).toHaveBeenCalledWith(
100,
"Codex login complete. Try your request again now.",
{},
),
);
});
it("moves a session created while Telegram login is pending to the returned profile", async () => {
const finishLogin = createDeferred<void>();
let sessionStore: Record<string, SessionEntry> = {};
loginSessionMocks.loadSessionStore.mockImplementation(() => sessionStore);
const runModelsAuthLoginFlow = vi.fn<TelegramLoginFlow>(async (opts) => {
await opts.prompter.deviceCode?.({
title: "OpenAI Codex device code",
code: "NEW-SESSION",
});
await finishLogin.promise;
return {
providerId: "openai",
methodId: "device-code",
profiles: [
{ profileId: "openai:new-owner@example.com", provider: "openai", mode: "oauth" },
],
};
});
const { handler, sendMessage } = registerLoginCommand({
accountId: "default",
cfg: {
commands: { native: true, ownerAllowFrom: ["200"] },
} as OpenClawConfig,
allowFrom: ["200"],
loginFlow: runModelsAuthLoginFlow,
});
await handler(createPrivateCommandContext({ match: "codex", userId: 200 }));
sessionStore = {
"agent:main:main": {
sessionId: "sess-created-during-login",
updatedAt: 2,
},
};
finishLogin.resolve();
await vi.waitFor(() =>
expect(loginSessionMocks.updateSessionStoreEntry).toHaveBeenCalledTimes(1),
);
const update = (
loginSessionMocks.updateSessionStoreEntry.mock.calls[0]?.[0] as {
update?: (entry: SessionEntry) => Partial<SessionEntry> | null;
}
)?.update;
expect(
update?.({
sessionId: "sess-created-during-login",
updatedAt: 2,
}),
).toEqual({
authProfileOverride: "openai:new-owner@example.com",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: undefined,
});
await vi.waitFor(() =>
expect(sendMessage).toHaveBeenCalledWith(
100,
"Codex login complete. Try your request again now.",
{},
),
);
});
it("preserves a later user-selected profile on a session created during Telegram login", async () => {
const finishLogin = createDeferred<void>();
let sessionStore: Record<string, SessionEntry> = {};
loginSessionMocks.loadSessionStore.mockImplementation(() => sessionStore);
const runModelsAuthLoginFlow = vi.fn<TelegramLoginFlow>(async (opts) => {
await opts.prompter.deviceCode?.({
title: "OpenAI Codex device code",
code: "LATER-USER-SELECTION",
});
await finishLogin.promise;
return {
providerId: "openai",
methodId: "device-code",
profiles: [{ profileId: "openai:login-profile", provider: "openai", mode: "oauth" }],
};
});
const { handler, sendMessage } = registerLoginCommand({
accountId: "default",
cfg: {
commands: { native: true, ownerAllowFrom: ["200"] },
} as OpenClawConfig,
allowFrom: ["200"],
loginFlow: runModelsAuthLoginFlow,
});
await handler(createPrivateCommandContext({ match: "codex", userId: 200 }));
sessionStore = {
"agent:main:main": {
authProfileOverride: "openai:later-user-profile",
authProfileOverrideSource: "user",
sessionId: "sess-created-during-login",
updatedAt: 2,
},
};
finishLogin.resolve();
await vi.waitFor(() =>
expect(sendMessage).toHaveBeenCalledWith(
100,
"Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.",
{},
),
);
expect(sessionStore["agent:main:main"]?.authProfileOverride).toBe("openai:later-user-profile");
expect(sendMessage).not.toHaveBeenCalledWith(
100,
"Codex login complete. Try your request again now.",
expect.any(Object),
);
});
it("marks a same-profile Telegram login as user-selected", async () => {
loginSessionMocks.loadSessionStore.mockReturnValue({
"agent:main:main": {
authProfileOverride: "openai:owner@example.com",
authProfileOverrideSource: "auto",
authProfileOverrideCompactionCount: 2,
sessionId: "sess-main",
updatedAt: 1,
},
});
const runModelsAuthLoginFlow = vi.fn<TelegramLoginFlow>(async () => ({
providerId: "openai",
methodId: "device-code",
profiles: [{ profileId: "openai:owner@example.com", provider: "openai", mode: "oauth" }],
}));
const { handler } = registerLoginCommand({
accountId: "default",
cfg: {
commands: { native: true, ownerAllowFrom: ["200"] },
} as OpenClawConfig,
allowFrom: ["200"],
loginFlow: runModelsAuthLoginFlow,
});
await handler(createPrivateCommandContext({ match: "codex", userId: 200 }));
const update = (
loginSessionMocks.updateSessionStoreEntry.mock.calls[0]?.[0] as {
update?: (entry: Record<string, unknown>) => Record<string, unknown>;
}
)?.update;
expect(update).toBeTypeOf("function");
expect(
update?.({
authProfileOverride: "openai:owner@example.com",
authProfileOverrideSource: "auto",
authProfileOverrideCompactionCount: 2,
sessionId: "sess-main",
updatedAt: 1,
}),
).toEqual({
authProfileOverride: "openai:owner@example.com",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: undefined,
});
expect(
update?.({
authProfileOverride: "openai:owner@example.com",
authProfileOverrideSource: "user",
sessionId: "sess-main",
updatedAt: 2,
}),
).toBeNull();
});
it("reports partial success when Telegram cannot persist the returned profile", async () => {
loginSessionMocks.loadSessionStore.mockReturnValue({
"agent:main:main": {
authProfileOverride: "openai:old-owner@example.com",
sessionId: "sess-main",
updatedAt: 1,
},
});
loginSessionMocks.updateSessionStoreEntry.mockRejectedValueOnce(new Error("write failed"));
const runModelsAuthLoginFlow = vi.fn<TelegramLoginFlow>(async () => ({
providerId: "openai",
methodId: "device-code",
profiles: [{ profileId: "openai:new-owner@example.com", provider: "openai", mode: "oauth" }],
}));
const { handler, sendMessage } = registerLoginCommand({
accountId: "default",
cfg: {
commands: { native: true, ownerAllowFrom: ["200"] },
} as OpenClawConfig,
allowFrom: ["200"],
loginFlow: runModelsAuthLoginFlow,
});
await handler(createPrivateCommandContext({ match: "codex", userId: 200 }));
expect(sendMessage).toHaveBeenCalledWith(
100,
"Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.",
{},
);
expect(sendMessage).not.toHaveBeenCalledWith(
100,
"Codex login complete. Try your request again now.",
expect.any(Object),
);
});
it("reports partial success when Telegram login returns no OpenAI profile", async () => {
const runModelsAuthLoginFlow = vi.fn<TelegramLoginFlow>(async () => ({
providerId: "openai",
methodId: "device-code",
profiles: [],
}));
const { handler, sendMessage } = registerLoginCommand({
accountId: "default",
cfg: {
commands: { native: true, ownerAllowFrom: ["200"] },
} as OpenClawConfig,
allowFrom: ["200"],
loginFlow: runModelsAuthLoginFlow,
});
await handler(createPrivateCommandContext({ match: "codex", userId: 200 }));
expect(sendMessage).toHaveBeenCalledWith(
100,
"Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.",
{},
);
expect(sendMessage).not.toHaveBeenCalledWith(
100,
"Codex login complete. Try your request again now.",
expect.any(Object),
);
});
it("revalidates an unchanged Telegram profile after device login", async () => {
const previousEntry = {
authProfileOverride: "openai:owner@example.com",
authProfileOverrideSource: "user",
sessionId: "sess-main",
updatedAt: 1,
};
loginSessionMocks.loadSessionStore.mockReturnValue({
"agent:main:main": previousEntry,
});
loginSessionMocks.updateSessionStoreEntry.mockImplementationOnce(async (params) => {
const concurrentEntry = {
...previousEntry,
authProfileOverride: "openai:concurrent-owner@example.com",
updatedAt: 2,
};
const patch = await params.update({ ...concurrentEntry });
return patch ? { ...concurrentEntry, ...patch } : concurrentEntry;
});
const runModelsAuthLoginFlow = vi.fn<TelegramLoginFlow>(async () => ({
providerId: "openai",
methodId: "device-code",
profiles: [{ profileId: "openai:owner@example.com", provider: "openai", mode: "oauth" }],
}));
const { handler, sendMessage } = registerLoginCommand({
accountId: "default",
cfg: {
commands: { native: true, ownerAllowFrom: ["200"] },
} as OpenClawConfig,
allowFrom: ["200"],
loginFlow: runModelsAuthLoginFlow,
});
await handler(createPrivateCommandContext({ match: "codex", userId: 200 }));
expect(sendMessage).toHaveBeenCalledWith(
100,
"Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.",
{},
);
expect(sendMessage).not.toHaveBeenCalledWith(
100,
"Codex login complete. Try your request again now.",
expect.any(Object),
);
});
});
@@ -0,0 +1,271 @@
// Telegram plugin module implements native Codex login behavior.
import type { CommandArgs } from "openclaw/plugin-sdk/command-auth-native";
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import { codexChannelLoginRuntime } from "openclaw/plugin-sdk/provider-auth-login-flow-runtime";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import {
resolveStorePath,
updateSessionStoreEntry,
} from "openclaw/plugin-sdk/session-store-runtime";
import { escapeHtml } from "openclaw/plugin-sdk/text-utility-runtime";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import { defaultTelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js";
import type { TelegramCommandDispatch } from "./bot-native-command-dispatch.js";
import { buildTelegramRoutingTarget } from "./bot/helpers.js";
const activeTelegramCodexLoginFlows = codexChannelLoginRuntime.createFlowRegistry();
type TelegramLoginDeviceCode = {
title: string;
code: string;
expiresInMinutes?: number;
message?: string;
};
// Telegram's inline-code entity provides the tap-to-copy affordance needed for
// short-lived device codes; plain text and literal backticks do not.
function formatTelegramLoginDeviceCode(params: TelegramLoginDeviceCode): string {
return [
`<b>${escapeHtml(params.title)}</b>`,
"",
...(params.message ? [escapeHtml(params.message)] : []),
`Code: <code>${escapeHtml(params.code)}</code>`,
...(params.expiresInMinutes
? [`Code expires in ${params.expiresInMinutes} minutes. Never share it.`]
: []),
].join("\n");
}
function resolveTelegramCodexLoginProviderInput(commandArgs: CommandArgs | undefined): string {
const providerValue = commandArgs?.values?.provider;
return typeof providerValue === "string" && providerValue.trim()
? providerValue
: (commandArgs?.raw ?? "codex");
}
function buildTelegramCodexLoginFlowKey(params: {
dispatch: TelegramCommandDispatch;
provider: string;
}): string {
const { dispatch } = params;
const threadKey =
dispatch.threadSpec.id == null
? dispatch.threadSpec.scope
: `${dispatch.threadSpec.scope}:${dispatch.threadSpec.id}`;
return [
"telegram",
dispatch.route.accountId,
String(dispatch.chatId),
threadKey,
dispatch.route.agentId,
params.provider,
].join(":");
}
export async function executeTelegramLoginCommand(params: {
dispatch: TelegramCommandDispatch;
commandArgs?: CommandArgs;
}): Promise<boolean> {
const { dispatch } = params;
const sendLoginMessage = async (text: string) => {
await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime: dispatch.runtime,
fn: () => dispatch.bot.api.sendMessage(dispatch.chatId, text, dispatch.threadParams ?? {}),
});
};
const sendLoginDeviceCode = async (deviceCode: TelegramLoginDeviceCode) => {
await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime: dispatch.runtime,
fn: () =>
dispatch.bot.api.sendMessage(dispatch.chatId, formatTelegramLoginDeviceCode(deviceCode), {
...dispatch.threadParams,
parse_mode: "HTML",
}),
});
};
const sendLoginResultMessage = async (text: string) => {
await dispatch.telegramDeps.sendMessageTelegram(
buildTelegramRoutingTarget(dispatch.chatId, dispatch.threadSpec),
text,
{
cfg: dispatch.runtimeCfg,
token: dispatch.opts.token,
accountId: dispatch.route.accountId,
},
);
};
if (
!dispatch.senderIsOwner ||
!codexChannelLoginRuntime.hasConfiguredCommandOwnerAllowlist(dispatch.runtimeCfg)
) {
await sendLoginMessage("Only a configured OpenClaw owner can start Codex login from Telegram.");
return false;
}
if (dispatch.isGroup) {
await sendLoginMessage(
"For safety, Codex login codes are only sent in a private chat with this bot. DM this bot `/login codex` to pair Codex.",
);
return true;
}
const loginProvider = codexChannelLoginRuntime.resolveProvider(
resolveTelegramCodexLoginProviderInput(params.commandArgs),
);
if (!loginProvider) {
await sendLoginMessage("Unsupported login provider. Use `/login codex`.");
return false;
}
const flowKey = buildTelegramCodexLoginFlowKey({ dispatch, provider: loginProvider });
const reservation = codexChannelLoginRuntime.reserveFlow({
flows: activeTelegramCodexLoginFlows,
flowKey,
});
if (reservation.status === "active") {
await sendLoginMessage(
"A Codex login code is already active for this Telegram chat. Complete it, or wait for it to expire before requesting a new one.",
);
return true;
}
const flowSignal = dispatch.opts.accountAbortSignal
? AbortSignal.any([reservation.record.signal, dispatch.opts.accountAbortSignal])
: reservation.record.signal;
const deviceCodeDelivered = createDeferred<void>();
let deviceCodeWasDelivered = false;
// Device-code delivery releases Telegram's serialized chat lane. The
// reservation and account signal still own polling through completion.
const completion = (async () => {
const sessionSwitchFailedMessage =
"Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.";
let terminalMessage: string;
const loginFlow =
dispatch.telegramDeps.runModelsAuthLoginFlow ??
defaultTelegramNativeCommandDeps.runModelsAuthLoginFlow;
try {
if (!loginFlow) {
throw new Error("Codex login flow is unavailable.");
}
const targetSessionEntryAtStart = dispatch.nativeCommandRuntime.getSessionEntry({
agentId: dispatch.route.agentId,
sessionKey: dispatch.targetSessionKey,
});
const loginResult = await codexChannelLoginRuntime.runDeviceLoginFlow({
runLoginFlow: loginFlow,
provider: loginProvider,
agentId: dispatch.route.agentId,
config: dispatch.runtimeCfg,
runtime: dispatch.runtime,
signal: flowSignal,
sendMessage: sendLoginMessage,
sendDeviceCode: async (deviceCode) => {
flowSignal.throwIfAborted();
await sendLoginDeviceCode(deviceCode);
flowSignal.throwIfAborted();
deviceCodeWasDelivered = true;
deviceCodeDelivered.resolve();
},
unsupportedPromptMessage: "Telegram /login supports only fixed Codex device-code auth.",
});
flowSignal.throwIfAborted();
const nextProfileId = loginResult.profiles.find(
(profile) => profile.provider === loginProvider,
)?.profileId;
terminalMessage = "Codex login complete. Try your request again now.";
if (!nextProfileId) {
terminalMessage = sessionSwitchFailedMessage;
} else {
const storePath = resolveStorePath(dispatch.runtimeCfg.session?.store, {
agentId: dispatch.route.agentId,
});
let entryObserved = false;
let adoptionAllowed = false;
try {
const persisted = await updateSessionStoreEntry({
sessionKey: dispatch.targetSessionKey,
storePath,
requireWriteSuccess: true,
skipMaintenance: true,
update: (entry) => {
entryObserved = true;
const source =
entry.authProfileOverrideSource ??
(typeof entry.authProfileOverrideCompactionCount === "number"
? "auto"
: entry.authProfileOverride
? "user"
: undefined);
if (
flowSignal.aborted ||
(targetSessionEntryAtStart
? entry.sessionId !== targetSessionEntryAtStart.sessionId ||
entry.authProfileOverride !== targetSessionEntryAtStart.authProfileOverride ||
entry.authProfileOverrideSource !==
targetSessionEntryAtStart.authProfileOverrideSource ||
entry.authProfileOverrideCompactionCount !==
targetSessionEntryAtStart.authProfileOverrideCompactionCount
: source === "user" && entry.authProfileOverride !== nextProfileId)
) {
return null;
}
adoptionAllowed = true;
return entry.authProfileOverride !== nextProfileId ||
entry.authProfileOverrideSource !== "user" ||
entry.authProfileOverrideCompactionCount !== undefined
? {
authProfileOverride: nextProfileId,
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: undefined,
}
: null;
},
});
flowSignal.throwIfAborted();
if (
entryObserved &&
(!adoptionAllowed ||
!persisted ||
persisted.authProfileOverride !== nextProfileId ||
persisted.authProfileOverrideSource !== "user" ||
persisted.authProfileOverrideCompactionCount !== undefined)
) {
terminalMessage = sessionSwitchFailedMessage;
}
} catch (error) {
flowSignal.throwIfAborted();
dispatch.runtime.error?.(
danger(
`telegram /login codex completed but failed to update session auth profile: ${String(
error,
)}`,
),
);
terminalMessage = sessionSwitchFailedMessage;
}
}
} catch (error) {
if (flowSignal.aborted) {
return;
}
dispatch.runtime.error?.(danger(`telegram /login codex failed: ${String(error)}`));
terminalMessage = "Codex login did not complete. Send `/login codex` to request a new code.";
}
if (flowSignal.aborted) {
return;
}
try {
await sendLoginResultMessage(terminalMessage);
} catch (error) {
dispatch.runtime.error?.(
danger(`telegram /login codex result notification failed: ${String(error)}`),
);
}
})().finally(() => {
codexChannelLoginRuntime.releaseFlow({
flows: activeTelegramCodexLoginFlows,
flowKey,
record: reservation.record,
});
});
await Promise.race([deviceCodeDelivered.promise, completion]);
return deviceCodeWasDelivered;
}
@@ -0,0 +1,692 @@
import {
createEmptyPluginRegistry,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/channel-test-helpers";
// Telegram tests cover bot native commands plugin behavior.
import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts";
import { clearPluginCommands, registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime";
import type { SessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createTelegramTopicCommandContext } from "./bot-native-commands.fixture-test-support.js";
import {
createCommandBot,
createNativeCommandTestParams,
createPrivateCommandContext,
deliverReplies,
editMessageTelegram,
emitTelegramMessageSentHooks,
resetNativeCommandMenuMocks,
} from "./bot-native-commands.menu-test-support.js";
import { resetTelegramForumFlagCacheForTest } from "./bot/helpers.js";
const pluginSessionMocks = vi.hoisted(() => ({
getSessionEntry: vi.fn(),
resolveStorePath: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/session-store-runtime")>(
"openclaw/plugin-sdk/session-store-runtime",
);
return {
...actual,
getSessionEntry: pluginSessionMocks.getSessionEntry,
resolveStorePath: pluginSessionMocks.resolveStorePath,
};
});
type CommandBotHarness = ReturnType<typeof createCommandBot>;
type PlugCommandHarnessParams = {
botHarness?: CommandBotHarness;
cfg?: OpenClawConfig;
command?: Record<string, unknown>;
acceptsArgs?: boolean;
args?: string;
result?: Record<string, unknown>;
registerOverrides?: Partial<Parameters<typeof registerTelegramNativeCommands>[0]>;
};
const pluginCommandHandler = vi.fn(async (_ctx: Record<string, unknown>) => ({ text: "ok" }));
function registerTestPluginCommand(params: {
name: string;
description: string;
acceptsArgs?: boolean;
command?: Record<string, unknown>;
result?: Record<string, unknown>;
}) {
expect(
registerPluginCommand(`test-${params.name}`, {
name: params.name,
description: params.description,
acceptsArgs: params.acceptsArgs,
requireAuth: false,
...params.command,
handler: async (ctx) => {
const handlerResult = await pluginCommandHandler(ctx as unknown as Record<string, unknown>);
return params.result ?? handlerResult;
},
}),
).toEqual({ ok: true });
}
function primePlugCommand(params: PlugCommandHarnessParams = {}) {
registerTestPluginCommand({
name: "plug",
description: "Plugin command",
acceptsArgs: params.acceptsArgs ?? true,
command: params.command,
result: params.result,
});
}
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");
if (!handler) {
throw new Error("expected plug command handler to be registered");
}
return {
...botHarness,
handler,
};
}
function firstCall(mock: { mock: { calls: Array<Array<unknown>> } }) {
const call = mock.mock.calls.at(0);
if (!call) {
throw new Error("expected first mock call");
}
return call;
}
function firstCallArg(mock: { mock: { calls: Array<Array<unknown>> } }, argIndex = 0) {
const arg = firstCall(mock)[argIndex];
if (!arg || typeof arg !== "object") {
throw new Error(`expected first mock call arg ${argIndex}`);
}
return arg as Record<string, unknown>;
}
function firstDeliverRepliesParams() {
return firstCallArg(deliverReplies as unknown as { mock: { calls: Array<Array<unknown>> } });
}
function firstExecutePluginCommandParams() {
return firstCallArg(
pluginCommandHandler as unknown as {
mock: { calls: Array<Array<unknown>> };
},
);
}
function replyAt(params: Record<string, unknown>, index = 0) {
const replies = params.replies as Array<Record<string, unknown>> | undefined;
const reply = replies?.[index];
if (!reply) {
throw new Error(`expected reply ${index}`);
}
return reply;
}
resetPluginRuntimeStateForTest();
setActivePluginRegistry(createEmptyPluginRegistry());
const { registerTelegramNativeCommands } = await import("./bot-native-commands.js");
registerTelegramNativeCommands(createNativeCommandTestParams({}));
describe("registerTelegramNativeCommands", () => {
beforeEach(() => {
resetTelegramForumFlagCacheForTest();
resetNativeCommandMenuMocks();
resetPluginRuntimeStateForTest();
setActivePluginRegistry(createEmptyPluginRegistry());
clearPluginCommands();
pluginCommandHandler.mockReset().mockResolvedValue({ text: "ok" });
pluginSessionMocks.getSessionEntry.mockReset().mockReturnValue(undefined);
pluginSessionMocks.resolveStorePath.mockReset().mockReturnValue("/tmp/openclaw-sessions.json");
});
it("passes agent-scoped media roots for plugin command replies with media", async () => {
const mediaMaxBytes = 50 * 1024 * 1024;
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",
},
registerOverrides: {
mediaMaxBytes,
} as Partial<Parameters<typeof registerTelegramNativeCommands>[0]>,
});
await handler(createPrivateCommandContext());
const deliverParams = firstDeliverRepliesParams();
expect(deliverParams.mediaMaxBytes).toBe(mediaMaxBytes);
const mediaLocalRoots = deliverParams.mediaLocalRoots as Array<string> | undefined;
expect(mediaLocalRoots?.some((root) => /[\\/]\.openclaw[\\/]workspace-work$/.test(root))).toBe(
true,
);
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
});
it("delivers presentation-only tables returned by plugin commands", async () => {
const presentation = {
title: "FY25 outlook",
blocks: [
{
type: "table",
caption: "Pipeline",
headers: ["Account", "Stage"],
rows: [["Acme", "Won"]],
},
],
};
const { handler } = registerPlugCommand({ result: { presentation } });
await handler(createPrivateCommandContext());
expect(replyAt(firstDeliverRepliesParams())).toMatchObject({ presentation });
expect(replyAt(firstDeliverRepliesParams()).text).toBeUndefined();
});
it("delivers Telegram button-only plugin command replies", async () => {
const buttons = [[{ text: "Retry", callback_data: "retry" }]];
const { handler } = registerPlugCommand({
result: { channelData: { telegram: { buttons } } },
});
await handler(createPrivateCommandContext());
expect(replyAt(firstDeliverRepliesParams())).toEqual({
channelData: { telegram: { buttons } },
});
});
it("targets reaction-only plugin replies at the invoking command message", async () => {
const { handler } = registerPlugCommand({
result: { channelData: { telegram: { reaction: { emoji: "🔥" } } } },
});
await handler(createPrivateCommandContext({ messageId: 321 }));
const deliveryParams = firstDeliverRepliesParams();
expect(replyAt(deliveryParams)).toEqual({
replyToId: "321",
channelData: { telegram: { reaction: { emoji: "🔥" } } },
});
expect(deliveryParams.replyToMode).toBe("all");
});
it("uses the empty-response fallback for unrelated metadata-only plugin results", async () => {
const { handler } = registerPlugCommand({
result: { channelData: { plugin: { traceId: "trace-1" } } },
});
await handler(createPrivateCommandContext());
expect(replyAt(firstDeliverRepliesParams())).toEqual({
text: "No response generated. Please try again.",
});
});
it("replies to unmatched plugin commands in the originating forum topic", async () => {
const { handler, sendMessage } = registerPlugCommand({ acceptsArgs: false });
await handler({
match: "unexpected",
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" },
},
});
const sendMessageCall = firstCall(sendMessage);
expect(sendMessageCall[0]).toBe(-1001234567890);
expect(sendMessageCall[1]).toBe("Command not found.");
expect(
(sendMessageCall[2] as { message_thread_id?: number } | undefined)?.message_thread_id,
).toBe(77);
});
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",
}),
);
const sendMessageCall = firstCall(sendMessage);
expect(sendMessageCall[0]).toBe(100);
expect(String(sendMessageCall[1])).toContain("Running this command now");
expect(sendMessageCall[2]).toBeUndefined();
const editCall = firstCall(
editMessageTelegram as unknown as { mock: { calls: Array<Array<unknown>> } },
);
expect(editCall[0]).toBe(100);
expect(editCall[1]).toBe(999);
expect(String(editCall[2])).toContain("Command completed successfully");
expect((editCall[3] as { accountId?: string } | undefined)?.accountId).toBe("default");
expect(deleteMessage).not.toHaveBeenCalled();
expect(deliverReplies).not.toHaveBeenCalled();
const hookParams = firstCallArg(
emitTelegramMessageSentHooks as unknown as { mock: { calls: Array<Array<unknown>> } },
);
expect(hookParams.chatId).toBe("100");
expect(hookParams.content).toBe("Command completed successfully");
expect(hookParams.messageId).toBe(999);
expect(hookParams.success).toBe(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);
const editCall = firstCall(
editMessageTelegram as unknown as { mock: { calls: Array<Array<unknown>> } },
);
expect(editCall[0]).toBe(100);
expect(editCall[1]).toBe(999);
expect(editCall[2]).toBe("Choose an option");
expect((editCall[3] as { buttons?: unknown } | undefined)?.buttons).toEqual([
[{ text: "Approve", callback_data: "approve" }],
]);
expect(deleteMessage).not.toHaveBeenCalled();
expect(deliverReplies).not.toHaveBeenCalled();
});
it("delivers reactions after cleaning up a metadata-driven progress placeholder", async () => {
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
args: "now",
command: {
nativeProgressMessages: { telegram: "Working on it..." },
},
result: {
text: "Command completed successfully",
channelData: { telegram: { reaction: { emoji: "🔥" } } },
},
});
await handler(createPrivateCommandContext({ match: "now", messageId: 321 }));
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
expect(editMessageTelegram).not.toHaveBeenCalled();
expect(deleteMessage).toHaveBeenCalledWith(100, 999);
const deliveryParams = firstDeliverRepliesParams();
expect(deliveryParams.replyToMode).toBe("all");
expect(replyAt(deliveryParams)).toEqual({
text: "Command completed successfully",
replyToId: "321",
channelData: { telegram: { reaction: { emoji: "🔥" } } },
});
});
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(replyAt(firstDeliverRepliesParams()).mediaUrl).toBe("/tmp/render.png");
});
it("falls back to a normal reply when a progress result has presentation controls", async () => {
const presentation = {
blocks: [
{
type: "buttons",
buttons: [{ label: "Approve", action: { type: "callback", value: "/approve yes" } }],
},
],
};
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
args: "now",
command: {
nativeProgressMessages: { telegram: "Working on it..." },
},
result: {
text: "Approval required",
presentation,
},
});
await handler(createPrivateCommandContext({ match: "now" }));
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
expect(editMessageTelegram).not.toHaveBeenCalled();
expect(deleteMessage).toHaveBeenCalledWith(100, 999);
expect(replyAt(firstDeliverRepliesParams())).toMatchObject({
text: "Approval required",
presentation,
});
});
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(replyAt(firstDeliverRepliesParams()).text).toBe("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 deliverParams = firstDeliverRepliesParams();
expect(deliverParams.silent).toBe(true);
expect(replyAt(deliverParams).isError).toBe(true);
});
it("uses rich messages for plugin command replies when enabled", async () => {
const { handler } = registerPlugCommand({
cfg: {
channels: {
telegram: {
richMessages: true,
},
},
},
registerOverrides: {
telegramCfg: { richMessages: true } as TelegramAccountConfig,
},
});
await handler(createPrivateCommandContext());
expect(firstDeliverRepliesParams().richMessages).toBe(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" },
},
});
const commandParams = firstExecutePluginCommandParams();
expect(commandParams.channel).toBe("telegram");
expect(commandParams.accountId).toBe("default");
expect(commandParams.from).toBe("telegram:group:-1001234567890:topic:77");
expect(commandParams.to).toBe("telegram:-1001234567890");
expect(commandParams.messageThreadId).toBe(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);
const commandParams = firstExecutePluginCommandParams();
expect(commandParams.accountId).toBe("default");
expect(commandParams.from).toBe("telegram:group:-1001234567890:topic:1");
expect(commandParams.to).toBe("telegram:-1001234567890");
expect(commandParams.messageThreadId).toBe(1);
});
it("forwards direct-message binding context to Telegram plugin commands", async () => {
const { handler } = registerPlugCommand();
await handler(createPrivateCommandContext({ chatId: 100, userId: 200 }));
const commandParams = firstExecutePluginCommandParams();
expect(commandParams.channel).toBe("telegram");
expect(commandParams.accountId).toBe("default");
expect(commandParams.from).toBe("telegram:100");
expect(commandParams.to).toBe("telegram:100");
expect(commandParams.messageThreadId).toBeUndefined();
});
it("suppresses the fallback reply when a plugin command returns suppressReply: true", async () => {
const { handler } = registerPlugCommand({
result: { suppressReply: true },
});
await handler(createPrivateCommandContext());
expect(deliverReplies).not.toHaveBeenCalled();
expect(editMessageTelegram).not.toHaveBeenCalled();
});
it("uses bot topic capability for Telegram plugin command DM topic session keys", async () => {
const { handler } = registerPlugCommand();
await handler({
...createPrivateCommandContext({ chatId: 100, userId: 200, threadId: 77 }),
me: { has_topics_enabled: true },
});
const commandParams = firstExecutePluginCommandParams();
expect(commandParams.sessionKey).toBe("agent:main:main:thread:100:77");
const deliveryParams = firstDeliverRepliesParams();
expect(deliveryParams.sessionKeyForInternalHooks).toBe("agent:main:main:thread:100:77");
});
it("passes persisted topic session identity to plugin commands", async () => {
pluginSessionMocks.getSessionEntry.mockReturnValue({
authProfileOverride: "openai:owner@example.com",
sessionId: "sess-topic",
updatedAt: 1,
});
const { handler } = registerPlugCommand({
cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig,
});
await handler(
createTelegramTopicCommandContext({ match: "bind --cwd /tmp/work", threadId: 42 }),
);
expect(firstExecutePluginCommandParams()).toEqual(
expect.objectContaining({
sessionKey: "agent:main:telegram:group:-1001234567890:topic:42",
sessionId: "sess-topic",
messageThreadId: 42,
}),
);
});
it.each([
{
name: "creates a SQLite marker when the entry has no file",
entry: { sessionId: "sess-main", updatedAt: 1 } satisfies SessionEntry,
},
{
name: "keeps the canonical SQLite marker",
entry: {
sessionId: "sess-main",
sessionFile: "sqlite:main:sess-main:/tmp/openclaw-sessions.json",
updatedAt: 1,
} satisfies SessionEntry,
},
{
name: "replaces a stale legacy transcript path",
entry: {
sessionId: "sess-main",
sessionFile: "sess-main.jsonl",
updatedAt: 1,
} satisfies SessionEntry,
},
])("$name", async ({ entry }) => {
pluginSessionMocks.getSessionEntry.mockReturnValue(entry);
const { handler } = registerPlugCommand();
await handler(createPrivateCommandContext({ match: "status" }));
expect(firstExecutePluginCommandParams()).toEqual(
expect.objectContaining({
sessionKey: "agent:main:main",
sessionId: "sess-main",
sessionFile: "sqlite:main:sess-main:/tmp/openclaw-sessions.json",
}),
);
});
it("sends an empty-response fallback when a plugin command returns undefined", async () => {
pluginCommandHandler.mockResolvedValueOnce(undefined as never);
const { handler } = registerPlugCommand();
await handler(createPrivateCommandContext({ match: "status" }));
expect(replyAt(firstDeliverRepliesParams())).toEqual({
text: "No response generated. Please try again.",
});
});
});
@@ -0,0 +1,316 @@
// Telegram plugin module implements native plugin command behavior.
import { randomUUID } from "node:crypto";
import type { Bot, Context } from "grammy";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { PluginCommandNativeCandidate } from "openclaw/plugin-sdk/plugin-command-runtime";
import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload";
import {
formatSqliteSessionFileMarker,
getSessionEntry,
resolveStorePath,
} from "openclaw/plugin-sdk/session-store-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import {
prepareTelegramCommandDispatch,
type TelegramCommandExecutorParams,
} from "./bot-native-command-dispatch.js";
import {
buildTelegramRoutingTarget,
buildTelegramGroupFrom,
buildTelegramThreadParams,
extractTelegramForumFlag,
resolveTelegramForumFlag,
resolveTelegramMessageThreadSpec,
} from "./bot/helpers.js";
import type { TelegramGetChat } from "./bot/types.js";
import type { TelegramInlineButtons } from "./button-types.js";
import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
import { buildInlineKeyboard } from "./inline-keyboard.js";
import { recordSentMessage } from "./sent-message-cache.js";
const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
type TelegramNativeReplyPayload = import("openclaw/plugin-sdk/plugin-entry").PluginCommandResult;
type TelegramNativeReplyChannelData = {
buttons?: TelegramInlineButtons;
pin?: boolean;
reaction?: { emoji?: unknown };
};
function resolveTelegramNativeReplyChannelData(
result: TelegramNativeReplyPayload,
): TelegramNativeReplyChannelData | undefined {
return result.channelData?.telegram as TelegramNativeReplyChannelData | undefined;
}
function normalizeTelegramNativeReplyPayload(
result: TelegramNativeReplyPayload | null | undefined,
): TelegramNativeReplyPayload {
return result && typeof result === "object" ? result : {};
}
function hasTelegramNativeReplyReaction(result: TelegramNativeReplyPayload): boolean {
const reactionEmoji = resolveTelegramNativeReplyChannelData(result)?.reaction?.emoji;
return typeof reactionEmoji === "string" && reactionEmoji.trim().length > 0;
}
function hasRenderableTelegramNativeReplyPayload(result: TelegramNativeReplyPayload): boolean {
const { channelData: _channelData, ...portableContent } = result;
if (hasOutboundReplyContent(portableContent, { trimText: true })) {
return true;
}
const telegramData = resolveTelegramNativeReplyChannelData(result);
return Boolean(
buildInlineKeyboard(telegramData?.buttons) || hasTelegramNativeReplyReaction(result),
);
}
function isEditableTelegramProgressResult(result: TelegramNativeReplyPayload): boolean {
const telegramData = resolveTelegramNativeReplyChannelData(result);
return Boolean(
typeof result.text === "string" &&
result.text.trim() &&
!result.mediaUrl &&
(!result.mediaUrls || result.mediaUrls.length === 0) &&
!result.presentation &&
!result.interactive &&
!result.btw &&
!hasTelegramNativeReplyReaction(result) &&
telegramData?.pin !== true,
);
}
async function cleanupTelegramProgressPlaceholder(params: {
bot: Bot;
chatId: number;
progressMessageId?: number;
runtime: TelegramCommandExecutorParams["runtime"];
}): Promise<void> {
if (params.progressMessageId == null) {
return;
}
try {
await withTelegramApiErrorLogging({
operation: "deleteMessage",
runtime: params.runtime,
fn: () => params.bot.api.deleteMessage(params.chatId, params.progressMessageId!),
});
} catch {
// Best-effort cleanup before fallback or suppression exits.
}
}
async function resolveTelegramPluginThreadParams(params: {
msg: NonNullable<Context["message"]>;
bot: Bot;
}) {
const isGroup = params.msg.chat.type === "group" || params.msg.chat.type === "supergroup";
const getChat =
typeof params.bot.api.getChat === "function"
? (params.bot.api.getChat.bind(params.bot.api) as TelegramGetChat)
: undefined;
const isForum =
params.msg.chat.is_direct_messages === true
? false
: await resolveTelegramForumFlag({
chatId: params.msg.chat.id,
chatType: params.msg.chat.type,
isGroup,
isForum: extractTelegramForumFlag(params.msg.chat),
isTopicMessage: params.msg.is_topic_message,
getChat,
});
return buildTelegramThreadParams(resolveTelegramMessageThreadSpec(params.msg, isForum));
}
async function resolveTelegramCommandTranscriptContext(params: {
cfg: OpenClawConfig;
agentId: string;
sessionKey: string;
}): Promise<{ sessionId?: string; sessionFile?: string; authProfileId?: string }> {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
return {};
}
try {
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId });
const entry = getSessionEntry({ agentId: params.agentId, sessionKey, storePath });
const sessionId = entry?.sessionId?.trim() || randomUUID();
const sessionFile = formatSqliteSessionFileMarker({
agentId: params.agentId,
sessionId,
storePath,
});
const authProfileId = normalizeOptionalString(entry?.authProfileOverride);
return { sessionId, sessionFile, ...(authProfileId ? { authProfileId } : {}) };
} catch {
return {};
}
}
export async function executeTelegramPluginCommand(
params: TelegramCommandExecutorParams & {
commandName: string;
candidate: PluginCommandNativeCandidate;
},
): Promise<void> {
const commandBody = `/${params.commandName}${params.rawText ? ` ${params.rawText}` : ""}`;
const pluginCommandDispatch = params.candidate.prepareDispatch(params.rawText);
if (pluginCommandDispatch.kind === "non-plugin") {
await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime: params.runtime,
fn: async () =>
await params.bot.api.sendMessage(
params.msg.chat.id,
"Command not found.",
(await resolveTelegramPluginThreadParams(params)) ?? {},
),
});
return;
}
const dispatch = await prepareTelegramCommandDispatch({
...params,
requireAuth: params.candidate.requireAuth,
});
if (!dispatch) {
return;
}
const targetSessionEntry = dispatch.nativeCommandRuntime.getSessionEntry({
agentId: dispatch.route.agentId,
sessionKey: dispatch.targetSessionKey,
});
const from = dispatch.isGroup
? buildTelegramGroupFrom(dispatch.chatId, dispatch.threadSpec.id)
: `telegram:${dispatch.chatId}`;
const to =
dispatch.threadSpec.scope === "direct-messages"
? buildTelegramRoutingTarget(dispatch.chatId, dispatch.threadSpec)
: `telegram:${dispatch.chatId}`;
const { deliverReplies, emitTelegramMessageSentHooks } = await dispatch.loadDeliveryRuntime();
let progressMessageId: number | undefined;
if (params.candidate.progressMessage) {
try {
const sent = await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime: dispatch.runtime,
fn: () =>
dispatch.bot.api.sendMessage(
dispatch.chatId,
params.candidate.progressMessage!,
buildTelegramThreadParams(dispatch.threadSpec),
),
});
const maybeMessageId = (sent as { message_id?: unknown } | undefined)?.message_id;
if (typeof maybeMessageId === "number") {
progressMessageId = maybeMessageId;
}
} catch {
// Fall back to the normal final reply path if the placeholder send fails.
}
}
const transcriptContext = await resolveTelegramCommandTranscriptContext({
cfg: dispatch.runtimeCfg,
agentId: dispatch.route.agentId,
sessionKey: dispatch.targetSessionKey,
});
const result = normalizeTelegramNativeReplyPayload(
await pluginCommandDispatch.execute({
senderId: dispatch.senderId,
channel: "telegram",
isAuthorizedSender: dispatch.commandAuthorized,
senderIsOwner: dispatch.senderIsOwner,
agentId: dispatch.route.agentId,
sessionKey: dispatch.targetSessionKey,
sessionId: transcriptContext.sessionId,
sessionFile: transcriptContext.sessionFile,
authProfileId: transcriptContext.authProfileId ?? targetSessionEntry?.authProfileOverride,
commandBody,
config: dispatch.runtimeCfg,
from,
to,
accountId: dispatch.accountId,
messageThreadId: dispatch.threadSpec.id,
}),
);
const suppressReply =
shouldSuppressLocalTelegramExecApprovalPrompt({
cfg: dispatch.runtimeCfg,
accountId: dispatch.route.accountId,
payload: result,
}) || result.suppressReply === true;
if (suppressReply) {
await cleanupTelegramProgressPlaceholder({
bot: dispatch.bot,
chatId: dispatch.chatId,
progressMessageId,
runtime: dispatch.runtime,
});
return;
}
const hasReaction = hasTelegramNativeReplyReaction(result);
const deliverableResult: TelegramNativeReplyPayload = hasRenderableTelegramNativeReplyPayload(
result,
)
? hasReaction && !normalizeOptionalString(result.replyToId)
? { ...result, replyToId: String(dispatch.msg.message_id) }
: result
: { text: EMPTY_RESPONSE_FALLBACK };
const progressResultText =
typeof deliverableResult.text === "string" && deliverableResult.text.trim().length > 0
? deliverableResult.text
: null;
const telegramResultData = resolveTelegramNativeReplyChannelData(deliverableResult);
if (
progressMessageId != null &&
dispatch.telegramDeps.editMessageTelegram &&
progressResultText &&
isEditableTelegramProgressResult(deliverableResult)
) {
try {
await dispatch.telegramDeps.editMessageTelegram(
dispatch.chatId,
progressMessageId,
progressResultText,
{
cfg: dispatch.runtimeCfg,
accountId: dispatch.route.accountId,
textMode: "markdown",
linkPreview: dispatch.runtimeTelegramCfg.linkPreview,
buttons: telegramResultData?.buttons,
},
);
recordSentMessage(dispatch.chatId, progressMessageId, dispatch.runtimeCfg);
emitTelegramMessageSentHooks({
sessionKeyForInternalHooks: dispatch.targetSessionKey,
chatId: String(dispatch.chatId),
accountId: dispatch.route.accountId,
content: progressResultText,
success: true,
messageId: progressMessageId,
isGroup: dispatch.isGroup,
groupId: dispatch.isGroup ? String(dispatch.chatId) : undefined,
});
return;
} catch {
// Fall through to cleanup + normal delivered reply if editing fails.
}
}
await cleanupTelegramProgressPlaceholder({
bot: dispatch.bot,
chatId: dispatch.chatId,
progressMessageId,
runtime: dispatch.runtime,
});
await deliverReplies({
replies: [deliverableResult],
...dispatch.buildDeliveryBaseOptions({
sessionKeyForInternalHooks: dispatch.targetSessionKey,
policySessionKey: dispatch.targetSessionKey,
}),
...(hasReaction ? { replyToMode: "all" as const } : {}),
silent:
dispatch.runtimeTelegramCfg.silentErrorReplies === true && deliverableResult.isError === true,
});
}
@@ -1,5 +1,4 @@
// Telegram plugin module implements bot native commandselivery behavior.
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
import { deliverReplies, emitTelegramMessageSentHooks } from "./bot/delivery.js";
export { createChannelMessageReplyPipeline, deliverReplies, emitTelegramMessageSentHooks };
export { deliverReplies, emitTelegramMessageSentHooks };
@@ -1,8 +1,5 @@
// Telegram plugin module implements bot native commands behavior.
export {
ensureConfiguredBindingRouteReady,
recordInboundSessionMetaSafe,
} from "openclaw/plugin-sdk/conversation-runtime";
export { ensureConfiguredBindingRouteReady } from "openclaw/plugin-sdk/conversation-runtime";
export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
export {
finalizeInboundContext,
File diff suppressed because it is too large Load Diff
@@ -13,9 +13,6 @@ import {
createCommandBot,
createNativeCommandTestParams,
createPrivateCommandContext,
deliverReplies,
editMessageTelegram,
emitTelegramMessageSentHooks,
listSkillCommandsForAgents,
resetNativeCommandMenuMocks,
waitForRegisteredCommands,
@@ -23,19 +20,9 @@ import {
import { resetTelegramForumFlagCacheForTest } from "./bot/helpers.js";
import { normalizeTelegramCommandName, TELEGRAM_COMMAND_NAME_PATTERN } from "./command-config.js";
type CommandBotHarness = ReturnType<typeof createCommandBot>;
type TelegramInlineKeyboardReplyMarkup = {
inline_keyboard?: Array<Array<{ text?: string; callback_data?: string }>>;
};
type PlugCommandHarnessParams = {
botHarness?: CommandBotHarness;
cfg?: OpenClawConfig;
command?: Record<string, unknown>;
acceptsArgs?: boolean;
args?: string;
result?: Record<string, unknown>;
registerOverrides?: Partial<Parameters<typeof registerTelegramNativeCommands>[0]>;
};
const pluginCommandHandler = vi.fn(async (_ctx: Record<string, unknown>) => ({ text: "ok" }));
@@ -62,35 +49,6 @@ function registerTestPluginCommand(params: {
).toEqual({ ok: true });
}
function primePlugCommand(params: PlugCommandHarnessParams = {}) {
registerTestPluginCommand({
name: "plug",
description: "Plugin command",
acceptsArgs: params.acceptsArgs ?? true,
command: params.command,
result: params.result,
});
}
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");
if (!handler) {
throw new Error("expected plug command handler to be registered");
}
return {
...botHarness,
handler,
};
}
function collectCallbackData(replyMarkup: TelegramInlineKeyboardReplyMarkup | undefined): string[] {
const callbackData: string[] = [];
for (const row of replyMarkup?.inline_keyboard ?? []) {
@@ -111,39 +69,9 @@ function firstCall(mock: { mock: { calls: Array<Array<unknown>> } }) {
return call;
}
function firstCallArg(mock: { mock: { calls: Array<Array<unknown>> } }, argIndex = 0) {
const arg = firstCall(mock)[argIndex];
if (!arg || typeof arg !== "object") {
throw new Error(`expected first mock call arg ${argIndex}`);
}
return arg as Record<string, unknown>;
}
function firstDeliverRepliesParams() {
return firstCallArg(deliverReplies as unknown as { mock: { calls: Array<Array<unknown>> } });
}
function firstExecutePluginCommandParams() {
return firstCallArg(
pluginCommandHandler as unknown as {
mock: { calls: Array<Array<unknown>> };
},
);
}
function replyAt(params: Record<string, unknown>, index = 0) {
const replies = params.replies as Array<Record<string, unknown>> | undefined;
const reply = replies?.[index];
if (!reply) {
throw new Error(`expected reply ${index}`);
}
return reply;
}
resetPluginRuntimeStateForTest();
setActivePluginRegistry(createEmptyPluginRegistry());
const { registerTelegramNativeCommands, parseTelegramNativeCommandCallbackData } =
await import("./bot-native-commands.js");
const { registerTelegramNativeCommands } = await import("./bot-native-commands.js");
registerTelegramNativeCommands(createNativeCommandTestParams({}));
describe("registerTelegramNativeCommands", () => {
@@ -439,476 +367,5 @@ describe("registerTelegramNativeCommands", () => {
"tgcmd:/fast status",
]);
expect(labels).toEqual(["on", "off", "auto (30 sec)", "default", "status"]);
expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast status")).toBe("/fast status");
expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast auto")).toBe("/fast auto");
expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast default")).toBe("/fast default");
expect(parseTelegramNativeCommandCallbackData("tgcmd:fast status")).toBeNull();
});
it("passes agent-scoped media roots for plugin command replies with media", async () => {
const mediaMaxBytes = 50 * 1024 * 1024;
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",
},
registerOverrides: {
mediaMaxBytes,
} as Partial<Parameters<typeof registerTelegramNativeCommands>[0]>,
});
await handler(createPrivateCommandContext());
const deliverParams = firstDeliverRepliesParams();
expect(deliverParams.mediaMaxBytes).toBe(mediaMaxBytes);
const mediaLocalRoots = deliverParams.mediaLocalRoots as Array<string> | undefined;
expect(mediaLocalRoots?.some((root) => /[\\/]\.openclaw[\\/]workspace-work$/.test(root))).toBe(
true,
);
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
});
it("delivers presentation-only tables returned by plugin commands", async () => {
const presentation = {
title: "FY25 outlook",
blocks: [
{
type: "table",
caption: "Pipeline",
headers: ["Account", "Stage"],
rows: [["Acme", "Won"]],
},
],
};
const { handler } = registerPlugCommand({ result: { presentation } });
await handler(createPrivateCommandContext());
expect(replyAt(firstDeliverRepliesParams())).toMatchObject({ presentation });
expect(replyAt(firstDeliverRepliesParams()).text).toBeUndefined();
});
it("delivers Telegram button-only plugin command replies", async () => {
const buttons = [[{ text: "Retry", callback_data: "retry" }]];
const { handler } = registerPlugCommand({
result: { channelData: { telegram: { buttons } } },
});
await handler(createPrivateCommandContext());
expect(replyAt(firstDeliverRepliesParams())).toEqual({
channelData: { telegram: { buttons } },
});
});
it("targets reaction-only plugin replies at the invoking command message", async () => {
const { handler } = registerPlugCommand({
result: { channelData: { telegram: { reaction: { emoji: "🔥" } } } },
});
await handler(createPrivateCommandContext({ messageId: 321 }));
const deliveryParams = firstDeliverRepliesParams();
expect(replyAt(deliveryParams)).toEqual({
replyToId: "321",
channelData: { telegram: { reaction: { emoji: "🔥" } } },
});
expect(deliveryParams.replyToMode).toBe("all");
});
it("uses the empty-response fallback for unrelated metadata-only plugin results", async () => {
const { handler } = registerPlugCommand({
result: { channelData: { plugin: { traceId: "trace-1" } } },
});
await handler(createPrivateCommandContext());
expect(replyAt(firstDeliverRepliesParams())).toEqual({
text: "No response generated. Please try again.",
});
});
it("replies to unmatched plugin commands in the originating forum topic", async () => {
const { handler, sendMessage } = registerPlugCommand({ acceptsArgs: false });
await handler({
match: "unexpected",
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" },
},
});
const sendMessageCall = firstCall(sendMessage);
expect(sendMessageCall[0]).toBe(-1001234567890);
expect(sendMessageCall[1]).toBe("Command not found.");
expect(
(sendMessageCall[2] as { message_thread_id?: number } | undefined)?.message_thread_id,
).toBe(77);
});
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",
}),
);
const sendMessageCall = firstCall(sendMessage);
expect(sendMessageCall[0]).toBe(100);
expect(String(sendMessageCall[1])).toContain("Running this command now");
expect(sendMessageCall[2]).toBeUndefined();
const editCall = firstCall(
editMessageTelegram as unknown as { mock: { calls: Array<Array<unknown>> } },
);
expect(editCall[0]).toBe(100);
expect(editCall[1]).toBe(999);
expect(String(editCall[2])).toContain("Command completed successfully");
expect((editCall[3] as { accountId?: string } | undefined)?.accountId).toBe("default");
expect(deleteMessage).not.toHaveBeenCalled();
expect(deliverReplies).not.toHaveBeenCalled();
const hookParams = firstCallArg(
emitTelegramMessageSentHooks as unknown as { mock: { calls: Array<Array<unknown>> } },
);
expect(hookParams.chatId).toBe("100");
expect(hookParams.content).toBe("Command completed successfully");
expect(hookParams.messageId).toBe(999);
expect(hookParams.success).toBe(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);
const editCall = firstCall(
editMessageTelegram as unknown as { mock: { calls: Array<Array<unknown>> } },
);
expect(editCall[0]).toBe(100);
expect(editCall[1]).toBe(999);
expect(editCall[2]).toBe("Choose an option");
expect((editCall[3] as { buttons?: unknown } | undefined)?.buttons).toEqual([
[{ text: "Approve", callback_data: "approve" }],
]);
expect(deleteMessage).not.toHaveBeenCalled();
expect(deliverReplies).not.toHaveBeenCalled();
});
it("delivers reactions after cleaning up a metadata-driven progress placeholder", async () => {
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
args: "now",
command: {
nativeProgressMessages: { telegram: "Working on it..." },
},
result: {
text: "Command completed successfully",
channelData: { telegram: { reaction: { emoji: "🔥" } } },
},
});
await handler(createPrivateCommandContext({ match: "now", messageId: 321 }));
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
expect(editMessageTelegram).not.toHaveBeenCalled();
expect(deleteMessage).toHaveBeenCalledWith(100, 999);
const deliveryParams = firstDeliverRepliesParams();
expect(deliveryParams.replyToMode).toBe("all");
expect(replyAt(deliveryParams)).toEqual({
text: "Command completed successfully",
replyToId: "321",
channelData: { telegram: { reaction: { emoji: "🔥" } } },
});
});
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(replyAt(firstDeliverRepliesParams()).mediaUrl).toBe("/tmp/render.png");
});
it("falls back to a normal reply when a progress result has presentation controls", async () => {
const presentation = {
blocks: [
{
type: "buttons",
buttons: [{ label: "Approve", action: { type: "callback", value: "/approve yes" } }],
},
],
};
const { handler, sendMessage, deleteMessage } = registerPlugCommand({
args: "now",
command: {
nativeProgressMessages: { telegram: "Working on it..." },
},
result: {
text: "Approval required",
presentation,
},
});
await handler(createPrivateCommandContext({ match: "now" }));
expect(sendMessage).toHaveBeenCalledWith(100, "Working on it...", undefined);
expect(editMessageTelegram).not.toHaveBeenCalled();
expect(deleteMessage).toHaveBeenCalledWith(100, 999);
expect(replyAt(firstDeliverRepliesParams())).toMatchObject({
text: "Approval required",
presentation,
});
});
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(replyAt(firstDeliverRepliesParams()).text).toBe("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 deliverParams = firstDeliverRepliesParams();
expect(deliverParams.silent).toBe(true);
expect(replyAt(deliverParams).isError).toBe(true);
});
it("uses rich messages for plugin command replies when enabled", async () => {
const { handler } = registerPlugCommand({
cfg: {
channels: {
telegram: {
richMessages: true,
},
},
},
registerOverrides: {
telegramCfg: { richMessages: true } as TelegramAccountConfig,
},
});
await handler(createPrivateCommandContext());
expect(firstDeliverRepliesParams().richMessages).toBe(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" },
},
});
const commandParams = firstExecutePluginCommandParams();
expect(commandParams.channel).toBe("telegram");
expect(commandParams.accountId).toBe("default");
expect(commandParams.from).toBe("telegram:group:-1001234567890:topic:77");
expect(commandParams.to).toBe("telegram:-1001234567890");
expect(commandParams.messageThreadId).toBe(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);
const commandParams = firstExecutePluginCommandParams();
expect(commandParams.accountId).toBe("default");
expect(commandParams.from).toBe("telegram:group:-1001234567890:topic:1");
expect(commandParams.to).toBe("telegram:-1001234567890");
expect(commandParams.messageThreadId).toBe(1);
});
it("forwards direct-message binding context to Telegram plugin commands", async () => {
const { handler } = registerPlugCommand();
await handler(createPrivateCommandContext({ chatId: 100, userId: 200 }));
const commandParams = firstExecutePluginCommandParams();
expect(commandParams.channel).toBe("telegram");
expect(commandParams.accountId).toBe("default");
expect(commandParams.from).toBe("telegram:100");
expect(commandParams.to).toBe("telegram:100");
expect(commandParams.messageThreadId).toBeUndefined();
});
it("suppresses the fallback reply when a plugin command returns suppressReply: true", async () => {
const { handler } = registerPlugCommand({
result: { suppressReply: true },
});
await handler(createPrivateCommandContext());
expect(deliverReplies).not.toHaveBeenCalled();
expect(editMessageTelegram).not.toHaveBeenCalled();
});
it("uses bot topic capability for Telegram plugin command DM topic session keys", async () => {
const { handler } = registerPlugCommand();
await handler({
...createPrivateCommandContext({ chatId: 100, userId: 200, threadId: 77 }),
me: { has_topics_enabled: true },
});
const commandParams = firstExecutePluginCommandParams();
expect(commandParams.sessionKey).toBe("agent:main:main:thread:100:77");
const deliveryParams = firstDeliverRepliesParams();
expect(deliveryParams.sessionKeyForInternalHooks).toBe("agent:main:main:thread:100:77");
});
});
File diff suppressed because it is too large Load Diff
+29 -1
View File
@@ -9,12 +9,17 @@ import {
buildAgentSessionKey,
deriveLastRoutePolicy,
resolveAgentRoute,
resolveThreadSessionKeys,
} from "openclaw/plugin-sdk/routing";
import { buildAgentMainSessionKey, sanitizeAgentId } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveDefaultTelegramAccountId } from "./accounts.js";
import { buildTelegramGroupPeerId, buildTelegramParentPeer } from "./bot/helpers.js";
import {
buildTelegramGroupPeerId,
buildTelegramParentPeer,
shouldUseTelegramDmThreadSession,
} from "./bot/helpers.js";
import {
resolveTelegramDirectPeerId,
resolveTelegramNamedAccountBaseSessionKey,
@@ -162,3 +167,26 @@ export function resolveTelegramConversationBaseSessionKey(
params,
);
}
export function resolveTelegramTargetSession(params: {
cfg: OpenClawConfig;
route: TelegramResolvedRoute;
chatId: number | string;
isGroup: boolean;
senderId?: string | number | null;
dmThreadId?: number;
botHasTopicsEnabled?: boolean;
}): string {
const baseSessionKey = resolveTelegramConversationBaseSessionKey(params);
const threadKeys =
shouldUseTelegramDmThreadSession({
dmThreadId: params.dmThreadId,
botHasTopicsEnabled: params.botHasTopicsEnabled,
}) && params.dmThreadId != null
? resolveThreadSessionKeys({
baseSessionKey,
threadId: `${params.chatId}:${params.dmThreadId}`,
})
: null;
return threadKeys?.sessionKey ?? baseSessionKey;
}
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { parseTelegramNativeCommandCallbackData } from "./native-command-callback-data.js";
describe("parseTelegramNativeCommandCallbackData", () => {
it("preserves prefixed native commands and rejects malformed command bodies", () => {
expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast status")).toBe("/fast status");
expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast auto")).toBe("/fast auto");
expect(parseTelegramNativeCommandCallbackData("tgcmd:/fast default")).toBe("/fast default");
expect(parseTelegramNativeCommandCallbackData("tgcmd:fast status")).toBeNull();
});
});