@feihan-im/openclaw-plugin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.en.md +112 -0
- package/README.md +112 -0
- package/dist/index.cjs +650 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +62 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.js +627 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
- package/src/channel.ts +61 -0
- package/src/config.test.ts +251 -0
- package/src/config.ts +162 -0
- package/src/core/feihan-client.test.ts +140 -0
- package/src/core/feihan-client.ts +319 -0
- package/src/index.test.ts +164 -0
- package/src/index.ts +112 -0
- package/src/messaging/inbound.test.ts +560 -0
- package/src/messaging/inbound.ts +396 -0
- package/src/messaging/outbound.test.ts +172 -0
- package/src/messaging/outbound.ts +176 -0
- package/src/targets.test.ts +91 -0
- package/src/targets.ts +41 -0
- package/src/types.test.ts +10 -0
- package/src/types.ts +115 -0
- package/src/typings/feihan-sdk.d.ts +23 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
// Copyright (c) 2026 上海飞函安全科技有限公司 (Shanghai Feihan Security Technology Co., Ltd.)
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
parseMessageEvent,
|
|
7
|
+
checkMessageGate,
|
|
8
|
+
isBotMentioned,
|
|
9
|
+
isDuplicate,
|
|
10
|
+
_resetDedup,
|
|
11
|
+
buildContext,
|
|
12
|
+
processInboundMessage,
|
|
13
|
+
} from "./inbound.js";
|
|
14
|
+
import type {
|
|
15
|
+
FeihanAccountConfig,
|
|
16
|
+
FeihanMessageEvent,
|
|
17
|
+
PluginApi,
|
|
18
|
+
} from "../types.js";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
function makeAccount(
|
|
25
|
+
overrides: Partial<FeihanAccountConfig> = {},
|
|
26
|
+
): FeihanAccountConfig {
|
|
27
|
+
return {
|
|
28
|
+
accountId: "test-account",
|
|
29
|
+
enabled: true,
|
|
30
|
+
appId: "app_test",
|
|
31
|
+
appSecret: "secret",
|
|
32
|
+
backendUrl: "https://api.feihan.test",
|
|
33
|
+
enableEncryption: true,
|
|
34
|
+
requestTimeout: 30_000,
|
|
35
|
+
requireMention: true,
|
|
36
|
+
botUserId: "bot_user_001",
|
|
37
|
+
inboundWhitelist: [],
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeEvent(
|
|
43
|
+
overrides: Partial<FeihanMessageEvent["message"]> = {},
|
|
44
|
+
): FeihanMessageEvent {
|
|
45
|
+
return {
|
|
46
|
+
message: {
|
|
47
|
+
messageId: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
48
|
+
messageType: "text",
|
|
49
|
+
messageContent: { text: "hello world" },
|
|
50
|
+
chatId: "chat_001",
|
|
51
|
+
chatType: "direct",
|
|
52
|
+
sender: { userId: "user_sender_001" },
|
|
53
|
+
createdAt: Date.now(),
|
|
54
|
+
...overrides,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeMockApi(): PluginApi {
|
|
60
|
+
return {
|
|
61
|
+
registerChannel: vi.fn(),
|
|
62
|
+
registerService: vi.fn(),
|
|
63
|
+
config: { channels: { feihan: {} } },
|
|
64
|
+
runtime: {
|
|
65
|
+
channel: {
|
|
66
|
+
reply: {
|
|
67
|
+
dispatchReplyWithBufferedBlockDispatcher: vi.fn().mockResolvedValue(undefined),
|
|
68
|
+
},
|
|
69
|
+
session: {
|
|
70
|
+
recordInboundSession: vi.fn().mockResolvedValue(undefined),
|
|
71
|
+
resolveStorePath: vi.fn().mockReturnValue("/tmp/store"),
|
|
72
|
+
},
|
|
73
|
+
routing: {
|
|
74
|
+
resolveAgentRoute: vi.fn(),
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
logger: {
|
|
79
|
+
info: vi.fn(),
|
|
80
|
+
warn: vi.fn(),
|
|
81
|
+
error: vi.fn(),
|
|
82
|
+
debug: vi.fn(),
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// parseMessageEvent
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
describe("parseMessageEvent", () => {
|
|
92
|
+
it("parses a text message with object content", () => {
|
|
93
|
+
const event = makeEvent({
|
|
94
|
+
messageContent: { text: "Hello from Feihan" },
|
|
95
|
+
});
|
|
96
|
+
const result = parseMessageEvent(event);
|
|
97
|
+
expect(result).not.toBeNull();
|
|
98
|
+
expect(result!.body).toBe("Hello from Feihan");
|
|
99
|
+
expect(result!.chatType).toBe("direct");
|
|
100
|
+
expect(result!.senderId).toBe("user_sender_001");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("parses a text message with JSON-encoded string content", () => {
|
|
104
|
+
const event = makeEvent({
|
|
105
|
+
messageContent: JSON.stringify({ text: "JSON body" }),
|
|
106
|
+
});
|
|
107
|
+
const result = parseMessageEvent(event);
|
|
108
|
+
expect(result).not.toBeNull();
|
|
109
|
+
expect(result!.body).toBe("JSON body");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("parses a text message with content field in JSON string", () => {
|
|
113
|
+
const event = makeEvent({
|
|
114
|
+
messageContent: JSON.stringify({ content: "alt field" }),
|
|
115
|
+
});
|
|
116
|
+
const result = parseMessageEvent(event);
|
|
117
|
+
expect(result!.body).toBe("alt field");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("parses a text message with plain string content", () => {
|
|
121
|
+
const event = makeEvent({
|
|
122
|
+
messageContent: "plain string body",
|
|
123
|
+
});
|
|
124
|
+
const result = parseMessageEvent(event);
|
|
125
|
+
expect(result!.body).toBe("plain string body");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns null for non-text message types", () => {
|
|
129
|
+
const event = makeEvent({ messageType: "image" });
|
|
130
|
+
expect(parseMessageEvent(event)).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("returns null for empty text body", () => {
|
|
134
|
+
const event = makeEvent({ messageContent: { text: " " } });
|
|
135
|
+
expect(parseMessageEvent(event)).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("returns null for missing messageId", () => {
|
|
139
|
+
const event = makeEvent({ messageId: "" });
|
|
140
|
+
expect(parseMessageEvent(event)).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("returns null for missing chatId", () => {
|
|
144
|
+
const event = makeEvent({ chatId: "" });
|
|
145
|
+
expect(parseMessageEvent(event)).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns null for missing sender", () => {
|
|
149
|
+
const event = makeEvent({
|
|
150
|
+
sender: { userId: "" },
|
|
151
|
+
});
|
|
152
|
+
// userId is empty string, which is falsy
|
|
153
|
+
expect(parseMessageEvent(event)).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("extracts mention user IDs", () => {
|
|
157
|
+
const event = makeEvent({
|
|
158
|
+
mentionUserList: [
|
|
159
|
+
{ userId: "u1" },
|
|
160
|
+
{ userId: "u2", openUserId: "ou2" },
|
|
161
|
+
],
|
|
162
|
+
});
|
|
163
|
+
const result = parseMessageEvent(event);
|
|
164
|
+
expect(result!.mentionUserIds).toEqual(["u1", "u2"]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("falls back to openUserId for sender when userId is missing", () => {
|
|
168
|
+
const event = makeEvent();
|
|
169
|
+
// Simulate a sender with only openUserId (override at runtime)
|
|
170
|
+
(event.message.sender as unknown as Record<string, unknown>).userId = undefined;
|
|
171
|
+
(event.message.sender as unknown as Record<string, unknown>).openUserId = "open_sender_001";
|
|
172
|
+
const result = parseMessageEvent(event);
|
|
173
|
+
expect(result).not.toBeNull();
|
|
174
|
+
expect(result!.senderId).toBe("open_sender_001");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("normalizes group chatType", () => {
|
|
178
|
+
const event = makeEvent({ chatType: "group" });
|
|
179
|
+
expect(parseMessageEvent(event)!.chatType).toBe("group");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("normalizes non-group chatType to direct", () => {
|
|
183
|
+
const event = makeEvent({ chatType: "p2p" });
|
|
184
|
+
expect(parseMessageEvent(event)!.chatType).toBe("direct");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("returns null for null/undefined content", () => {
|
|
188
|
+
const event = makeEvent({ messageContent: null as unknown });
|
|
189
|
+
expect(parseMessageEvent(event)).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// checkMessageGate
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
describe("checkMessageGate", () => {
|
|
198
|
+
it("passes a normal direct message", () => {
|
|
199
|
+
const msg = parseMessageEvent(makeEvent())!;
|
|
200
|
+
const account = makeAccount({ requireMention: false });
|
|
201
|
+
const result = checkMessageGate(msg, account);
|
|
202
|
+
expect(result.pass).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("blocks self-messages", () => {
|
|
206
|
+
const event = makeEvent({
|
|
207
|
+
sender: { userId: "bot_user_001" },
|
|
208
|
+
});
|
|
209
|
+
const msg = parseMessageEvent(event)!;
|
|
210
|
+
const account = makeAccount();
|
|
211
|
+
const result = checkMessageGate(msg, account);
|
|
212
|
+
expect(result.pass).toBe(false);
|
|
213
|
+
expect(result.reason).toBe("self-message");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("blocks sender not in whitelist when whitelist is set", () => {
|
|
217
|
+
const msg = parseMessageEvent(makeEvent())!;
|
|
218
|
+
const account = makeAccount({
|
|
219
|
+
inboundWhitelist: ["allowed_user"],
|
|
220
|
+
});
|
|
221
|
+
const result = checkMessageGate(msg, account);
|
|
222
|
+
expect(result.pass).toBe(false);
|
|
223
|
+
expect(result.reason).toBe("sender-not-in-whitelist");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("passes sender in whitelist", () => {
|
|
227
|
+
const msg = parseMessageEvent(makeEvent())!;
|
|
228
|
+
const account = makeAccount({
|
|
229
|
+
inboundWhitelist: ["user_sender_001"],
|
|
230
|
+
requireMention: false,
|
|
231
|
+
});
|
|
232
|
+
const result = checkMessageGate(msg, account);
|
|
233
|
+
expect(result.pass).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("blocks group message without mention when requireMention is true", () => {
|
|
237
|
+
const event = makeEvent({ chatType: "group" });
|
|
238
|
+
const msg = parseMessageEvent(event)!;
|
|
239
|
+
const account = makeAccount({ requireMention: true });
|
|
240
|
+
const result = checkMessageGate(msg, account);
|
|
241
|
+
expect(result.pass).toBe(false);
|
|
242
|
+
expect(result.reason).toBe("group-no-mention");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("passes group message with mention", () => {
|
|
246
|
+
const event = makeEvent({
|
|
247
|
+
chatType: "group",
|
|
248
|
+
mentionUserList: [{ userId: "bot_user_001" }],
|
|
249
|
+
});
|
|
250
|
+
const msg = parseMessageEvent(event)!;
|
|
251
|
+
const account = makeAccount({ requireMention: true });
|
|
252
|
+
const result = checkMessageGate(msg, account);
|
|
253
|
+
expect(result.pass).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("passes group message when requireMention is false", () => {
|
|
257
|
+
const event = makeEvent({ chatType: "group" });
|
|
258
|
+
const msg = parseMessageEvent(event)!;
|
|
259
|
+
const account = makeAccount({ requireMention: false });
|
|
260
|
+
const result = checkMessageGate(msg, account);
|
|
261
|
+
expect(result.pass).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("skips self-message check when botUserId is not set", () => {
|
|
265
|
+
const event = makeEvent({
|
|
266
|
+
sender: { userId: "any_user" },
|
|
267
|
+
});
|
|
268
|
+
const msg = parseMessageEvent(event)!;
|
|
269
|
+
const account = makeAccount({ botUserId: undefined });
|
|
270
|
+
const result = checkMessageGate(msg, account);
|
|
271
|
+
expect(result.pass).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// isBotMentioned
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
describe("isBotMentioned", () => {
|
|
280
|
+
it("returns true when bot is in mentionUserIds", () => {
|
|
281
|
+
const event = makeEvent({
|
|
282
|
+
mentionUserList: [{ userId: "bot_user_001" }],
|
|
283
|
+
});
|
|
284
|
+
const msg = parseMessageEvent(event)!;
|
|
285
|
+
const account = makeAccount();
|
|
286
|
+
expect(isBotMentioned(msg, account)).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("returns false when bot is not mentioned", () => {
|
|
290
|
+
const event = makeEvent({
|
|
291
|
+
mentionUserList: [{ userId: "other_user" }],
|
|
292
|
+
});
|
|
293
|
+
const msg = parseMessageEvent(event)!;
|
|
294
|
+
const account = makeAccount();
|
|
295
|
+
expect(isBotMentioned(msg, account)).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("returns false when botUserId is not set", () => {
|
|
299
|
+
const event = makeEvent({
|
|
300
|
+
mentionUserList: [{ userId: "bot_user_001" }],
|
|
301
|
+
});
|
|
302
|
+
const msg = parseMessageEvent(event)!;
|
|
303
|
+
const account = makeAccount({ botUserId: undefined });
|
|
304
|
+
expect(isBotMentioned(msg, account)).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// isDuplicate / dedup
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
describe("isDuplicate", () => {
|
|
313
|
+
beforeEach(() => {
|
|
314
|
+
_resetDedup();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("returns false for first occurrence", () => {
|
|
318
|
+
expect(isDuplicate("msg_1")).toBe(false);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("returns true for second occurrence", () => {
|
|
322
|
+
isDuplicate("msg_2");
|
|
323
|
+
expect(isDuplicate("msg_2")).toBe(true);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("tracks multiple independent messages", () => {
|
|
327
|
+
expect(isDuplicate("msg_a")).toBe(false);
|
|
328
|
+
expect(isDuplicate("msg_b")).toBe(false);
|
|
329
|
+
expect(isDuplicate("msg_a")).toBe(true);
|
|
330
|
+
expect(isDuplicate("msg_b")).toBe(true);
|
|
331
|
+
expect(isDuplicate("msg_c")).toBe(false);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// buildContext
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
describe("buildContext", () => {
|
|
340
|
+
it("builds context for a direct message", () => {
|
|
341
|
+
const msg = parseMessageEvent(makeEvent())!;
|
|
342
|
+
const account = makeAccount();
|
|
343
|
+
const ctx = buildContext(msg, account);
|
|
344
|
+
|
|
345
|
+
expect(ctx.Provider).toBe("feihan");
|
|
346
|
+
expect(ctx.Surface).toBe("feihan");
|
|
347
|
+
expect(ctx.ChatType).toBe("direct");
|
|
348
|
+
expect(ctx.AccountId).toBe("test-account");
|
|
349
|
+
expect(ctx.Body).toBe("hello world");
|
|
350
|
+
expect(ctx.From).toBe("feihan:user:user_sender_001");
|
|
351
|
+
expect(ctx.To).toBe("feihan:bot:bot_user_001");
|
|
352
|
+
expect(ctx.SessionKey).toBe("feihan:user:user_sender_001");
|
|
353
|
+
expect(ctx.CommandAuthorized).toBe(true);
|
|
354
|
+
expect(ctx._feihan.isGroup).toBe(false);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("builds context for a group message", () => {
|
|
358
|
+
const event = makeEvent({ chatType: "group", chatId: "chat_group_1" });
|
|
359
|
+
const msg = parseMessageEvent(event)!;
|
|
360
|
+
const account = makeAccount();
|
|
361
|
+
const ctx = buildContext(msg, account);
|
|
362
|
+
|
|
363
|
+
expect(ctx.ChatType).toBe("group");
|
|
364
|
+
expect(ctx.From).toBe("feihan:chat:chat_group_1");
|
|
365
|
+
expect(ctx.SessionKey).toBe("feihan:chat:chat_group_1");
|
|
366
|
+
expect(ctx._feihan.isGroup).toBe(true);
|
|
367
|
+
expect(ctx._feihan.chatId).toBe("chat_group_1");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("falls back to appId when botUserId is not set", () => {
|
|
371
|
+
const msg = parseMessageEvent(makeEvent())!;
|
|
372
|
+
const account = makeAccount({ botUserId: undefined });
|
|
373
|
+
const ctx = buildContext(msg, account);
|
|
374
|
+
expect(ctx.To).toBe("feihan:bot:app_test");
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// processInboundMessage (integration)
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
describe("processInboundMessage", () => {
|
|
383
|
+
beforeEach(() => {
|
|
384
|
+
_resetDedup();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("dispatches a valid direct message", async () => {
|
|
388
|
+
const api = makeMockApi();
|
|
389
|
+
const account = makeAccount({ requireMention: false });
|
|
390
|
+
const event = makeEvent();
|
|
391
|
+
const deliver = vi.fn().mockResolvedValue(undefined);
|
|
392
|
+
|
|
393
|
+
const result = await processInboundMessage(api, account, event, { deliver });
|
|
394
|
+
|
|
395
|
+
expect(result).toBe(true);
|
|
396
|
+
expect(api.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
|
|
397
|
+
expect(api.runtime.channel.session.recordInboundSession).toHaveBeenCalledOnce();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("filters self-messages", async () => {
|
|
401
|
+
const api = makeMockApi();
|
|
402
|
+
const account = makeAccount();
|
|
403
|
+
const event = makeEvent({ sender: { userId: "bot_user_001" } });
|
|
404
|
+
const deliver = vi.fn();
|
|
405
|
+
|
|
406
|
+
const result = await processInboundMessage(api, account, event, { deliver });
|
|
407
|
+
|
|
408
|
+
expect(result).toBe(false);
|
|
409
|
+
expect(api.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("filters duplicate messages", async () => {
|
|
413
|
+
const api = makeMockApi();
|
|
414
|
+
const account = makeAccount({ requireMention: false });
|
|
415
|
+
const msgId = "msg_dedup_test";
|
|
416
|
+
const event = makeEvent({ messageId: msgId });
|
|
417
|
+
const deliver = vi.fn();
|
|
418
|
+
|
|
419
|
+
const first = await processInboundMessage(api, account, event, { deliver });
|
|
420
|
+
expect(first).toBe(true);
|
|
421
|
+
|
|
422
|
+
const second = await processInboundMessage(api, account, event, { deliver });
|
|
423
|
+
expect(second).toBe(false);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("filters group messages without mention when required", async () => {
|
|
427
|
+
const api = makeMockApi();
|
|
428
|
+
const account = makeAccount({ requireMention: true });
|
|
429
|
+
const event = makeEvent({ chatType: "group" });
|
|
430
|
+
const deliver = vi.fn();
|
|
431
|
+
|
|
432
|
+
const result = await processInboundMessage(api, account, event, { deliver });
|
|
433
|
+
|
|
434
|
+
expect(result).toBe(false);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("dispatches group message with mention", async () => {
|
|
438
|
+
const api = makeMockApi();
|
|
439
|
+
const account = makeAccount({ requireMention: true });
|
|
440
|
+
const event = makeEvent({
|
|
441
|
+
chatType: "group",
|
|
442
|
+
mentionUserList: [{ userId: "bot_user_001" }],
|
|
443
|
+
});
|
|
444
|
+
const deliver = vi.fn().mockResolvedValue(undefined);
|
|
445
|
+
|
|
446
|
+
const result = await processInboundMessage(api, account, event, { deliver });
|
|
447
|
+
|
|
448
|
+
expect(result).toBe(true);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("returns false for unparseable event", async () => {
|
|
452
|
+
const api = makeMockApi();
|
|
453
|
+
const account = makeAccount();
|
|
454
|
+
const event = makeEvent({ messageType: "image" });
|
|
455
|
+
const deliver = vi.fn();
|
|
456
|
+
|
|
457
|
+
const result = await processInboundMessage(api, account, event, { deliver });
|
|
458
|
+
|
|
459
|
+
expect(result).toBe(false);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("returns false when runtime dispatch is unavailable", async () => {
|
|
463
|
+
const api = makeMockApi();
|
|
464
|
+
// Remove dispatch function
|
|
465
|
+
(api.runtime.channel.reply as Record<string, unknown>).dispatchReplyWithBufferedBlockDispatcher = undefined;
|
|
466
|
+
const account = makeAccount({ requireMention: false });
|
|
467
|
+
const event = makeEvent();
|
|
468
|
+
const deliver = vi.fn();
|
|
469
|
+
|
|
470
|
+
const result = await processInboundMessage(api, account, event, { deliver });
|
|
471
|
+
|
|
472
|
+
expect(result).toBe(false);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("calls deliver callback via dispatcher", async () => {
|
|
476
|
+
const api = makeMockApi();
|
|
477
|
+
// Simulate the dispatcher calling deliver
|
|
478
|
+
const mockDispatch = vi.fn().mockImplementation(async (opts: Record<string, unknown>) => {
|
|
479
|
+
const dispatcherOpts = opts.dispatcherOptions as Record<string, unknown>;
|
|
480
|
+
const deliverFn = dispatcherOpts.deliver as (payload: { text?: string }) => Promise<void>;
|
|
481
|
+
await deliverFn({ text: "AI response" });
|
|
482
|
+
});
|
|
483
|
+
api.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher = mockDispatch;
|
|
484
|
+
|
|
485
|
+
const account = makeAccount({ requireMention: false });
|
|
486
|
+
const event = makeEvent({ chatId: "chat_deliver_test" });
|
|
487
|
+
const deliver = vi.fn().mockResolvedValue(undefined);
|
|
488
|
+
|
|
489
|
+
await processInboundMessage(api, account, event, { deliver });
|
|
490
|
+
|
|
491
|
+
expect(deliver).toHaveBeenCalledWith("chat_deliver_test", "AI response");
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("does not call deliver for empty text payload", async () => {
|
|
495
|
+
const api = makeMockApi();
|
|
496
|
+
const mockDispatch = vi.fn().mockImplementation(async (opts: Record<string, unknown>) => {
|
|
497
|
+
const dispatcherOpts = opts.dispatcherOptions as Record<string, unknown>;
|
|
498
|
+
const deliverFn = dispatcherOpts.deliver as (payload: { text?: string }) => Promise<void>;
|
|
499
|
+
await deliverFn({ text: undefined });
|
|
500
|
+
});
|
|
501
|
+
api.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher = mockDispatch;
|
|
502
|
+
|
|
503
|
+
const account = makeAccount({ requireMention: false });
|
|
504
|
+
const event = makeEvent();
|
|
505
|
+
const deliver = vi.fn();
|
|
506
|
+
|
|
507
|
+
await processInboundMessage(api, account, event, { deliver });
|
|
508
|
+
|
|
509
|
+
expect(deliver).not.toHaveBeenCalled();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("records session with updateLastRoute for DM", async () => {
|
|
513
|
+
const api = makeMockApi();
|
|
514
|
+
const account = makeAccount({ requireMention: false });
|
|
515
|
+
const event = makeEvent({ chatId: "chat_dm_001" });
|
|
516
|
+
const deliver = vi.fn().mockResolvedValue(undefined);
|
|
517
|
+
|
|
518
|
+
await processInboundMessage(api, account, event, { deliver });
|
|
519
|
+
|
|
520
|
+
const sessionCall = (api.runtime.channel.session.recordInboundSession as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
521
|
+
expect(sessionCall.updateLastRoute).toEqual({
|
|
522
|
+
sessionKey: expect.stringContaining("feihan:user:"),
|
|
523
|
+
channel: "feihan",
|
|
524
|
+
to: "chat_dm_001",
|
|
525
|
+
accountId: "test-account",
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("does not set updateLastRoute for group messages", async () => {
|
|
530
|
+
const api = makeMockApi();
|
|
531
|
+
const account = makeAccount({ requireMention: false });
|
|
532
|
+
const event = makeEvent({ chatType: "group" });
|
|
533
|
+
const deliver = vi.fn().mockResolvedValue(undefined);
|
|
534
|
+
|
|
535
|
+
await processInboundMessage(api, account, event, { deliver });
|
|
536
|
+
|
|
537
|
+
const sessionCall = (api.runtime.channel.session.recordInboundSession as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
538
|
+
expect(sessionCall.updateLastRoute).toBeUndefined();
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("logs dispatch error via onError callback", async () => {
|
|
542
|
+
const api = makeMockApi();
|
|
543
|
+
const mockDispatch = vi.fn().mockImplementation(async (opts: Record<string, unknown>) => {
|
|
544
|
+
const dispatcherOpts = opts.dispatcherOptions as Record<string, unknown>;
|
|
545
|
+
const onError = dispatcherOpts.onError as (err: unknown, info?: { kind?: string }) => void;
|
|
546
|
+
onError(new Error("test failure"), { kind: "llm" });
|
|
547
|
+
});
|
|
548
|
+
api.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher = mockDispatch;
|
|
549
|
+
|
|
550
|
+
const account = makeAccount({ requireMention: false });
|
|
551
|
+
const event = makeEvent();
|
|
552
|
+
const deliver = vi.fn();
|
|
553
|
+
|
|
554
|
+
await processInboundMessage(api, account, event, { deliver });
|
|
555
|
+
|
|
556
|
+
expect(api.logger!.error).toHaveBeenCalledWith(
|
|
557
|
+
expect.stringContaining("llm"),
|
|
558
|
+
);
|
|
559
|
+
});
|
|
560
|
+
});
|