





















@@ -1,3 +1,4 @@
1+import { createInboundDebouncer } from "openclaw/plugin-sdk/channel-inbound-debounce";
12import { beforeEach, describe, expect, it, vi } from "vitest";
23import { monitorMattermostProvider } from "./monitor.js";
34import type { OpenClawConfig, RuntimeEnv } from "./runtime-api.js";
@@ -148,6 +149,14 @@ function createRuntimeCore(
148149mainSessionKey?: string;
149150sessionKey?: string;
150151},
152+overrides: {
153+inboundDebounceMs?: number;
154+isControlCommandMessage?: (text?: string) => boolean;
155+shouldComputeCommandAuthorized?: (text?: string) => boolean;
156+shouldHandleTextCommands?: () => boolean;
157+textHasControlCommand?: (text?: string) => boolean;
158+createInboundDebouncer?: typeof createInboundDebouncer;
159+} = {},
151160) {
152161const runPrepared = vi.fn(
153162async (turn: {
@@ -230,20 +239,23 @@ function createRuntimeCore(
230239record: vi.fn(),
231240},
232241commands: {
233-shouldHandleTextCommands: () => false,
242+isControlCommandMessage: overrides.isControlCommandMessage ?? (() => false),
243+shouldComputeCommandAuthorized: overrides.shouldComputeCommandAuthorized ?? (() => false),
244+shouldHandleTextCommands: overrides.shouldHandleTextCommands ?? (() => false),
234245},
235246debounce: {
236-resolveInboundDebounceMs: () => 0,
237-createInboundDebouncer: <T>(params: {
238-onFlush: (entries: T[]) => Promise<void> | void;
239-}) => ({
240-enqueue: async (entry: T) => {
241-await params.onFlush([entry]);
242-},
243-}),
247+resolveInboundDebounceMs: () => overrides.inboundDebounceMs ?? 0,
248+createInboundDebouncer:
249+overrides.createInboundDebouncer ??
250+ (<T>(params: { onFlush: (entries: T[]) => Promise<void> | void }) => ({
251+ enqueue: async (entry: T) => {
252+ await params.onFlush([entry]);
253+ },
254+ })),
244255},
245256groups: {
246-resolveRequireMention: () => false,
257+resolveRequireMention: (params: { requireMentionOverride?: boolean }) =>
258+params.requireMentionOverride ?? false,
247259},
248260media: {
249261readRemoteMediaBuffer: vi.fn(),
@@ -316,7 +328,7 @@ function createRuntimeCore(
316328text: {
317329chunkMarkdownTextWithMode: (text: string) => [text],
318330convertMarkdownTables: (text: string) => text,
319-hasControlCommand: () => false,
331+hasControlCommand: overrides.textHasControlCommand ?? (() => false),
320332resolveChunkMode: () => "off",
321333resolveMarkdownTableMode: () => "off",
322334resolveTextChunkLimit: () => 4000,
@@ -432,6 +444,73 @@ describe("mattermost inbound user posts", () => {
432444expect(ctx?.Provider).toBe("mattermost");
433445});
434446447+it("does not drop inline command-looking group text from non-command-authorized senders", async () => {
448+const socket = new FakeWebSocket();
449+const abortController = new AbortController();
450+mockState.abortController = abortController;
451+const inlineCommandConfig: OpenClawConfig = {
452+commands: { useAccessGroups: true },
453+channels: {
454+mattermost: {
455+enabled: true,
456+baseUrl: "https://mattermost.example.com",
457+botToken: "bot-token",
458+chatmode: "onmessage",
459+dmPolicy: "open",
460+groupPolicy: "open",
461+},
462+},
463+};
464+const isControlCommandMessage = vi.fn(() => false);
465+const shouldComputeCommandAuthorized = vi.fn(() => true);
466+mockState.runtimeCore = createRuntimeCore(inlineCommandConfig, undefined, {
467+ isControlCommandMessage,
468+ shouldComputeCommandAuthorized,
469+shouldHandleTextCommands: () => true,
470+});
471+472+const monitor = monitorMattermostProvider({
473+config: inlineCommandConfig,
474+runtime: testRuntime(),
475+abortSignal: abortController.signal,
476+webSocketFactory: () => socket,
477+});
478+479+await vi.waitFor(() => {
480+expect(socket.openListenerCount).toBeGreaterThan(0);
481+});
482+socket.emitOpen();
483+484+await socket.emitMessage({
485+event: "posted",
486+data: {
487+channel_id: "chan-1",
488+channel_name: "town-square",
489+channel_display_name: "Town Square",
490+sender_name: "alice",
491+post: JSON.stringify({
492+id: "post-inline-command",
493+channel_id: "chan-1",
494+user_id: "user-1",
495+message: "hello /status",
496+create_at: 1_714_000_000_000,
497+}),
498+},
499+broadcast: {
500+channel_id: "chan-1",
501+user_id: "user-1",
502+},
503+});
504+socket.emitClose(1000);
505+await monitor;
506+507+expect(isControlCommandMessage).toHaveBeenCalledWith("hello /status", inlineCommandConfig);
508+expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
509+const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
510+expect(ctx?.BodyForAgent).toBe("hello /status");
511+expect(ctx?.CommandAuthorized).toBe(false);
512+});
513+435514it("uses websocket channel type when REST channel lookup fails", async () => {
436515const socket = new FakeWebSocket();
437516const abortController = new AbortController();
@@ -543,6 +622,98 @@ describe("mattermost inbound user posts", () => {
543622expect(runtimeCore.channel.session.recordInboundSession).not.toHaveBeenCalled();
544623});
545624625+it("flushes pending group text before authorizing a bare abort without a mention", async () => {
626+const socket = new FakeWebSocket();
627+const abortController = new AbortController();
628+mockState.abortController = abortController;
629+const mentionConfig: OpenClawConfig = {
630+commands: { useAccessGroups: false },
631+messages: { inbound: { debounceMs: 60_000 } },
632+channels: {
633+mattermost: {
634+enabled: true,
635+baseUrl: "https://mattermost.example.com",
636+botToken: "bot-token",
637+chatmode: "oncall",
638+dmPolicy: "open",
639+groupPolicy: "open",
640+},
641+},
642+};
643+const isBareAbort = (text?: string) => ["abort", "stop"].includes(text?.trim() ?? "");
644+const runtimeCore = createRuntimeCore(mentionConfig, undefined, {
645+inboundDebounceMs: 60_000,
646+ createInboundDebouncer,
647+isControlCommandMessage: isBareAbort,
648+shouldComputeCommandAuthorized: isBareAbort,
649+shouldHandleTextCommands: () => true,
650+textHasControlCommand: () => false,
651+});
652+mockState.runtimeCore = runtimeCore;
653+654+const monitor = monitorMattermostProvider({
655+config: mentionConfig,
656+runtime: testRuntime(),
657+abortSignal: abortController.signal,
658+webSocketFactory: () => socket,
659+});
660+661+await vi.waitFor(() => {
662+expect(socket.openListenerCount).toBeGreaterThan(0);
663+});
664+socket.emitOpen();
665+666+await socket.emitMessage({
667+event: "posted",
668+data: {
669+channel_id: "chan-1",
670+channel_name: "town-square",
671+channel_display_name: "Town Square",
672+sender_name: "alice",
673+post: JSON.stringify({
674+id: "post-pending",
675+channel_id: "chan-1",
676+user_id: "user-1",
677+message: "pending text",
678+create_at: 1_714_000_000_000,
679+}),
680+},
681+broadcast: {
682+channel_id: "chan-1",
683+user_id: "user-1",
684+},
685+});
686+expect(mockState.dispatchReplyFromConfig).not.toHaveBeenCalled();
687+688+await socket.emitMessage({
689+event: "posted",
690+data: {
691+channel_id: "chan-1",
692+channel_name: "town-square",
693+channel_display_name: "Town Square",
694+sender_name: "alice",
695+post: JSON.stringify({
696+id: "post-abort",
697+channel_id: "chan-1",
698+user_id: "user-1",
699+message: "abort",
700+create_at: 1_714_000_000_100,
701+}),
702+},
703+broadcast: {
704+channel_id: "chan-1",
705+user_id: "user-1",
706+},
707+});
708+socket.emitClose(1000);
709+await monitor;
710+711+expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
712+const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
713+expect(ctx?.BodyForAgent).toBe("abort");
714+expect(ctx?.CommandAuthorized).toBe(true);
715+});
716+546717it("pins direct-message main route updates to the configured owner", async () => {
547718const socket = new FakeWebSocket();
548719const abortController = new AbortController();
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。