fix(telegram): skip native commands in regular message handler
When native commands are enabled (default), the regular message handler
now skips messages that match registered native commands (e.g., /new,
/commands). This prevents duplicate processing where both the native
command handler (bot.command()) and the regular message handler
(bot.on("message")) would process the same command.
Previously, /new sent to a secondary Telegram agent would reset both
that agent's session AND the default agent's session because Grammy
triggers both handlers for the same message. The native handler
correctly targeted the bound agent via CommandTargetSessionKey, but the
regular handler processed it as a normal message.
Fixes #4385
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
09be5d45d5
commit
75366cdade
@ -37,6 +37,24 @@ export const registerTelegramHandlers = ({
|
||||
shouldSkipUpdate,
|
||||
processMessage,
|
||||
logger,
|
||||
nativeEnabled,
|
||||
nativeCommandNames,
|
||||
}: {
|
||||
cfg: unknown;
|
||||
accountId: string;
|
||||
bot: unknown;
|
||||
opts: unknown;
|
||||
runtime: unknown;
|
||||
mediaMaxBytes: number;
|
||||
telegramCfg: unknown;
|
||||
groupAllowFrom: unknown;
|
||||
resolveGroupPolicy: unknown;
|
||||
resolveTelegramGroupConfig: unknown;
|
||||
shouldSkipUpdate: unknown;
|
||||
processMessage: unknown;
|
||||
logger: unknown;
|
||||
nativeEnabled?: boolean;
|
||||
nativeCommandNames?: Set<string>;
|
||||
}) => {
|
||||
const TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS = 4000;
|
||||
const TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS = 1500;
|
||||
@ -441,6 +459,24 @@ export const registerTelegramHandlers = ({
|
||||
if (!msg) return;
|
||||
if (shouldSkipUpdate(ctx)) return;
|
||||
|
||||
// Skip messages that will be handled by native command handlers.
|
||||
// Native commands (e.g., /new) are processed by bot.command() handlers first.
|
||||
// Without this check, the regular message handler would also process them,
|
||||
// potentially causing duplicate session resets or targeting wrong agents.
|
||||
if (nativeEnabled && nativeCommandNames && nativeCommandNames.size > 0) {
|
||||
const rawText = (msg.text ?? "").trim();
|
||||
if (rawText.startsWith("/")) {
|
||||
const commandMatch = rawText.match(/^\/([a-z0-9_]+)/i);
|
||||
if (commandMatch) {
|
||||
const commandName = commandMatch[1].toLowerCase();
|
||||
if (nativeCommandNames.has(commandName)) {
|
||||
logVerbose(`telegram: skipping native command /${commandName} in regular handler`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chatId = msg.chat.id;
|
||||
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
||||
|
||||
@ -0,0 +1,329 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let createTelegramBot: typeof import("./bot.js").createTelegramBot;
|
||||
let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
|
||||
|
||||
const { sessionStorePath } = vi.hoisted(() => ({
|
||||
sessionStorePath: `/tmp/openclaw-telegram-${Math.random().toString(16).slice(2)}.json`,
|
||||
}));
|
||||
|
||||
const { loadWebMedia } = vi.hoisted(() => ({
|
||||
loadWebMedia: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../web/media.js", () => ({
|
||||
loadWebMedia,
|
||||
}));
|
||||
|
||||
const { loadConfig } = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/sessions.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath),
|
||||
};
|
||||
});
|
||||
|
||||
const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({
|
||||
readTelegramAllowFromStore: vi.fn(async () => [] as string[]),
|
||||
upsertTelegramPairingRequest: vi.fn(async () => ({
|
||||
code: "PAIRCODE",
|
||||
created: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./pairing-store.js", () => ({
|
||||
readTelegramAllowFromStore,
|
||||
upsertTelegramPairingRequest,
|
||||
}));
|
||||
|
||||
const useSpy = vi.fn();
|
||||
const middlewareUseSpy = vi.fn();
|
||||
const onSpy = vi.fn();
|
||||
const stopSpy = vi.fn();
|
||||
const commandSpy = vi.fn();
|
||||
const botCtorSpy = vi.fn();
|
||||
const answerCallbackQuerySpy = vi.fn(async () => undefined);
|
||||
const sendChatActionSpy = vi.fn();
|
||||
const setMessageReactionSpy = vi.fn(async () => undefined);
|
||||
const setMyCommandsSpy = vi.fn(async () => undefined);
|
||||
const sendMessageSpy = vi.fn(async () => ({ message_id: 77 }));
|
||||
const sendAnimationSpy = vi.fn(async () => ({ message_id: 78 }));
|
||||
const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 }));
|
||||
type ApiStub = {
|
||||
config: { use: (arg: unknown) => void };
|
||||
answerCallbackQuery: typeof answerCallbackQuerySpy;
|
||||
sendChatAction: typeof sendChatActionSpy;
|
||||
setMessageReaction: typeof setMessageReactionSpy;
|
||||
setMyCommands: typeof setMyCommandsSpy;
|
||||
sendMessage: typeof sendMessageSpy;
|
||||
sendAnimation: typeof sendAnimationSpy;
|
||||
sendPhoto: typeof sendPhotoSpy;
|
||||
};
|
||||
const apiStub: ApiStub = {
|
||||
config: { use: useSpy },
|
||||
answerCallbackQuery: answerCallbackQuerySpy,
|
||||
sendChatAction: sendChatActionSpy,
|
||||
setMessageReaction: setMessageReactionSpy,
|
||||
setMyCommands: setMyCommandsSpy,
|
||||
sendMessage: sendMessageSpy,
|
||||
sendAnimation: sendAnimationSpy,
|
||||
sendPhoto: sendPhotoSpy,
|
||||
};
|
||||
|
||||
vi.mock("grammy", () => ({
|
||||
Bot: class {
|
||||
api = apiStub;
|
||||
use = middlewareUseSpy;
|
||||
on = onSpy;
|
||||
stop = stopSpy;
|
||||
command = commandSpy;
|
||||
catch = vi.fn();
|
||||
constructor(
|
||||
public token: string,
|
||||
public options?: { client?: { fetch?: typeof fetch } },
|
||||
) {
|
||||
botCtorSpy(token, options);
|
||||
}
|
||||
},
|
||||
InputFile: class {},
|
||||
webhookCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
const sequentializeMiddleware = vi.fn();
|
||||
const sequentializeSpy = vi.fn(() => sequentializeMiddleware);
|
||||
let _sequentializeKey: ((ctx: unknown) => string) | undefined;
|
||||
vi.mock("@grammyjs/runner", () => ({
|
||||
sequentialize: (keyFn: (ctx: unknown) => string) => {
|
||||
_sequentializeKey = keyFn;
|
||||
return sequentializeSpy();
|
||||
},
|
||||
}));
|
||||
|
||||
const throttlerSpy = vi.fn(() => "throttler");
|
||||
|
||||
vi.mock("@grammyjs/transformer-throttler", () => ({
|
||||
apiThrottler: () => throttlerSpy(),
|
||||
}));
|
||||
|
||||
vi.mock("../auto-reply/reply.js", () => {
|
||||
const replySpy = vi.fn(async (_ctx, opts) => {
|
||||
await opts?.onReplyStart?.();
|
||||
return undefined;
|
||||
});
|
||||
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
|
||||
});
|
||||
|
||||
let replyModule: typeof import("../auto-reply/reply.js");
|
||||
|
||||
const getOnHandler = (event: string) => {
|
||||
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
||||
if (!handler) throw new Error(`Missing handler for event: ${event}`);
|
||||
return handler as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
};
|
||||
|
||||
describe("createTelegramBot - native command handling", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
|
||||
({ createTelegramBot } = await import("./bot.js"));
|
||||
replyModule = await import("../auto-reply/reply.js");
|
||||
resetInboundDedupe();
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: { dmPolicy: "open", allowFrom: ["*"] },
|
||||
},
|
||||
});
|
||||
loadWebMedia.mockReset();
|
||||
sendAnimationSpy.mockReset();
|
||||
sendPhotoSpy.mockReset();
|
||||
setMessageReactionSpy.mockReset();
|
||||
answerCallbackQuerySpy.mockReset();
|
||||
setMyCommandsSpy.mockReset();
|
||||
middlewareUseSpy.mockReset();
|
||||
sequentializeSpy.mockReset();
|
||||
botCtorSpy.mockReset();
|
||||
onSpy.mockReset();
|
||||
commandSpy.mockReset();
|
||||
_sequentializeKey = undefined;
|
||||
});
|
||||
|
||||
it("skips native commands in regular message handler when native commands are enabled", async () => {
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
replySpy.mockReset();
|
||||
|
||||
// Native commands are enabled by default, so /new should be skipped in regular handler
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
// Send a /new command message
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 123, type: "private" },
|
||||
from: { id: 999, username: "testuser" },
|
||||
text: "/new",
|
||||
date: 1736380800,
|
||||
message_id: 42,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
// Regular handler should NOT process /new when native commands are enabled
|
||||
// because it will be handled by the native command handler
|
||||
expect(replySpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("processes regular messages that are not native commands", async () => {
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
replySpy.mockReset();
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
// Send a regular message (not a native command)
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 123, type: "private" },
|
||||
from: { id: 999, username: "testuser" },
|
||||
text: "hello world",
|
||||
date: 1736380800,
|
||||
message_id: 42,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
// Regular handler should process normal messages
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("processes text commands (like /foo) that are not native commands", async () => {
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
replySpy.mockReset();
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
// Send a slash command that is NOT a registered native command
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 123, type: "private" },
|
||||
from: { id: 999, username: "testuser" },
|
||||
text: "/customcommand",
|
||||
date: 1736380800,
|
||||
message_id: 42,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
// Regular handler should process commands that aren't native
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips /commands native command in regular message handler", async () => {
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
replySpy.mockReset();
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
// Send a /commands message (another native command)
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 123, type: "private" },
|
||||
from: { id: 999, username: "testuser" },
|
||||
text: "/commands",
|
||||
date: 1736380800,
|
||||
message_id: 42,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
// Regular handler should NOT process /commands when native commands are enabled
|
||||
expect(replySpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("processes native commands in regular handler when native commands are disabled", async () => {
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||
replySpy.mockReset();
|
||||
|
||||
// Disable native commands
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
commands: { native: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
// Send a /new command message
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 123, type: "private" },
|
||||
from: { id: 999, username: "testuser" },
|
||||
text: "/new",
|
||||
date: 1736380800,
|
||||
message_id: 42,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
// When native commands are disabled, regular handler should process them
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -6,6 +6,8 @@ import { Bot, webhookCallback } from "grammy";
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { isControlCommandMessage } from "../auto-reply/command-detection.js";
|
||||
import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||
import { listNativeCommandSpecsForConfig } from "../auto-reply/commands-registry.js";
|
||||
import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js";
|
||||
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js";
|
||||
import {
|
||||
isNativeCommandsExplicitlyDisabled,
|
||||
@ -250,6 +252,20 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
providerSetting: telegramCfg.commands?.native,
|
||||
globalSetting: cfg.commands?.native,
|
||||
});
|
||||
// Build set of native command names for deduplication in regular message handler.
|
||||
// This prevents native commands (e.g., /new) from being processed twice.
|
||||
const nativeCommandNames = new Set<string>();
|
||||
if (nativeEnabled) {
|
||||
const skillCommands =
|
||||
nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
|
||||
const nativeSpecs = listNativeCommandSpecsForConfig(cfg, {
|
||||
skillCommands,
|
||||
provider: "telegram",
|
||||
});
|
||||
for (const spec of nativeSpecs) {
|
||||
nativeCommandNames.add(spec.name.toLowerCase());
|
||||
}
|
||||
}
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||
const mediaMaxBytes = (opts.mediaMaxMb ?? telegramCfg.mediaMaxMb ?? 5) * 1024 * 1024;
|
||||
@ -465,6 +481,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
shouldSkipUpdate,
|
||||
processMessage,
|
||||
logger,
|
||||
nativeEnabled,
|
||||
nativeCommandNames,
|
||||
});
|
||||
|
||||
return bot;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user