mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 19:31:37 +00:00
fix(telegram): prevent non-abort slash commands from racing chat replies (#17899)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 5c2f6f2c96
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
This commit is contained in:
@@ -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: 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: 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: 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: 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: 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.
|
- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../../config/config.js";
|
|||||||
import {
|
import {
|
||||||
getAbortMemory,
|
getAbortMemory,
|
||||||
getAbortMemorySizeForTest,
|
getAbortMemorySizeForTest,
|
||||||
|
isAbortRequestText,
|
||||||
isAbortTrigger,
|
isAbortTrigger,
|
||||||
resetAbortMemoryForTest,
|
resetAbortMemoryForTest,
|
||||||
setAbortMemory,
|
setAbortMemory,
|
||||||
@@ -75,6 +76,17 @@ describe("abort detection", () => {
|
|||||||
expect(isAbortTrigger("/stop")).toBe(false);
|
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", () => {
|
it("removes abort memory entry when flag is reset", () => {
|
||||||
setAbortMemory("session-1", true);
|
setAbortMemory("session-1", true);
|
||||||
expect(getAbortMemory("session-1")).toBe(true);
|
expect(getAbortMemory("session-1")).toBe(true);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||||
import { resolveCommandAuthorization } from "../command-auth.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 { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||||
import { clearSessionQueues } from "./queue.js";
|
import { clearSessionQueues } from "./queue.js";
|
||||||
|
|
||||||
@@ -35,6 +35,17 @@ export function isAbortTrigger(text?: string): boolean {
|
|||||||
return ABORT_TRIGGERS.has(normalized);
|
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 {
|
export function getAbortMemory(key: string): boolean | undefined {
|
||||||
const normalized = key.trim();
|
const normalized = key.trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -202,8 +213,7 @@ export async function tryFastAbortFromMessage(params: {
|
|||||||
const raw = stripStructuralPrefixes(ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "");
|
const raw = stripStructuralPrefixes(ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "");
|
||||||
const isGroup = ctx.ChatType?.trim().toLowerCase() === "group";
|
const isGroup = ctx.ChatType?.trim().toLowerCase() === "group";
|
||||||
const stripped = isGroup ? stripMentions(raw, ctx, cfg, agentId) : raw;
|
const stripped = isGroup ? stripMentions(raw, ctx, cfg, agentId) : raw;
|
||||||
const normalized = normalizeCommandBody(stripped);
|
const abortRequested = isAbortRequestText(stripped);
|
||||||
const abortRequested = normalized === "/stop" || isAbortTrigger(stripped);
|
|
||||||
if (!abortRequested) {
|
if (!abortRequested) {
|
||||||
return { handled: false, aborted: false };
|
return { handled: false, aborted: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,12 +139,27 @@ describe("createTelegramBot", () => {
|
|||||||
getTelegramSequentialKey({
|
getTelegramSequentialKey({
|
||||||
message: { chat: { id: 123 }, text: "/status" },
|
message: { chat: { id: 123 }, text: "/status" },
|
||||||
}),
|
}),
|
||||||
).toBe("telegram:123:control");
|
).toBe("telegram:123");
|
||||||
expect(
|
expect(
|
||||||
getTelegramSequentialKey({
|
getTelegramSequentialKey({
|
||||||
message: { chat: { id: 123 }, text: "stop" },
|
message: { chat: { id: 123 }, text: "stop" },
|
||||||
}),
|
}),
|
||||||
).toBe("telegram:123:control");
|
).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 () => {
|
it("routes callback_query payloads as messages and answers callbacks", async () => {
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { OpenClawConfig, ReplyToMode } from "../config/config.js";
|
|||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import { resolveTextChunkLimit } from "../auto-reply/chunk.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 { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js";
|
||||||
import {
|
import {
|
||||||
isNativeCommandsExplicitlyDisabled,
|
isNativeCommandsExplicitlyDisabled,
|
||||||
@@ -90,10 +90,7 @@ export function getTelegramSequentialKey(ctx: {
|
|||||||
const chatId = msg?.chat?.id ?? ctx.chat?.id;
|
const chatId = msg?.chat?.id ?? ctx.chat?.id;
|
||||||
const rawText = msg?.text ?? msg?.caption;
|
const rawText = msg?.text ?? msg?.caption;
|
||||||
const botUsername = ctx.me?.username;
|
const botUsername = ctx.me?.username;
|
||||||
if (
|
if (isAbortRequestText(rawText, botUsername ? { botUsername } : undefined)) {
|
||||||
rawText &&
|
|
||||||
isControlCommandMessage(rawText, undefined, botUsername ? { botUsername } : undefined)
|
|
||||||
) {
|
|
||||||
if (typeof chatId === "number") {
|
if (typeof chatId === "number") {
|
||||||
return `telegram:${chatId}:control`;
|
return `telegram:${chatId}:control`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user