diff --git a/CHANGELOG.md b/CHANGELOG.md index ff690c76b27..7138172b10e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/feishu/src/monitor.message-handler.ts b/extensions/feishu/src/monitor.message-handler.ts index 3df9cbbed0d..350c322dd08 100644 --- a/extensions/feishu/src/monitor.message-handler.ts +++ b/extensions/feishu/src/monitor.message-handler.ts @@ -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); diff --git a/extensions/feishu/src/monitor.reaction.test.ts b/extensions/feishu/src/monitor.reaction.test.ts index 00ff5bdac78..99a35bdc0cb 100644 --- a/extensions/feishu/src/monitor.reaction.test.ts +++ b/extensions/feishu/src/monitor.reaction.test.ts @@ -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(); diff --git a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts index ca56f7e1da9..b6cbbc70134 100644 --- a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts @@ -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: (params: { - onFlush: (entries: T[]) => Promise | void; - }) => ({ - enqueue: async (entry: T) => { - await params.onFlush([entry]); - }, - }), + resolveInboundDebounceMs: () => overrides.inboundDebounceMs ?? 0, + createInboundDebouncer: + overrides.createInboundDebouncer ?? + ((params: { onFlush: (entries: T[]) => Promise | 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(); diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index e3deb6c4b4b..8588f60be40 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -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); diff --git a/extensions/msteams/src/monitor-handler.test-helpers.ts b/extensions/msteams/src/monitor-handler.test-helpers.ts index 401ec2ba6e0..633cca16a13 100644 --- a/extensions/msteams/src/monitor-handler.test-helpers.ts +++ b/extensions/msteams/src/monitor-handler.test-helpers.ts @@ -15,6 +15,11 @@ type MSTeamsTestRuntimeOptions = { recordInboundSession?: ReturnType; 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: (params: { - onFlush: (entries: T[]) => Promise; - }): { enqueue: (entry: T) => Promise } => ({ - enqueue: async (entry: T) => { - await params.onFlush([entry]); - }, - }), + resolveInboundDebounceMs: + options.resolveInboundDebounceMs ?? + ((() => 0) as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"]), + createInboundDebouncer: + options.createInboundDebouncer ?? + ((params: { + onFlush: (entries: T[]) => Promise; + }): { enqueue: (entry: T) => Promise } => ({ + 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", diff --git a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts index 763a92b8e7d..e5d3ac9bd14 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts @@ -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({ diff --git a/extensions/msteams/src/monitor-handler/message-handler.test-support.ts b/extensions/msteams/src/monitor-handler/message-handler.test-support.ts index fe13e3f6e22..d3f7679867f 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.test-support.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.test-support.ts @@ -12,6 +12,11 @@ type MessageHandlerDepsOptions = { recordInboundSession?: ReturnType; 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", }); diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 017e3cdbf2c..cc4bfe4612e 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -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); diff --git a/extensions/whatsapp/src/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts index c41e99f4ee5..bc62cbe5101 100644 --- a/extensions/whatsapp/src/auto-reply/monitor.ts +++ b/extensions/whatsapp/src/auto-reply/monitor.ts @@ -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; diff --git a/src/channels/inbound-debounce-policy.test.ts b/src/channels/inbound-debounce-policy.test.ts index 23d406215fb..648b40999eb 100644 --- a/src/channels/inbound-debounce-policy.test.ts +++ b/src/channels/inbound-debounce-policy.test.ts @@ -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", () => { diff --git a/src/channels/inbound-debounce-policy.ts b/src/channels/inbound-debounce-policy.ts index fa0326baa7e..457bd392259 100644 --- a/src/channels/inbound-debounce-policy.ts +++ b/src/channels/inbound-debounce-policy.ts @@ -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(