From b2aa6e094db73210376a7ed3dc72f506398dfdaa Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 16 Feb 2026 16:21:10 +0530 Subject: [PATCH] fix(telegram): prevent non-abort slash commands from racing chat replies (#17899) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 5c2f6f2c969b5088a0a7f457f02f29fff960e501 Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + src/auto-reply/reply/abort.test.ts | 12 ++++++++++++ src/auto-reply/reply/abort.ts | 16 +++++++++++++--- src/telegram/bot.create-telegram-bot.test.ts | 17 ++++++++++++++++- src/telegram/bot.ts | 7 ++----- 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f158ea65e..8ec200ba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang. - Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus. - Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk. +- Telegram: route non-abort slash commands on the normal chat/topic sequential lane while keeping true abort requests (`/stop`, `stop`) on the control lane, preventing command/reply race conditions from control-lane bypass. (#17899) Thanks @obviyus. - Discord: preserve channel session continuity when runtime payloads omit `message.channelId` by falling back to event/raw `channel_id` values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as `sessionKey=unknown`. (#17622) Thanks @shakkernerd. - Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with `_2` suffixes. (#17365) Thanks @seewhyme. - Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu. diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index 76b0889e8..7f4172f1d 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { getAbortMemory, getAbortMemorySizeForTest, + isAbortRequestText, isAbortTrigger, resetAbortMemoryForTest, setAbortMemory, @@ -75,6 +76,17 @@ describe("abort detection", () => { expect(isAbortTrigger("/stop")).toBe(false); }); + it("isAbortRequestText aligns abort command semantics", () => { + expect(isAbortRequestText("/stop")).toBe(true); + expect(isAbortRequestText("stop")).toBe(true); + expect(isAbortRequestText("/stop@openclaw_bot", { botUsername: "openclaw_bot" })).toBe(true); + + expect(isAbortRequestText("/status")).toBe(false); + expect(isAbortRequestText("stop please")).toBe(false); + expect(isAbortRequestText("/abort")).toBe(false); + expect(isAbortRequestText("/abort now")).toBe(false); + }); + it("removes abort memory entry when flag is reset", () => { setAbortMemory("session-1", true); expect(getAbortMemory("session-1")).toBe(true); diff --git a/src/auto-reply/reply/abort.ts b/src/auto-reply/reply/abort.ts index f2b4e8bc7..3b55c08bb 100644 --- a/src/auto-reply/reply/abort.ts +++ b/src/auto-reply/reply/abort.ts @@ -19,7 +19,7 @@ import { import { logVerbose } from "../../globals.js"; import { parseAgentSessionKey } from "../../routing/session-key.js"; import { resolveCommandAuthorization } from "../command-auth.js"; -import { normalizeCommandBody } from "../commands-registry.js"; +import { normalizeCommandBody, type CommandNormalizeOptions } from "../commands-registry.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { clearSessionQueues } from "./queue.js"; @@ -35,6 +35,17 @@ export function isAbortTrigger(text?: string): boolean { return ABORT_TRIGGERS.has(normalized); } +export function isAbortRequestText(text?: string, options?: CommandNormalizeOptions): boolean { + if (!text) { + return false; + } + const normalized = normalizeCommandBody(text, options).trim(); + if (!normalized) { + return false; + } + return normalized.toLowerCase() === "/stop" || isAbortTrigger(normalized); +} + export function getAbortMemory(key: string): boolean | undefined { const normalized = key.trim(); if (!normalized) { @@ -202,8 +213,7 @@ export async function tryFastAbortFromMessage(params: { const raw = stripStructuralPrefixes(ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? ""); const isGroup = ctx.ChatType?.trim().toLowerCase() === "group"; const stripped = isGroup ? stripMentions(raw, ctx, cfg, agentId) : raw; - const normalized = normalizeCommandBody(stripped); - const abortRequested = normalized === "/stop" || isAbortTrigger(stripped); + const abortRequested = isAbortRequestText(stripped); if (!abortRequested) { return { handled: false, aborted: false }; } diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts index 922ed5877..8704eebeb 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -139,12 +139,27 @@ describe("createTelegramBot", () => { getTelegramSequentialKey({ message: { chat: { id: 123 }, text: "/status" }, }), - ).toBe("telegram:123:control"); + ).toBe("telegram:123"); expect( getTelegramSequentialKey({ message: { chat: { id: 123 }, text: "stop" }, }), ).toBe("telegram:123:control"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123 }, text: "stop please" }, + }), + ).toBe("telegram:123"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123 }, text: "/abort" }, + }), + ).toBe("telegram:123"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123 }, text: "/abort now" }, + }), + ).toBe("telegram:123"); }); it("routes callback_query payloads as messages and answers callbacks", async () => { onSpy.mockReset(); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index b5b463168..aaba24759 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -7,7 +7,7 @@ import type { OpenClawConfig, ReplyToMode } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveTextChunkLimit } from "../auto-reply/chunk.js"; -import { isControlCommandMessage } from "../auto-reply/command-detection.js"; +import { isAbortRequestText } from "../auto-reply/reply/abort.js"; import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js"; import { isNativeCommandsExplicitlyDisabled, @@ -90,10 +90,7 @@ export function getTelegramSequentialKey(ctx: { const chatId = msg?.chat?.id ?? ctx.chat?.id; const rawText = msg?.text ?? msg?.caption; const botUsername = ctx.me?.username; - if ( - rawText && - isControlCommandMessage(rawText, undefined, botUsername ? { botUsername } : undefined) - ) { + if (isAbortRequestText(rawText, botUsername ? { botUsername } : undefined)) { if (typeof chatId === "number") { return `telegram:${chatId}:control`; }