mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 01:42:59 +00:00
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 headc96bf84270. - Required merge gates passed before the squash merge. Prepared head SHA:c96bf84270Review: 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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>(
|
||||
|
||||
Reference in New Issue
Block a user