惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Jina AI
Jina AI
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
有赞技术团队
有赞技术团队
罗磊的独立博客
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
U
Unit 42
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Recent Announcements
Recent Announcements
Y
Y Combinator Blog
Vercel News
Vercel News
Martin Fowler
Martin Fowler
V
V2EX
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
L
LangChain Blog
云风的 BLOG
云风的 BLOG
H
Hackread – Cybersecurity News, Data Breaches, AI and More
aimingoo的专栏
aimingoo的专栏
G
Google Developers Blog
The GitHub Blog
The GitHub Blog
N
Netflix TechBlog - Medium
Google DeepMind News
Google DeepMind News
雷峰网
雷峰网
阮一峰的网络日志
阮一峰的网络日志
F
Fortinet All Blogs

Recent Commits to openclaw:main

test: merge chat side-result checks · openclaw/openclaw@ddd2c2a test: merge cron history checks · openclaw/openclaw@f7eb746 test: merge responsive navigation shell checks · openclaw/openclaw@c2e4b47 docs(changelog): add codex oauth fixes · openclaw/openclaw@628e6cd test: merge navigation routing cases · openclaw/openclaw@5d8cecb Tests: mock channel registry bundled fallback · openclaw/openclaw@2b08233 Secrets: avoid broad web search discovery for single plugin config · openclaw/openclaw@a464f59 test: merge config view browser checks · openclaw/openclaw@20cf511 fix(status): align oauth health with runtime · openclaw/openclaw@eed7116 feat: add macOS screen snapshots for monitor preview (#67954) thanks … · openclaw/openclaw@f377db1 fix: report shared auth scopes in hello-ok (#67810) thanks @BunsDev · openclaw/openclaw@0b6c39b Auto-reply: avoid eager bundled route fallback · openclaw/openclaw@3ea1bf4 Tests: narrow session binding contract setup · openclaw/openclaw@54e4e16 fix(macOS): enable undo/redo in webchat composer text input (#34962) · openclaw/openclaw@00951dc Tests: speed up channel setup promotion · openclaw/openclaw@82b529a Docs: refresh agent instructions · openclaw/openclaw@5775fe2 fix(auth): serialize OAuth refresh across agents to fix #26322 (#67876) · openclaw/openclaw@8e79080 test: allow ollama public surface boundary test · openclaw/openclaw@7d4f1a6 Docs: add test performance guardrails · openclaw/openclaw@89706d3 Tests: restore context-engine usage proof · openclaw/openclaw@e4c4f95 Tests: slim context engine runtime coverage · openclaw/openclaw@74c198f ci: retry failed custom checkouts · openclaw/openclaw@0ee5baf test: trim duplicate provider auth onboarding cases · openclaw/openclaw@1ffc02e matrix: fix sessions_spawn --thread subagent session spawning (#67643) · openclaw/openclaw@1ce2596 test: reduce auth choice fixture churn · openclaw/openclaw@857b9cd test: mock health status config boundaries · openclaw/openclaw@9d5ab4a test: mock onboard config io boundary · openclaw/openclaw@299694d test: mock legacy state plugin boundaries · openclaw/openclaw@2713089 test: mock channel install boundaries · openclaw/openclaw@b945248 test: mock doctor preview channel boundaries · openclaw/openclaw@b1a3ad4
fix(mattermost): avoid system events for user posts · ope...
steipete · 2026-04-28 · via Recent Commits to openclaw:main

@@ -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+

});