























@@ -0,0 +1,337 @@
1+import { beforeEach, describe, expect, it, vi } from "vitest";
2+import type { OpenClawConfig, RuntimeEnv } from "./runtime-api.js";
3+4+class FakeWebSocket {
5+public readonly sent: string[] = [];
6+private readonly openListeners: Array<() => void> = [];
7+private readonly messageListeners: Array<(data: Buffer) => void | Promise<void>> = [];
8+private readonly closeListeners: Array<(code: number, reason: Buffer) => void> = [];
9+private readonly errorListeners: Array<(err: unknown) => void> = [];
10+11+on(event: "open", listener: () => void): void;
12+on(event: "message", listener: (data: Buffer) => void | Promise<void>): void;
13+on(event: "close", listener: (code: number, reason: Buffer) => void): void;
14+on(event: "error", listener: (err: unknown) => void): void;
15+on(event: "open" | "message" | "close" | "error", listener: unknown): void {
16+if (event === "open") {
17+this.openListeners.push(listener as () => void);
18+return;
19+}
20+if (event === "message") {
21+this.messageListeners.push(listener as (data: Buffer) => void | Promise<void>);
22+return;
23+}
24+if (event === "close") {
25+this.closeListeners.push(listener as (code: number, reason: Buffer) => void);
26+return;
27+}
28+this.errorListeners.push(listener as (err: unknown) => void);
29+}
30+31+send(data: string): void {
32+this.sent.push(data);
33+}
34+35+close(): void {}
36+37+terminate(): void {}
38+39+get openListenerCount(): number {
40+return this.openListeners.length;
41+}
42+43+emitOpen(): void {
44+for (const listener of this.openListeners) {
45+listener();
46+}
47+}
48+49+async emitMessage(payload: unknown): Promise<void> {
50+const buffer = Buffer.from(JSON.stringify(payload), "utf8");
51+await Promise.all(this.messageListeners.map((listener) => Promise.resolve(listener(buffer))));
52+}
53+54+emitClose(code: number, reason = ""): void {
55+const buffer = Buffer.from(reason, "utf8");
56+for (const listener of this.closeListeners) {
57+listener(code, buffer);
58+}
59+}
60+61+emitError(err: unknown): void {
62+for (const listener of this.errorListeners) {
63+listener(err);
64+}
65+}
66+}
67+68+const mockState = vi.hoisted(() => ({
69+abortController: undefined as AbortController | undefined,
70+createMattermostClient: vi.fn(),
71+createMattermostDraftStream: vi.fn(),
72+dispatchReplyFromConfig: vi.fn(),
73+enqueueSystemEvent: vi.fn(),
74+fetchMattermostMe: vi.fn(),
75+registerMattermostMonitorSlashCommands: vi.fn(),
76+registerPluginHttpRoute: vi.fn(),
77+resolveChannelInfo: vi.fn(),
78+resolveMattermostMedia: vi.fn(),
79+resolveUserInfo: vi.fn(),
80+runtimeCore: undefined as unknown,
81+updateMattermostPost: vi.fn(),
82+}));
83+84+vi.mock("./client.js", async () => {
85+const actual = await vi.importActual<typeof import("./client.js")>("./client.js");
86+return {
87+ ...actual,
88+createMattermostClient: mockState.createMattermostClient,
89+fetchMattermostMe: mockState.fetchMattermostMe,
90+normalizeMattermostBaseUrl: (value: string | undefined) => value?.trim() ?? "",
91+updateMattermostPost: mockState.updateMattermostPost,
92+};
93+});
94+95+vi.mock("./draft-stream.js", () => ({
96+buildMattermostToolStatusText: () => "Working",
97+createMattermostDraftStream: mockState.createMattermostDraftStream,
98+}));
99+100+vi.mock("./monitor-resources.js", () => ({
101+createMattermostMonitorResources: () => ({
102+resolveMattermostMedia: mockState.resolveMattermostMedia,
103+sendTypingIndicator: vi.fn(async () => {}),
104+resolveChannelInfo: mockState.resolveChannelInfo,
105+resolveUserInfo: mockState.resolveUserInfo,
106+updateModelPickerPost: vi.fn(async () => {}),
107+}),
108+}));
109+110+vi.mock("./monitor-slash.js", () => ({
111+registerMattermostMonitorSlashCommands: mockState.registerMattermostMonitorSlashCommands,
112+}));
113+114+vi.mock("./runtime-api.js", async () => {
115+const actual = await vi.importActual<typeof import("./runtime-api.js")>("./runtime-api.js");
116+return {
117+ ...actual,
118+buildAgentMediaPayload: vi.fn(() => ({})),
119+createChannelPairingController: vi.fn(() => ({
120+readStoreForDmPolicy: vi.fn(async () => []),
121+upsertPairingRequest: vi.fn(async () => ({ code: "123456", created: true })),
122+})),
123+createChannelReplyPipeline: vi.fn(() => ({
124+onModelSelected: vi.fn(),
125+typingCallbacks: {},
126+})),
127+readStoreAllowFromForDmPolicy: vi.fn(async () => []),
128+registerPluginHttpRoute: mockState.registerPluginHttpRoute,
129+resolveChannelMediaMaxBytes: vi.fn(() => 8 * 1024 * 1024),
130+warnMissingProviderGroupPolicyFallbackOnce: vi.fn(),
131+};
132+});
133+134+function createRuntimeCore(cfg: OpenClawConfig) {
135+return {
136+config: {
137+current: () => cfg,
138+},
139+logging: {
140+shouldLogVerbose: () => false,
141+getChildLogger: () => ({
142+debug: vi.fn(),
143+info: vi.fn(),
144+warn: vi.fn(),
145+error: vi.fn(),
146+}),
147+},
148+media: {
149+mediaKindFromMime: () => "document",
150+},
151+system: {
152+enqueueSystemEvent: mockState.enqueueSystemEvent,
153+},
154+channel: {
155+activity: {
156+record: vi.fn(),
157+},
158+commands: {
159+shouldHandleTextCommands: () => false,
160+},
161+debounce: {
162+resolveInboundDebounceMs: () => 0,
163+createInboundDebouncer: <T>(params: {
164+onFlush: (entries: T[]) => Promise<void> | void;
165+}) => ({
166+enqueue: async (entry: T) => {
167+await params.onFlush([entry]);
168+},
169+}),
170+},
171+groups: {
172+resolveRequireMention: () => false,
173+},
174+media: {
175+fetchRemoteMedia: vi.fn(),
176+saveMediaBuffer: vi.fn(),
177+},
178+mentions: {
179+buildMentionRegexes: () => [],
180+matchesMentionPatterns: () => false,
181+},
182+pairing: {
183+buildPairingReply: () => "pairing required",
184+},
185+reply: {
186+createReplyDispatcherWithTyping: vi.fn(() => ({
187+dispatcher: {},
188+replyOptions: {},
189+markDispatchIdle: vi.fn(),
190+markRunComplete: vi.fn(),
191+})),
192+dispatchReplyFromConfig: mockState.dispatchReplyFromConfig,
193+finalizeInboundContext: (context: unknown) => context,
194+formatInboundEnvelope: (params: { channel: string; from: string; body: string }) =>
195+`${params.channel} ${params.from}\n${params.body}`,
196+resolveHumanDelayConfig: () => ({}),
197+withReplyDispatcher: async (params: { run: () => unknown; onSettled?: () => void }) => {
198+try {
199+return await params.run();
200+} finally {
201+params.onSettled?.();
202+}
203+},
204+},
205+routing: {
206+resolveAgentRoute: () => ({
207+accountId: "default",
208+agentId: "main",
209+mainSessionKey: "mattermost:default:channel:chan-1",
210+sessionKey: "mattermost:default:channel:chan-1",
211+}),
212+},
213+session: {
214+resolveStorePath: () => "/tmp/openclaw-test-sessions.json",
215+updateLastRoute: vi.fn(async () => {}),
216+},
217+text: {
218+chunkMarkdownTextWithMode: (text: string) => [text],
219+convertMarkdownTables: (text: string) => text,
220+hasControlCommand: () => false,
221+resolveChunkMode: () => "off",
222+resolveMarkdownTableMode: () => "off",
223+resolveTextChunkLimit: () => 4000,
224+},
225+},
226+};
227+}
228+229+const testConfig: OpenClawConfig = {
230+channels: {
231+mattermost: {
232+enabled: true,
233+baseUrl: "https://mattermost.example.com",
234+botToken: "bot-token",
235+chatmode: "onmessage",
236+dmPolicy: "open",
237+groupPolicy: "open",
238+},
239+},
240+};
241+242+vi.mock("../runtime.js", () => ({
243+getMattermostRuntime: () => mockState.runtimeCore,
244+}));
245+246+const testRuntime = (): RuntimeEnv =>
247+({
248+log: vi.fn(),
249+error: vi.fn(),
250+exit: ((code: number): never => {
251+throw new Error(`exit ${code}`);
252+}) as RuntimeEnv["exit"],
253+}) satisfies RuntimeEnv;
254+255+describe("mattermost inbound user posts", () => {
256+beforeEach(() => {
257+vi.clearAllMocks();
258+mockState.abortController = undefined;
259+mockState.runtimeCore = createRuntimeCore(testConfig);
260+mockState.createMattermostClient.mockReturnValue({});
261+mockState.createMattermostDraftStream.mockReturnValue({
262+update: vi.fn(),
263+stop: vi.fn(async () => {}),
264+});
265+mockState.fetchMattermostMe.mockResolvedValue({
266+id: "bot-user",
267+username: "openclaw",
268+update_at: 1,
269+});
270+mockState.registerMattermostMonitorSlashCommands.mockResolvedValue(undefined);
271+mockState.registerPluginHttpRoute.mockReturnValue(vi.fn());
272+mockState.resolveChannelInfo.mockResolvedValue({
273+id: "chan-1",
274+name: "town-square",
275+display_name: "Town Square",
276+team_id: "team-1",
277+type: "O",
278+});
279+mockState.resolveMattermostMedia.mockResolvedValue([]);
280+mockState.resolveUserInfo.mockResolvedValue({ id: "user-1", username: "alice" });
281+mockState.dispatchReplyFromConfig.mockImplementation(async () => {
282+mockState.abortController?.abort();
283+});
284+});
285+286+it("does not enqueue regular user posts as system events", async () => {
287+const socket = new FakeWebSocket();
288+const abortController = new AbortController();
289+mockState.abortController = abortController;
290+const { monitorMattermostProvider } = await import("./monitor.js");
291+292+const monitor = monitorMattermostProvider({
293+config: testConfig,
294+runtime: testRuntime(),
295+abortSignal: abortController.signal,
296+webSocketFactory: () => socket,
297+});
298+299+await vi.waitFor(() => {
300+expect(socket.openListenerCount).toBeGreaterThan(0);
301+});
302+socket.emitOpen();
303+304+await socket.emitMessage({
305+event: "posted",
306+data: {
307+channel_id: "chan-1",
308+channel_name: "town-square",
309+channel_display_name: "Town Square",
310+sender_name: "alice",
311+post: JSON.stringify({
312+id: "post-1",
313+channel_id: "chan-1",
314+user_id: "user-1",
315+message: "hello from mattermost",
316+create_at: 1_714_000_000_000,
317+}),
318+},
319+broadcast: {
320+channel_id: "chan-1",
321+user_id: "user-1",
322+},
323+});
324+socket.emitClose(1000);
325+await monitor;
326+327+expect(mockState.enqueueSystemEvent).not.toHaveBeenCalled();
328+expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
329+expect(mockState.dispatchReplyFromConfig.mock.calls[0]?.[0].ctx).toMatchObject({
330+BodyForAgent: "hello from mattermost",
331+ConversationLabel: "Town Square id:chan-1",
332+MessageSid: "post-1",
333+OriginatingChannel: "mattermost",
334+Provider: "mattermost",
335+});
336+});
337+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。