mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
fc31a157cc
commit
cb52ded58d
@@ -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());
|
||||
+402
-9
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user