fix(channels): bypass debounce for bare abort triggers [AI-assisted] (#83348)

Summary:
- The PR changes shared, Feishu, Mattermost, Microsoft Teams, and WhatsApp inbound debounce predicates so bare abort text bypasses debounce, then adds focused tests and a changelog entry.
- Reproducibility: yes. source-level. Current main sends bare `stop`, `abort`, and `wait` through a `hasContro ... ()` debounce gate, while the existing abort-aware detector and trigger set already recognize those phrases.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(channels): bypass debounce for bare abort triggers [AI-assisted]
- PR branch already contained follow-up commit before automerge: fix(clawsweeper): address review for automerge-openclaw-openclaw-8334…

Validation:
- ClawSweeper review passed for head c96bf84270.
- Required merge gates passed before the squash merge.

Prepared head SHA: c96bf84270
Review: https://github.com/openclaw/openclaw/pull/83348#issuecomment-4473176095

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
This commit is contained in:
WhatsSkiLL
2026-05-22 06:09:33 +02:00
committed by GitHub
parent e399a92e6c
commit 7dc2e50ac3
12 changed files with 356 additions and 34 deletions

View File

@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
- CLI/agents: allow `openclaw agent --session-key` to target explicit session keys, including agent-scoped legacy keys. (#85121) Thanks @Kaspre.
- Auto-reply/ACP: wait for same-channel block reply delivery before starting tool work, while still honoring ACP dispatch aborts so stopped turns do not wait on slow channel sends. (#83722) Thanks @IWhatsskill.
- Codex/ACP: mark required child-run completions that only report progress, omit a final deliverable, or fail requester delivery as blocked while preserving real final reports. (#85110) Thanks @IWhatsskill.
- Channels: treat bare abort messages such as `stop`, `abort`, and `wait` as immediate control commands in inbound debounce paths so stop requests are not delayed behind pending message coalescing. (#83348) Thanks @IWhatsskill.
- Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle.
- Agents/Pi: treat accepted embedded `sessions_spawn` child-session handoffs as terminal progress so parent turns no longer report false non-deliverable failures. (#85054) Thanks @samzong.
- WhatsApp: update Baileys to `7.0.0-rc13` and drop the obsolete logger type patch.

View File

@@ -262,7 +262,7 @@ export function createFeishuMessageReceiveHandler({
return false;
}
const text = resolveDebounceText(event);
return Boolean(text) && !core.channel.text.hasControlCommand(text, cfg);
return Boolean(text) && !core.channel.commands.isControlCommandMessage(text, cfg);
},
onFlush: async (entries) => {
const last = entries.at(-1);

View File

@@ -2,7 +2,7 @@ import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "openclaw/plugin-sdk/channel-inbound-debounce";
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
import { hasControlCommand, isControlCommandMessage } from "openclaw/plugin-sdk/command-detection";
import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
@@ -255,10 +255,14 @@ function mentionOpenIds(event: FeishuMessageEvent): string[] {
function createFeishuMonitorRuntime(params?: {
createInboundDebouncer?: PluginRuntime["channel"]["debounce"]["createInboundDebouncer"];
resolveInboundDebounceMs?: PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"];
isControlCommandMessage?: PluginRuntime["channel"]["commands"]["isControlCommandMessage"];
hasControlCommand?: PluginRuntime["channel"]["text"]["hasControlCommand"];
}): PluginRuntime {
return {
channel: {
commands: {
isControlCommandMessage: params?.isControlCommandMessage ?? isControlCommandMessage,
},
debounce: {
createInboundDebouncer: params?.createInboundDebouncer ?? createInboundDebouncer,
resolveInboundDebounceMs: params?.resolveInboundDebounceMs ?? resolveInboundDebounceMs,
@@ -533,6 +537,32 @@ describe("Feishu inbound debounce regressions", () => {
vi.restoreAllMocks();
});
it("releases pending text before a bare abort trigger instead of debouncing it", async () => {
setDedupPassThroughMocks();
const onMessage = await setupDebounceMonitor();
await enqueueDebouncedMessage(onMessage, createTextEvent({ messageId: "om_1", text: "first" }));
expect(handleFeishuMessageMock).not.toHaveBeenCalled();
await enqueueDebouncedMessage(
onMessage,
createTextEvent({ messageId: "om_stop", text: "stop" }),
);
await Promise.resolve();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(2);
const first = getFirstDispatchedEvent();
const secondCall = mockCallAt(handleFeishuMessageMock, 1, "Feishu stop dispatch")[0] as
| { event?: FeishuMessageEvent }
| undefined;
const second = secondCall?.event;
expect(JSON.parse(first.message.content)).toEqual({ text: "first" });
expect(second?.message.message_id).toBe("om_stop");
expect(JSON.parse(second?.message.content ?? "{}")).toEqual({ text: "stop" });
});
it("keeps bot mention when per-message mention keys collide across non-forward messages", async () => {
setDedupPassThroughMocks();
const onMessage = await setupDebounceMonitor();

View File

@@ -1,3 +1,4 @@
import { createInboundDebouncer } from "openclaw/plugin-sdk/channel-inbound-debounce";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { monitorMattermostProvider } from "./monitor.js";
import type { OpenClawConfig, RuntimeEnv } from "./runtime-api.js";
@@ -148,6 +149,14 @@ function createRuntimeCore(
mainSessionKey?: string;
sessionKey?: string;
},
overrides: {
inboundDebounceMs?: number;
isControlCommandMessage?: (text?: string) => boolean;
shouldComputeCommandAuthorized?: (text?: string) => boolean;
shouldHandleTextCommands?: () => boolean;
textHasControlCommand?: (text?: string) => boolean;
createInboundDebouncer?: typeof createInboundDebouncer;
} = {},
) {
const runPrepared = vi.fn(
async (turn: {
@@ -230,20 +239,23 @@ function createRuntimeCore(
record: vi.fn(),
},
commands: {
shouldHandleTextCommands: () => false,
isControlCommandMessage: overrides.isControlCommandMessage ?? (() => false),
shouldComputeCommandAuthorized: overrides.shouldComputeCommandAuthorized ?? (() => false),
shouldHandleTextCommands: overrides.shouldHandleTextCommands ?? (() => false),
},
debounce: {
resolveInboundDebounceMs: () => 0,
createInboundDebouncer: <T>(params: {
onFlush: (entries: T[]) => Promise<void> | void;
}) => ({
enqueue: async (entry: T) => {
await params.onFlush([entry]);
},
}),
resolveInboundDebounceMs: () => overrides.inboundDebounceMs ?? 0,
createInboundDebouncer:
overrides.createInboundDebouncer ??
(<T>(params: { onFlush: (entries: T[]) => Promise<void> | void }) => ({
enqueue: async (entry: T) => {
await params.onFlush([entry]);
},
})),
},
groups: {
resolveRequireMention: () => false,
resolveRequireMention: (params: { requireMentionOverride?: boolean }) =>
params.requireMentionOverride ?? false,
},
media: {
readRemoteMediaBuffer: vi.fn(),
@@ -316,7 +328,7 @@ function createRuntimeCore(
text: {
chunkMarkdownTextWithMode: (text: string) => [text],
convertMarkdownTables: (text: string) => text,
hasControlCommand: () => false,
hasControlCommand: overrides.textHasControlCommand ?? (() => false),
resolveChunkMode: () => "off",
resolveMarkdownTableMode: () => "off",
resolveTextChunkLimit: () => 4000,
@@ -432,6 +444,73 @@ describe("mattermost inbound user posts", () => {
expect(ctx?.Provider).toBe("mattermost");
});
it("does not drop inline command-looking group text from non-command-authorized senders", async () => {
const socket = new FakeWebSocket();
const abortController = new AbortController();
mockState.abortController = abortController;
const inlineCommandConfig: OpenClawConfig = {
commands: { useAccessGroups: true },
channels: {
mattermost: {
enabled: true,
baseUrl: "https://mattermost.example.com",
botToken: "bot-token",
chatmode: "onmessage",
dmPolicy: "open",
groupPolicy: "open",
},
},
};
const isControlCommandMessage = vi.fn(() => false);
const shouldComputeCommandAuthorized = vi.fn(() => true);
mockState.runtimeCore = createRuntimeCore(inlineCommandConfig, undefined, {
isControlCommandMessage,
shouldComputeCommandAuthorized,
shouldHandleTextCommands: () => true,
});
const monitor = monitorMattermostProvider({
config: inlineCommandConfig,
runtime: testRuntime(),
abortSignal: abortController.signal,
webSocketFactory: () => socket,
});
await vi.waitFor(() => {
expect(socket.openListenerCount).toBeGreaterThan(0);
});
socket.emitOpen();
await socket.emitMessage({
event: "posted",
data: {
channel_id: "chan-1",
channel_name: "town-square",
channel_display_name: "Town Square",
sender_name: "alice",
post: JSON.stringify({
id: "post-inline-command",
channel_id: "chan-1",
user_id: "user-1",
message: "hello /status",
create_at: 1_714_000_000_000,
}),
},
broadcast: {
channel_id: "chan-1",
user_id: "user-1",
},
});
socket.emitClose(1000);
await monitor;
expect(isControlCommandMessage).toHaveBeenCalledWith("hello /status", inlineCommandConfig);
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
expect(ctx?.BodyForAgent).toBe("hello /status");
expect(ctx?.CommandAuthorized).toBe(false);
});
it("uses websocket channel type when REST channel lookup fails", async () => {
const socket = new FakeWebSocket();
const abortController = new AbortController();
@@ -543,6 +622,98 @@ describe("mattermost inbound user posts", () => {
expect(runtimeCore.channel.session.recordInboundSession).not.toHaveBeenCalled();
});
it("flushes pending group text before authorizing a bare abort without a mention", async () => {
const socket = new FakeWebSocket();
const abortController = new AbortController();
mockState.abortController = abortController;
const mentionConfig: OpenClawConfig = {
commands: { useAccessGroups: false },
messages: { inbound: { debounceMs: 60_000 } },
channels: {
mattermost: {
enabled: true,
baseUrl: "https://mattermost.example.com",
botToken: "bot-token",
chatmode: "oncall",
dmPolicy: "open",
groupPolicy: "open",
},
},
};
const isBareAbort = (text?: string) => ["abort", "stop"].includes(text?.trim() ?? "");
const runtimeCore = createRuntimeCore(mentionConfig, undefined, {
inboundDebounceMs: 60_000,
createInboundDebouncer,
isControlCommandMessage: isBareAbort,
shouldComputeCommandAuthorized: isBareAbort,
shouldHandleTextCommands: () => true,
textHasControlCommand: () => false,
});
mockState.runtimeCore = runtimeCore;
const monitor = monitorMattermostProvider({
config: mentionConfig,
runtime: testRuntime(),
abortSignal: abortController.signal,
webSocketFactory: () => socket,
});
await vi.waitFor(() => {
expect(socket.openListenerCount).toBeGreaterThan(0);
});
socket.emitOpen();
await socket.emitMessage({
event: "posted",
data: {
channel_id: "chan-1",
channel_name: "town-square",
channel_display_name: "Town Square",
sender_name: "alice",
post: JSON.stringify({
id: "post-pending",
channel_id: "chan-1",
user_id: "user-1",
message: "pending text",
create_at: 1_714_000_000_000,
}),
},
broadcast: {
channel_id: "chan-1",
user_id: "user-1",
},
});
expect(mockState.dispatchReplyFromConfig).not.toHaveBeenCalled();
await socket.emitMessage({
event: "posted",
data: {
channel_id: "chan-1",
channel_name: "town-square",
channel_display_name: "Town Square",
sender_name: "alice",
post: JSON.stringify({
id: "post-abort",
channel_id: "chan-1",
user_id: "user-1",
message: "abort",
create_at: 1_714_000_000_100,
}),
},
broadcast: {
channel_id: "chan-1",
user_id: "user-1",
},
});
socket.emitClose(1000);
await monitor;
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
expect(ctx?.BodyForAgent).toBe("abort");
expect(ctx?.CommandAuthorized).toBe(true);
});
it("pins direct-message main route updates to the configured owner", async () => {
const socket = new FakeWebSocket();
const abortController = new AbortController();

View File

@@ -1318,8 +1318,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
cfg,
surface: "mattermost",
});
const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg);
const isControlCommand = allowTextCommands && hasControlCommand;
const isControlCommand =
allowTextCommands && core.channel.commands.isControlCommandMessage(rawText, cfg);
const accessDecision = await resolveMattermostMonitorInboundAccess({
account,
cfg,
@@ -1330,7 +1330,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
groupPolicy,
readStoreAllowFrom: pairing.readAllowFromStore,
allowTextCommands,
hasControlCommand,
hasControlCommand: isControlCommand,
eventKind: "message",
mayPair: true,
});
@@ -2090,7 +2090,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
if (!text) {
return false;
}
return !core.channel.text.hasControlCommand(text, cfg);
return !core.channel.commands.isControlCommandMessage(text, cfg);
},
onFlush: async (entries) => {
const last = entries.at(-1);

View File

@@ -15,6 +15,11 @@ type MSTeamsTestRuntimeOptions = {
recordInboundSession?: ReturnType<typeof vi.fn>;
resolveAgentRoute?: (params: RuntimeRoutePeer) => unknown;
hasControlCommand?: PluginRuntime["channel"]["text"]["hasControlCommand"];
isControlCommandMessage?: PluginRuntime["channel"]["commands"]["isControlCommandMessage"];
shouldComputeCommandAuthorized?: PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"];
shouldHandleTextCommands?: PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"];
createInboundDebouncer?: PluginRuntime["channel"]["debounce"]["createInboundDebouncer"];
resolveInboundDebounceMs?: PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"];
resolveTextChunkLimit?: () => number;
resolveStorePath?: () => string;
};
@@ -66,19 +71,30 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {
system: { enqueueSystemEvent: options.enqueueSystemEvent ?? vi.fn() },
channel: {
debounce: {
resolveInboundDebounceMs: () => 0,
createInboundDebouncer: <T>(params: {
onFlush: (entries: T[]) => Promise<void>;
}): { enqueue: (entry: T) => Promise<void> } => ({
enqueue: async (entry: T) => {
await params.onFlush([entry]);
},
}),
resolveInboundDebounceMs:
options.resolveInboundDebounceMs ??
((() => 0) as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"]),
createInboundDebouncer:
options.createInboundDebouncer ??
(<T>(params: {
onFlush: (entries: T[]) => Promise<void>;
}): { enqueue: (entry: T) => Promise<void> } => ({
enqueue: async (entry: T) => {
await params.onFlush([entry]);
},
})),
},
pairing: {
readAllowFromStore: options.readAllowFromStore ?? vi.fn(async () => []),
upsertPairingRequest: options.upsertPairingRequest ?? vi.fn(async () => null),
},
commands: {
isControlCommandMessage:
options.isControlCommandMessage ?? options.hasControlCommand ?? (() => false),
shouldComputeCommandAuthorized:
options.shouldComputeCommandAuthorized ?? options.hasControlCommand ?? (() => false),
shouldHandleTextCommands: options.shouldHandleTextCommands ?? (() => true),
},
text: {
hasControlCommand: options.hasControlCommand ?? (() => false),
resolveChunkMode: () => "length",

View File

@@ -1,3 +1,4 @@
import { createInboundDebouncer } from "openclaw/plugin-sdk/channel-inbound-debounce";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../../runtime-api.js";
import type { GraphThreadMessage } from "../graph-thread.js";
@@ -84,6 +85,11 @@ describe("msteams monitor handler authz", () => {
cfg: OpenClawConfig,
options: {
hasControlCommand?: PluginRuntime["channel"]["text"]["hasControlCommand"];
isControlCommandMessage?: PluginRuntime["channel"]["commands"]["isControlCommandMessage"];
shouldComputeCommandAuthorized?: PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"];
shouldHandleTextCommands?: PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"];
createInboundDebouncer?: PluginRuntime["channel"]["debounce"]["createInboundDebouncer"];
resolveInboundDebounceMs?: PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"];
} = {},
) {
const readAllowFromStore = vi.fn(async () => ["attacker-aad"]);
@@ -100,6 +106,11 @@ describe("msteams monitor handler authz", () => {
accountId: "default",
})),
hasControlCommand: options.hasControlCommand,
isControlCommandMessage: options.isControlCommandMessage,
shouldComputeCommandAuthorized: options.shouldComputeCommandAuthorized,
shouldHandleTextCommands: options.shouldHandleTextCommands,
createInboundDebouncer: options.createInboundDebouncer,
resolveInboundDebounceMs: options.resolveInboundDebounceMs,
});
}
@@ -606,6 +617,80 @@ describe("msteams monitor handler authz", () => {
expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).not.toHaveBeenCalled();
});
it("does not drop inline command-looking group text from non-command-authorized senders", async () => {
resetThreadMocks();
const isControlCommandMessage = vi.fn(() => false);
const shouldComputeCommandAuthorized = vi.fn(() => true);
const { deps } = createDeps(
{
commands: { useAccessGroups: true },
channels: {
msteams: {
groupPolicy: "open",
requireMention: false,
},
},
} as OpenClawConfig,
{
isControlCommandMessage,
shouldComputeCommandAuthorized,
},
);
const handler = createMSTeamsMessageHandler(deps);
await handler(createAttackerGroupActivity({ text: "hello /status" }));
expect(isControlCommandMessage).toHaveBeenCalledWith("hello /status", deps.cfg);
expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).toHaveBeenCalledTimes(
1,
);
const dispatched = firstSettledDispatch();
const ctxPayload = recordFromMockCall(dispatched.ctxPayload);
expect(ctxPayload.BodyForAgent).toBe("hello /status");
expect(ctxPayload.CommandAuthorized).toBe(false);
});
it("flushes pending group text before authorizing a bare abort without a mention", async () => {
resetThreadMocks();
const isBareAbort = vi.fn((text?: string) =>
["abort", "stop"].includes(text?.trim().toLowerCase() ?? ""),
);
const { deps } = createDeps(
{
commands: { useAccessGroups: false },
messages: { inbound: { debounceMs: 60_000 } },
channels: {
msteams: {
groupPolicy: "open",
requireMention: true,
},
},
} as OpenClawConfig,
{
hasControlCommand: vi.fn(() => false),
isControlCommandMessage: isBareAbort,
shouldComputeCommandAuthorized: isBareAbort,
shouldHandleTextCommands: vi.fn(() => true),
createInboundDebouncer,
resolveInboundDebounceMs: vi.fn(() => 60_000),
},
);
const handler = createMSTeamsMessageHandler(deps);
await handler(createAttackerGroupActivity({ text: "pending text" }));
expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).not.toHaveBeenCalled();
await handler(createAttackerGroupActivity({ text: "abort" }));
expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).toHaveBeenCalledTimes(
1,
);
const dispatched = firstSettledDispatch();
const ctxPayload = recordFromMockCall(dispatched.ctxPayload);
expect(ctxPayload.BodyForAgent).toBe("abort");
expect(ctxPayload.CommandAuthorized).toBe(true);
});
it("marks skipped channel message system events as non-owner", async () => {
resetThreadMocks();
const { deps, enqueueSystemEvent } = createDeps({

View File

@@ -12,6 +12,11 @@ type MessageHandlerDepsOptions = {
recordInboundSession?: ReturnType<typeof vi.fn>;
resolveAgentRoute?: (params: { peer: { kind: string; id: string } }) => unknown;
hasControlCommand?: PluginRuntime["channel"]["text"]["hasControlCommand"];
isControlCommandMessage?: PluginRuntime["channel"]["commands"]["isControlCommandMessage"];
shouldComputeCommandAuthorized?: PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"];
shouldHandleTextCommands?: PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"];
createInboundDebouncer?: PluginRuntime["channel"]["debounce"]["createInboundDebouncer"];
resolveInboundDebounceMs?: PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"];
};
export function createMessageHandlerDeps(
@@ -41,6 +46,11 @@ export function createMessageHandlerDeps(
recordInboundSession,
resolveAgentRoute,
hasControlCommand: options.hasControlCommand,
isControlCommandMessage: options.isControlCommandMessage,
shouldComputeCommandAuthorized: options.shouldComputeCommandAuthorized,
shouldHandleTextCommands: options.shouldHandleTextCommands,
createInboundDebouncer: options.createInboundDebouncer,
resolveInboundDebounceMs: options.resolveInboundDebounceMs,
resolveTextChunkLimit: () => 4000,
resolveStorePath: () => "/tmp/test-store",
});

View File

@@ -281,6 +281,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
threadId,
});
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg,
surface: "msteams",
});
const isControlCommand =
allowTextCommands && core.channel.commands.isControlCommandMessage(text, cfg);
const {
dmPolicy,
senderId,
@@ -295,7 +301,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
} = await resolveMSTeamsSenderAccess({
cfg,
activity,
hasControlCommand: core.channel.text.hasControlCommand(text, cfg),
hasControlCommand: isControlCommand,
});
const commandAuthorized = commandAccess.requested ? commandAccess.authorized : undefined;
const effectiveDmAllowFrom = senderAccess.effectiveAllowFrom;
@@ -522,9 +528,9 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
policy: {
isGroup: !isDirectMessage,
requireMention,
allowTextCommands: false,
hasControlCommand: false,
commandAuthorized: false,
allowTextCommands,
hasControlCommand: isControlCommand,
commandAuthorized: commandAuthorized === true,
},
});
@@ -938,7 +944,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
if (entry.attachments.length > 0) {
return false;
}
return !core.channel.text.hasControlCommand(entry.text, cfg);
return !core.channel.commands.isControlCommandMessage(entry.text, cfg);
},
onFlush: async (entries) => {
const last = entries.at(-1);

View File

@@ -1,7 +1,7 @@
import { resolveAccountEntry } from "openclaw/plugin-sdk/account-core";
import { resolveInboundDebounceMs } from "openclaw/plugin-sdk/channel-inbound-debounce";
import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime";
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
import { isControlCommandMessage } from "openclaw/plugin-sdk/command-detection";
import { drainPendingDeliveries } from "openclaw/plugin-sdk/delivery-queue-runtime";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
@@ -299,7 +299,7 @@ export async function monitorWebChannel(
if (msg.replyToId || msg.replyToBody) {
return false;
}
return !hasControlCommand(msg.body, cfg);
return !isControlCommandMessage(msg.body, cfg);
};
let connection;

View File

@@ -11,6 +11,9 @@ describe("shouldDebounceTextInbound", () => {
expect(shouldDebounceTextInbound({ text: " ", cfg })).toBe(false);
expect(shouldDebounceTextInbound({ text: "hello", cfg, hasMedia: true })).toBe(false);
expect(shouldDebounceTextInbound({ text: "/status", cfg })).toBe(false);
expect(shouldDebounceTextInbound({ text: "stop", cfg })).toBe(false);
expect(shouldDebounceTextInbound({ text: "abort", cfg })).toBe(false);
expect(shouldDebounceTextInbound({ text: "wait", cfg })).toBe(false);
});
it("accepts normal text when debounce is allowed", () => {

View File

@@ -1,4 +1,4 @@
import { hasControlCommand } from "../auto-reply/command-detection.js";
import { isControlCommandMessage } from "../auto-reply/command-detection.js";
import type { CommandNormalizeOptions } from "../auto-reply/commands-registry.js";
import {
createInboundDebouncer,
@@ -25,7 +25,7 @@ export function shouldDebounceTextInbound(params: {
if (!text) {
return false;
}
return !hasControlCommand(text, params.cfg, params.commandOptions);
return !isControlCommandMessage(text, params.cfg, params.commandOptions);
}
export function createChannelInboundDebouncer<T>(