@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,396 @@
|
|
|
1
|
+
// Copyright (c) 2026 上海飞函安全科技有限公司 (Shanghai Feihan Security Technology Co., Ltd.)
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Inbound message processing pipeline for Feihan IM.
|
|
6
|
+
*
|
|
7
|
+
* Pipeline: parse -> gate -> dedup -> build context -> dispatch
|
|
8
|
+
*
|
|
9
|
+
* Codes against the FeihanClient interface contract (task 99) —
|
|
10
|
+
* the client is not imported here; this module receives parsed events.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
FeihanAccountConfig,
|
|
15
|
+
FeihanMessage,
|
|
16
|
+
FeihanMessageEvent,
|
|
17
|
+
PluginApi,
|
|
18
|
+
} from "../types.js";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Parse — extract usable text body from incoming message
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export interface ParsedInboundMessage {
|
|
25
|
+
messageId: string;
|
|
26
|
+
chatId: string;
|
|
27
|
+
chatType: "direct" | "group";
|
|
28
|
+
senderId: string;
|
|
29
|
+
body: string;
|
|
30
|
+
timestamp: number;
|
|
31
|
+
mentionUserIds: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Normalize a raw Feihan message event into a structured inbound message.
|
|
36
|
+
* Returns `null` if the event cannot be parsed into a usable message.
|
|
37
|
+
*
|
|
38
|
+
* Only text messages are supported in this phase — media/card/file are
|
|
39
|
+
* follow-up work (task 08).
|
|
40
|
+
*/
|
|
41
|
+
export function parseMessageEvent(
|
|
42
|
+
event: FeihanMessageEvent,
|
|
43
|
+
): ParsedInboundMessage | null {
|
|
44
|
+
const msg = event?.message;
|
|
45
|
+
if (!msg || !msg.messageId || !msg.chatId) return null;
|
|
46
|
+
|
|
47
|
+
const body = extractTextBody(msg);
|
|
48
|
+
if (body === null) return null;
|
|
49
|
+
|
|
50
|
+
const chatType = msg.chatType === "group" ? "group" : "direct";
|
|
51
|
+
|
|
52
|
+
const senderId =
|
|
53
|
+
msg.sender?.userId || msg.sender?.openUserId || msg.sender?.unionUserId;
|
|
54
|
+
if (!senderId) return null;
|
|
55
|
+
|
|
56
|
+
const mentionUserIds = (msg.mentionUserList ?? [])
|
|
57
|
+
.map((u) => u.userId || u.openUserId || u.unionUserId || "")
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
messageId: msg.messageId,
|
|
62
|
+
chatId: msg.chatId,
|
|
63
|
+
chatType,
|
|
64
|
+
senderId,
|
|
65
|
+
body,
|
|
66
|
+
timestamp: msg.createdAt ?? Date.now(),
|
|
67
|
+
mentionUserIds,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extract plain-text content from a Feihan message.
|
|
73
|
+
* Returns `null` for non-text or empty messages.
|
|
74
|
+
*/
|
|
75
|
+
function extractTextBody(msg: FeihanMessage): string | null {
|
|
76
|
+
if (msg.messageType !== "text") return null;
|
|
77
|
+
|
|
78
|
+
const content = msg.messageContent;
|
|
79
|
+
if (typeof content === "string") {
|
|
80
|
+
// Content may be JSON-encoded string (Feihan convention)
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(content);
|
|
83
|
+
// Feihan format: { text: { content: "Hello" } }
|
|
84
|
+
if (parsed?.text && typeof parsed.text === "object" && typeof parsed.text.content === "string")
|
|
85
|
+
return parsed.text.content.trim() || null;
|
|
86
|
+
if (typeof parsed?.text === "string") return parsed.text.trim() || null;
|
|
87
|
+
if (typeof parsed?.content === "string")
|
|
88
|
+
return parsed.content.trim() || null;
|
|
89
|
+
// Fall through to raw string
|
|
90
|
+
} catch {
|
|
91
|
+
// Not JSON — use raw string
|
|
92
|
+
}
|
|
93
|
+
return content.trim() || null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (content && typeof content === "object") {
|
|
97
|
+
const obj = content as Record<string, unknown>;
|
|
98
|
+
// Feihan format: { text: { content: "Hello" } }
|
|
99
|
+
if (obj.text && typeof obj.text === "object") {
|
|
100
|
+
const textObj = obj.text as Record<string, unknown>;
|
|
101
|
+
if (typeof textObj.content === "string") return textObj.content.trim() || null;
|
|
102
|
+
}
|
|
103
|
+
if (typeof obj.text === "string") return obj.text.trim() || null;
|
|
104
|
+
if (typeof obj.content === "string") return obj.content.trim() || null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Gate — filter out messages the plugin should not process
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
export interface GateResult {
|
|
115
|
+
pass: boolean;
|
|
116
|
+
reason?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Determine whether a parsed inbound message should be processed.
|
|
121
|
+
* Checks in order: self-message, whitelist, group mention requirement.
|
|
122
|
+
*/
|
|
123
|
+
export function checkMessageGate(
|
|
124
|
+
msg: ParsedInboundMessage,
|
|
125
|
+
account: FeihanAccountConfig,
|
|
126
|
+
): GateResult {
|
|
127
|
+
// 1. Self-message filter: ignore messages from the bot itself
|
|
128
|
+
if (account.botUserId && msg.senderId === account.botUserId) {
|
|
129
|
+
return { pass: false, reason: "self-message" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 2. Whitelist filter: if whitelist is non-empty, only allow listed senders
|
|
133
|
+
if (account.inboundWhitelist.length > 0) {
|
|
134
|
+
if (!account.inboundWhitelist.includes(msg.senderId)) {
|
|
135
|
+
return { pass: false, reason: "sender-not-in-whitelist" };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 3. Group mention filter: in group chats, require @mention if configured
|
|
140
|
+
if (
|
|
141
|
+
msg.chatType === "group" &&
|
|
142
|
+
account.requireMention &&
|
|
143
|
+
!isBotMentioned(msg, account)
|
|
144
|
+
) {
|
|
145
|
+
return { pass: false, reason: "group-no-mention" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { pass: true };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check if the bot was @mentioned in a message.
|
|
153
|
+
*/
|
|
154
|
+
export function isBotMentioned(
|
|
155
|
+
msg: ParsedInboundMessage,
|
|
156
|
+
account: FeihanAccountConfig,
|
|
157
|
+
): boolean {
|
|
158
|
+
if (!account.botUserId) return false;
|
|
159
|
+
return msg.mentionUserIds.includes(account.botUserId);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Dedup — skip already-processed message IDs
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
167
|
+
const DEDUP_CLEANUP_INTERVAL_MS = 60 * 1000; // clean every 1 minute
|
|
168
|
+
const DEDUP_MAX_SIZE = 10_000;
|
|
169
|
+
|
|
170
|
+
/** In-memory set of recently processed message IDs with TTL. */
|
|
171
|
+
const processedMessages = new Map<string, number>();
|
|
172
|
+
let lastCleanup = Date.now();
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Returns `true` if the message has already been processed (duplicate).
|
|
176
|
+
* Records the message ID on first encounter.
|
|
177
|
+
*/
|
|
178
|
+
export function isDuplicate(messageId: string): boolean {
|
|
179
|
+
cleanupIfNeeded();
|
|
180
|
+
|
|
181
|
+
if (processedMessages.has(messageId)) return true;
|
|
182
|
+
|
|
183
|
+
processedMessages.set(messageId, Date.now());
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function cleanupIfNeeded(): void {
|
|
188
|
+
const now = Date.now();
|
|
189
|
+
if (now - lastCleanup < DEDUP_CLEANUP_INTERVAL_MS) return;
|
|
190
|
+
lastCleanup = now;
|
|
191
|
+
|
|
192
|
+
const expiry = now - DEDUP_TTL_MS;
|
|
193
|
+
for (const [id, ts] of processedMessages) {
|
|
194
|
+
if (ts < expiry) processedMessages.delete(id);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Hard cap to prevent unbounded growth
|
|
198
|
+
if (processedMessages.size > DEDUP_MAX_SIZE) {
|
|
199
|
+
const excess = processedMessages.size - DEDUP_MAX_SIZE;
|
|
200
|
+
const iter = processedMessages.keys();
|
|
201
|
+
for (let i = 0; i < excess; i++) {
|
|
202
|
+
const key = iter.next().value;
|
|
203
|
+
if (key !== undefined) processedMessages.delete(key);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Reset dedup state — exposed for testing only. */
|
|
209
|
+
export function _resetDedup(): void {
|
|
210
|
+
processedMessages.clear();
|
|
211
|
+
lastCleanup = Date.now();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Build context & dispatch
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
export interface InboundContext {
|
|
219
|
+
Body: string;
|
|
220
|
+
From: string;
|
|
221
|
+
To: string;
|
|
222
|
+
SessionKey: string;
|
|
223
|
+
AccountId: string;
|
|
224
|
+
ChatType: "direct" | "group";
|
|
225
|
+
Provider: "feihan";
|
|
226
|
+
Surface: "feihan";
|
|
227
|
+
MessageSid: string;
|
|
228
|
+
Timestamp: number;
|
|
229
|
+
CommandAuthorized: boolean;
|
|
230
|
+
_feihan: {
|
|
231
|
+
accountId: string;
|
|
232
|
+
isGroup: boolean;
|
|
233
|
+
senderId: string;
|
|
234
|
+
chatId: string;
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Build the OpenClaw context payload from a parsed inbound message.
|
|
240
|
+
*/
|
|
241
|
+
export function buildContext(
|
|
242
|
+
msg: ParsedInboundMessage,
|
|
243
|
+
account: FeihanAccountConfig,
|
|
244
|
+
): InboundContext {
|
|
245
|
+
const isGroup = msg.chatType === "group";
|
|
246
|
+
|
|
247
|
+
// Session key: group chats key on chatId, DMs key on sender
|
|
248
|
+
const sessionKey = isGroup
|
|
249
|
+
? `feihan:chat:${msg.chatId}`
|
|
250
|
+
: `feihan:user:${msg.senderId}`;
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
Body: msg.body,
|
|
254
|
+
From: isGroup ? `feihan:chat:${msg.chatId}` : `feihan:user:${msg.senderId}`,
|
|
255
|
+
To: `feihan:bot:${account.botUserId ?? account.appId}`,
|
|
256
|
+
SessionKey: sessionKey,
|
|
257
|
+
AccountId: account.accountId,
|
|
258
|
+
ChatType: isGroup ? "group" : "direct",
|
|
259
|
+
Provider: "feihan",
|
|
260
|
+
Surface: "feihan",
|
|
261
|
+
MessageSid: msg.messageId,
|
|
262
|
+
Timestamp: msg.timestamp,
|
|
263
|
+
CommandAuthorized: true,
|
|
264
|
+
_feihan: {
|
|
265
|
+
accountId: account.accountId,
|
|
266
|
+
isGroup,
|
|
267
|
+
senderId: msg.senderId,
|
|
268
|
+
chatId: msg.chatId,
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Top-level dispatch
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Options for the deliver callback wired by the caller (e.g. the service
|
|
279
|
+
* start handler in index.ts once task 06 connects the FeihanClient).
|
|
280
|
+
*/
|
|
281
|
+
export interface InboundDispatchOptions {
|
|
282
|
+
/** Send a reply back to the Feihan chat. */
|
|
283
|
+
deliver: (chatId: string, text: string) => Promise<void>;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Process an inbound Feihan message event end-to-end:
|
|
288
|
+
* parse -> gate -> dedup -> build context -> dispatch.
|
|
289
|
+
*
|
|
290
|
+
* Returns `true` if the message was dispatched, `false` if filtered.
|
|
291
|
+
*/
|
|
292
|
+
export async function processInboundMessage(
|
|
293
|
+
api: PluginApi,
|
|
294
|
+
account: FeihanAccountConfig,
|
|
295
|
+
event: FeihanMessageEvent,
|
|
296
|
+
opts: InboundDispatchOptions,
|
|
297
|
+
): Promise<boolean> {
|
|
298
|
+
const logger = api.logger;
|
|
299
|
+
|
|
300
|
+
// 1. Parse
|
|
301
|
+
const parsed = parseMessageEvent(event);
|
|
302
|
+
if (!parsed) {
|
|
303
|
+
logger?.debug(
|
|
304
|
+
`[feihan] ignoring unparseable event (messageId=${event?.message?.messageId ?? "unknown"} messageType=${event?.message?.messageType ?? "undefined"})`,
|
|
305
|
+
);
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 2. Gate
|
|
310
|
+
const gate = checkMessageGate(parsed, account);
|
|
311
|
+
if (!gate.pass) {
|
|
312
|
+
logger?.debug(
|
|
313
|
+
`[feihan] gated message=${parsed.messageId} reason=${gate.reason} chat=${parsed.chatId}`,
|
|
314
|
+
);
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 3. Dedup
|
|
319
|
+
if (isDuplicate(parsed.messageId)) {
|
|
320
|
+
logger?.debug(
|
|
321
|
+
`[feihan] duplicate message=${parsed.messageId} chat=${parsed.chatId}`,
|
|
322
|
+
);
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 4. Build context
|
|
327
|
+
const ctx = buildContext(parsed, account);
|
|
328
|
+
|
|
329
|
+
logger?.info(
|
|
330
|
+
`[feihan] dispatching message=${parsed.messageId} chat=${parsed.chatId} sender=${parsed.senderId} type=${parsed.chatType}`,
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
// 5. Dispatch
|
|
334
|
+
const runtime = api.runtime;
|
|
335
|
+
if (!runtime?.channel?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
|
|
336
|
+
logger?.warn("[feihan] runtime.channel.reply not available — cannot dispatch");
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Record session for context continuity
|
|
341
|
+
const cfgSession = (api.config as Record<string, unknown>)?.session as Record<string, unknown> | undefined;
|
|
342
|
+
const storePath =
|
|
343
|
+
runtime.channel.session?.resolveStorePath?.(
|
|
344
|
+
cfgSession?.store,
|
|
345
|
+
{ agentId: "main" },
|
|
346
|
+
) ?? "";
|
|
347
|
+
|
|
348
|
+
await runtime.channel.session?.recordInboundSession?.({
|
|
349
|
+
storePath,
|
|
350
|
+
sessionKey: ctx.SessionKey,
|
|
351
|
+
ctx,
|
|
352
|
+
updateLastRoute:
|
|
353
|
+
ctx.ChatType === "direct"
|
|
354
|
+
? {
|
|
355
|
+
sessionKey: ctx.SessionKey,
|
|
356
|
+
channel: "feihan",
|
|
357
|
+
to: parsed.chatId,
|
|
358
|
+
accountId: account.accountId,
|
|
359
|
+
}
|
|
360
|
+
: undefined,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Dispatch reply with buffered block dispatcher
|
|
364
|
+
logger?.debug(
|
|
365
|
+
`[feihan] dispatch starting for message=${parsed.messageId} session=${ctx.SessionKey}`,
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
369
|
+
ctx,
|
|
370
|
+
cfg: api.config,
|
|
371
|
+
dispatcherOptions: {
|
|
372
|
+
deliver: async (payload: { text?: string }) => {
|
|
373
|
+
logger?.info(
|
|
374
|
+
`[feihan] deliver callback called for message=${parsed.messageId} hasText=${!!payload.text} textLen=${payload.text?.length ?? 0}`,
|
|
375
|
+
);
|
|
376
|
+
if (payload.text) {
|
|
377
|
+
await opts.deliver(parsed.chatId, payload.text);
|
|
378
|
+
logger?.info(
|
|
379
|
+
`[feihan] deliver completed for message=${parsed.messageId} chat=${parsed.chatId}`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
onError: (err: unknown, info?: { kind?: string }) => {
|
|
384
|
+
logger?.error(
|
|
385
|
+
`[feihan] ${info?.kind ?? "reply"} error for message=${parsed.messageId}: ${err}`,
|
|
386
|
+
);
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
logger?.info(
|
|
392
|
+
`[feihan] dispatch finished for message=${parsed.messageId}`,
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Copyright (c) 2026 上海飞函安全科技有限公司 (Shanghai Feihan Security Technology Co., Ltd.)
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
5
|
+
|
|
6
|
+
// We test outbound logic by mocking the client manager.
|
|
7
|
+
// Mock shape matches @feihan-im/sdk's client.Im.v1.* API surface.
|
|
8
|
+
|
|
9
|
+
/** Marker class for mock auth errors. */
|
|
10
|
+
class MockAuthError extends Error {
|
|
11
|
+
code = 40000006;
|
|
12
|
+
constructor() { super("鉴权失败"); }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
vi.mock("../core/feihan-client.js", () => {
|
|
16
|
+
const mockClient = {
|
|
17
|
+
client: {
|
|
18
|
+
Im: {
|
|
19
|
+
v1: {
|
|
20
|
+
Message: {
|
|
21
|
+
sendMessage: vi.fn().mockResolvedValue({ message_id: "sent_msg_001" }),
|
|
22
|
+
readMessage: vi.fn().mockResolvedValue({}),
|
|
23
|
+
},
|
|
24
|
+
Chat: {
|
|
25
|
+
createTyping: vi.fn().mockResolvedValue({}),
|
|
26
|
+
deleteTyping: vi.fn().mockResolvedValue({}),
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
preheat: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
},
|
|
32
|
+
config: { accountId: "test-account" },
|
|
33
|
+
connectionState: "connected",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
getClient: vi.fn((accountId?: string) => {
|
|
38
|
+
if (accountId === "missing") return undefined;
|
|
39
|
+
return mockClient;
|
|
40
|
+
}),
|
|
41
|
+
isAuthError: vi.fn((err: unknown) => err instanceof MockAuthError),
|
|
42
|
+
MessageType_TEXT: "text",
|
|
43
|
+
__mockClient: mockClient,
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
import { sendText, setTyping, clearTyping, readMessage, makeDeliver } from "./outbound.js";
|
|
48
|
+
import { getClient } from "../core/feihan-client.js";
|
|
49
|
+
|
|
50
|
+
// Access mock internals
|
|
51
|
+
const { __mockClient: mockClient } = await import("../core/feihan-client.js") as any;
|
|
52
|
+
|
|
53
|
+
describe("sendText", () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.clearAllMocks();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("sends a text message successfully", async () => {
|
|
59
|
+
const result = await sendText("chat_001", "Hello!", "test-account");
|
|
60
|
+
expect(result.ok).toBe(true);
|
|
61
|
+
expect(result.messageId).toBe("sent_msg_001");
|
|
62
|
+
expect(result.provider).toBe("feihan");
|
|
63
|
+
|
|
64
|
+
const sendCall = mockClient.client.Im.v1.Message.sendMessage.mock.calls[0][0];
|
|
65
|
+
expect(sendCall.chat_id).toBe("chat_001");
|
|
66
|
+
expect(sendCall.message_type).toBe("text");
|
|
67
|
+
expect(sendCall.message_content.text.content).toBe("Hello!");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns error when no client is connected", async () => {
|
|
71
|
+
const result = await sendText("chat_001", "Hello!", "missing");
|
|
72
|
+
expect(result.ok).toBe(false);
|
|
73
|
+
expect(result.error?.message).toContain("no connected client");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns error when SDK throws non-auth error", async () => {
|
|
77
|
+
mockClient.client.Im.v1.Message.sendMessage.mockRejectedValueOnce(new Error("API error"));
|
|
78
|
+
const result = await sendText("chat_001", "Hello!");
|
|
79
|
+
expect(result.ok).toBe(false);
|
|
80
|
+
expect(result.error?.message).toContain("send failed");
|
|
81
|
+
// preheat should NOT have been called for non-auth errors
|
|
82
|
+
expect(mockClient.client.preheat).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("retries once on auth error after preheat", async () => {
|
|
86
|
+
// First call fails with auth error, second succeeds
|
|
87
|
+
mockClient.client.Im.v1.Message.sendMessage
|
|
88
|
+
.mockRejectedValueOnce(new MockAuthError())
|
|
89
|
+
.mockResolvedValueOnce({ message_id: "retry_msg_001" });
|
|
90
|
+
|
|
91
|
+
const logWarn = vi.fn();
|
|
92
|
+
const result = await sendText("chat_001", "Hello!", "test-account", undefined, logWarn);
|
|
93
|
+
expect(result.ok).toBe(true);
|
|
94
|
+
expect(result.messageId).toBe("retry_msg_001");
|
|
95
|
+
expect(mockClient.client.preheat).toHaveBeenCalledOnce();
|
|
96
|
+
expect(logWarn).toHaveBeenCalled();
|
|
97
|
+
expect(logWarn.mock.calls[0][0]).toContain("auth error on send");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns error when retry also fails after auth error", async () => {
|
|
101
|
+
// Both calls fail
|
|
102
|
+
mockClient.client.Im.v1.Message.sendMessage
|
|
103
|
+
.mockRejectedValueOnce(new MockAuthError())
|
|
104
|
+
.mockRejectedValueOnce(new Error("still broken"));
|
|
105
|
+
|
|
106
|
+
const result = await sendText("chat_001", "Hello!", "test-account");
|
|
107
|
+
expect(result.ok).toBe(false);
|
|
108
|
+
expect(result.error?.message).toContain("after auth retry");
|
|
109
|
+
expect(mockClient.client.preheat).toHaveBeenCalledOnce();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("does not retry on non-auth SDK errors", async () => {
|
|
113
|
+
mockClient.client.Im.v1.Message.sendMessage.mockRejectedValueOnce(new Error("network timeout"));
|
|
114
|
+
const result = await sendText("chat_001", "Hello!");
|
|
115
|
+
expect(result.ok).toBe(false);
|
|
116
|
+
expect(mockClient.client.preheat).not.toHaveBeenCalled();
|
|
117
|
+
expect(mockClient.client.Im.v1.Message.sendMessage).toHaveBeenCalledOnce();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("passes reply_message_id when provided", async () => {
|
|
121
|
+
await sendText("chat_001", "reply!", "test-account", "original_msg");
|
|
122
|
+
const sendCall = mockClient.client.Im.v1.Message.sendMessage.mock.calls[0][0];
|
|
123
|
+
expect(sendCall.reply_message_id).toBe("original_msg");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("setTyping", () => {
|
|
128
|
+
beforeEach(() => vi.clearAllMocks());
|
|
129
|
+
|
|
130
|
+
it("calls createTyping on the client", async () => {
|
|
131
|
+
await setTyping("chat_001");
|
|
132
|
+
expect(mockClient.client.Im.v1.Chat.createTyping).toHaveBeenCalledWith({ chat_id: "chat_001" });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("does not throw when client is missing", async () => {
|
|
136
|
+
await expect(setTyping("chat_001", "missing")).resolves.toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("clearTyping", () => {
|
|
141
|
+
beforeEach(() => vi.clearAllMocks());
|
|
142
|
+
|
|
143
|
+
it("calls deleteTyping on the client", async () => {
|
|
144
|
+
await clearTyping("chat_001");
|
|
145
|
+
expect(mockClient.client.Im.v1.Chat.deleteTyping).toHaveBeenCalledWith({ chat_id: "chat_001" });
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("readMessage", () => {
|
|
150
|
+
beforeEach(() => vi.clearAllMocks());
|
|
151
|
+
|
|
152
|
+
it("calls readMessage on the client", async () => {
|
|
153
|
+
await readMessage("msg_001");
|
|
154
|
+
expect(mockClient.client.Im.v1.Message.readMessage).toHaveBeenCalledWith({ message_id: "msg_001" });
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("makeDeliver", () => {
|
|
159
|
+
beforeEach(() => vi.clearAllMocks());
|
|
160
|
+
|
|
161
|
+
it("returns a deliver function that sends text", async () => {
|
|
162
|
+
const deliver = makeDeliver("test-account");
|
|
163
|
+
await deliver("chat_001", "AI reply");
|
|
164
|
+
expect(mockClient.client.Im.v1.Message.sendMessage).toHaveBeenCalledOnce();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("throws when send fails", async () => {
|
|
168
|
+
mockClient.client.Im.v1.Message.sendMessage.mockRejectedValueOnce(new Error("fail"));
|
|
169
|
+
const deliver = makeDeliver("test-account");
|
|
170
|
+
await expect(deliver("chat_001", "AI reply")).rejects.toThrow();
|
|
171
|
+
});
|
|
172
|
+
});
|