@nextclaw/channel-plugin-feishu 0.2.13 → 0.2.14

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.
Files changed (102) hide show
  1. package/README.md +3 -1
  2. package/index.ts +65 -0
  3. package/openclaw.plugin.json +3 -7
  4. package/package.json +33 -9
  5. package/skills/feishu-doc/SKILL.md +211 -0
  6. package/skills/feishu-doc/references/block-types.md +103 -0
  7. package/skills/feishu-drive/SKILL.md +97 -0
  8. package/skills/feishu-perm/SKILL.md +119 -0
  9. package/skills/feishu-wiki/SKILL.md +111 -0
  10. package/src/accounts.test.ts +371 -0
  11. package/src/accounts.ts +244 -0
  12. package/src/async.ts +62 -0
  13. package/src/bitable.ts +725 -0
  14. package/src/bot.card-action.test.ts +63 -0
  15. package/src/bot.checkBotMentioned.test.ts +193 -0
  16. package/src/bot.stripBotMention.test.ts +134 -0
  17. package/src/bot.test.ts +2107 -0
  18. package/src/bot.ts +1556 -0
  19. package/src/card-action.ts +79 -0
  20. package/src/channel.test.ts +48 -0
  21. package/src/channel.ts +369 -0
  22. package/src/chat-schema.ts +24 -0
  23. package/src/chat.test.ts +89 -0
  24. package/src/chat.ts +130 -0
  25. package/src/client.test.ts +324 -0
  26. package/src/client.ts +196 -0
  27. package/src/config-schema.test.ts +247 -0
  28. package/src/config-schema.ts +306 -0
  29. package/src/dedup.ts +203 -0
  30. package/src/directory.test.ts +40 -0
  31. package/src/directory.ts +156 -0
  32. package/src/doc-schema.ts +182 -0
  33. package/src/docx-batch-insert.test.ts +90 -0
  34. package/src/docx-batch-insert.ts +187 -0
  35. package/src/docx-color-text.ts +149 -0
  36. package/src/docx-table-ops.ts +298 -0
  37. package/src/docx.account-selection.test.ts +70 -0
  38. package/src/docx.test.ts +445 -0
  39. package/src/docx.ts +1460 -0
  40. package/src/drive-schema.ts +46 -0
  41. package/src/drive.ts +228 -0
  42. package/src/dynamic-agent.ts +131 -0
  43. package/src/external-keys.test.ts +20 -0
  44. package/src/external-keys.ts +19 -0
  45. package/src/feishu-command-handler.ts +59 -0
  46. package/src/media.test.ts +523 -0
  47. package/src/media.ts +484 -0
  48. package/src/mention.ts +133 -0
  49. package/src/monitor.account.ts +562 -0
  50. package/src/monitor.reaction.test.ts +653 -0
  51. package/src/monitor.startup.test.ts +190 -0
  52. package/src/monitor.startup.ts +64 -0
  53. package/src/monitor.state.defaults.test.ts +46 -0
  54. package/src/monitor.state.ts +155 -0
  55. package/src/monitor.test-mocks.ts +45 -0
  56. package/src/monitor.transport.ts +264 -0
  57. package/src/monitor.ts +95 -0
  58. package/src/monitor.webhook-e2e.test.ts +214 -0
  59. package/src/monitor.webhook-security.test.ts +142 -0
  60. package/src/monitor.webhook.test-helpers.ts +98 -0
  61. package/src/onboarding.status.test.ts +25 -0
  62. package/src/onboarding.test.ts +143 -0
  63. package/src/onboarding.ts +489 -0
  64. package/src/outbound.test.ts +356 -0
  65. package/src/outbound.ts +176 -0
  66. package/src/perm-schema.ts +52 -0
  67. package/src/perm.ts +176 -0
  68. package/src/policy.test.ts +154 -0
  69. package/src/policy.ts +123 -0
  70. package/src/post.test.ts +105 -0
  71. package/src/post.ts +274 -0
  72. package/src/probe.test.ts +270 -0
  73. package/src/probe.ts +156 -0
  74. package/src/reactions.ts +153 -0
  75. package/src/reply-dispatcher.test.ts +513 -0
  76. package/src/reply-dispatcher.ts +397 -0
  77. package/src/runtime.ts +6 -0
  78. package/src/secret-input.ts +13 -0
  79. package/src/send-message.ts +71 -0
  80. package/src/send-result.ts +29 -0
  81. package/src/send-target.test.ts +74 -0
  82. package/src/send-target.ts +29 -0
  83. package/src/send.reply-fallback.test.ts +189 -0
  84. package/src/send.test.ts +168 -0
  85. package/src/send.ts +481 -0
  86. package/src/streaming-card.test.ts +54 -0
  87. package/src/streaming-card.ts +374 -0
  88. package/src/targets.test.ts +70 -0
  89. package/src/targets.ts +107 -0
  90. package/src/tool-account-routing.test.ts +129 -0
  91. package/src/tool-account.ts +70 -0
  92. package/src/tool-factory-test-harness.ts +76 -0
  93. package/src/tool-result.test.ts +32 -0
  94. package/src/tool-result.ts +14 -0
  95. package/src/tools-config.test.ts +21 -0
  96. package/src/tools-config.ts +22 -0
  97. package/src/types.ts +103 -0
  98. package/src/typing.test.ts +144 -0
  99. package/src/typing.ts +210 -0
  100. package/src/wiki-schema.ts +55 -0
  101. package/src/wiki.ts +233 -0
  102. package/index.js +0 -27
package/src/send.ts ADDED
@@ -0,0 +1,481 @@
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
2
+ import { resolveFeishuAccount } from "./accounts.js";
3
+ import { createFeishuClient } from "./client.js";
4
+ import type { MentionTarget } from "./mention.js";
5
+ import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
6
+ import { parsePostContent } from "./post.js";
7
+ import { getFeishuRuntime } from "./runtime.js";
8
+ import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
9
+ import { resolveFeishuSendTarget } from "./send-target.js";
10
+ import type { FeishuChatType, FeishuMessageInfo, FeishuSendResult } from "./types.js";
11
+
12
+ const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]);
13
+
14
+ function shouldFallbackFromReplyTarget(response: { code?: number; msg?: string }): boolean {
15
+ if (response.code !== undefined && WITHDRAWN_REPLY_ERROR_CODES.has(response.code)) {
16
+ return true;
17
+ }
18
+ const msg = response.msg?.toLowerCase() ?? "";
19
+ return msg.includes("withdrawn") || msg.includes("not found");
20
+ }
21
+
22
+ /** Check whether a thrown error indicates a withdrawn/not-found reply target. */
23
+ function isWithdrawnReplyError(err: unknown): boolean {
24
+ if (typeof err !== "object" || err === null) {
25
+ return false;
26
+ }
27
+ // SDK error shape: err.code
28
+ const code = (err as { code?: number }).code;
29
+ if (typeof code === "number" && WITHDRAWN_REPLY_ERROR_CODES.has(code)) {
30
+ return true;
31
+ }
32
+ // AxiosError shape: err.response.data.code
33
+ const response = (err as { response?: { data?: { code?: number; msg?: string } } }).response;
34
+ if (
35
+ typeof response?.data?.code === "number" &&
36
+ WITHDRAWN_REPLY_ERROR_CODES.has(response.data.code)
37
+ ) {
38
+ return true;
39
+ }
40
+ return false;
41
+ }
42
+
43
+ type FeishuCreateMessageClient = {
44
+ im: {
45
+ message: {
46
+ reply: (opts: {
47
+ path: { message_id: string };
48
+ data: { content: string; msg_type: string; reply_in_thread?: true };
49
+ }) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>;
50
+ create: (opts: {
51
+ params: { receive_id_type: "chat_id" | "email" | "open_id" | "union_id" | "user_id" };
52
+ data: { receive_id: string; content: string; msg_type: string };
53
+ }) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>;
54
+ };
55
+ };
56
+ };
57
+
58
+ type FeishuMessageSender = {
59
+ id?: string;
60
+ id_type?: string;
61
+ sender_type?: string;
62
+ };
63
+
64
+ type FeishuMessageGetItem = {
65
+ message_id?: string;
66
+ chat_id?: string;
67
+ chat_type?: FeishuChatType;
68
+ msg_type?: string;
69
+ body?: { content?: string };
70
+ sender?: FeishuMessageSender;
71
+ create_time?: string;
72
+ };
73
+
74
+ type FeishuGetMessageResponse = {
75
+ code?: number;
76
+ msg?: string;
77
+ data?: FeishuMessageGetItem & {
78
+ items?: FeishuMessageGetItem[];
79
+ };
80
+ };
81
+
82
+ /** Send a direct message as a fallback when a reply target is unavailable. */
83
+ async function sendFallbackDirect(
84
+ client: FeishuCreateMessageClient,
85
+ params: {
86
+ receiveId: string;
87
+ receiveIdType: "chat_id" | "email" | "open_id" | "union_id" | "user_id";
88
+ content: string;
89
+ msgType: string;
90
+ },
91
+ errorPrefix: string,
92
+ ): Promise<FeishuSendResult> {
93
+ const response = await client.im.message.create({
94
+ params: { receive_id_type: params.receiveIdType },
95
+ data: {
96
+ receive_id: params.receiveId,
97
+ content: params.content,
98
+ msg_type: params.msgType,
99
+ },
100
+ });
101
+ assertFeishuMessageApiSuccess(response, errorPrefix);
102
+ return toFeishuSendResult(response, params.receiveId);
103
+ }
104
+
105
+ async function sendReplyOrFallbackDirect(
106
+ client: FeishuCreateMessageClient,
107
+ params: {
108
+ replyToMessageId?: string;
109
+ replyInThread?: boolean;
110
+ content: string;
111
+ msgType: string;
112
+ directParams: {
113
+ receiveId: string;
114
+ receiveIdType: "chat_id" | "email" | "open_id" | "union_id" | "user_id";
115
+ content: string;
116
+ msgType: string;
117
+ };
118
+ directErrorPrefix: string;
119
+ replyErrorPrefix: string;
120
+ },
121
+ ): Promise<FeishuSendResult> {
122
+ if (!params.replyToMessageId) {
123
+ return sendFallbackDirect(client, params.directParams, params.directErrorPrefix);
124
+ }
125
+
126
+ let response: { code?: number; msg?: string; data?: { message_id?: string } };
127
+ try {
128
+ response = await client.im.message.reply({
129
+ path: { message_id: params.replyToMessageId },
130
+ data: {
131
+ content: params.content,
132
+ msg_type: params.msgType,
133
+ ...(params.replyInThread ? { reply_in_thread: true } : {}),
134
+ },
135
+ });
136
+ } catch (err) {
137
+ if (!isWithdrawnReplyError(err)) {
138
+ throw err;
139
+ }
140
+ return sendFallbackDirect(client, params.directParams, params.directErrorPrefix);
141
+ }
142
+ if (shouldFallbackFromReplyTarget(response)) {
143
+ return sendFallbackDirect(client, params.directParams, params.directErrorPrefix);
144
+ }
145
+ assertFeishuMessageApiSuccess(response, params.replyErrorPrefix);
146
+ return toFeishuSendResult(response, params.directParams.receiveId);
147
+ }
148
+
149
+ function parseInteractiveCardContent(parsed: unknown): string {
150
+ if (!parsed || typeof parsed !== "object") {
151
+ return "[Interactive Card]";
152
+ }
153
+
154
+ const candidate = parsed as { elements?: unknown };
155
+ if (!Array.isArray(candidate.elements)) {
156
+ return "[Interactive Card]";
157
+ }
158
+
159
+ const texts: string[] = [];
160
+ for (const element of candidate.elements) {
161
+ if (!element || typeof element !== "object") {
162
+ continue;
163
+ }
164
+ const item = element as {
165
+ tag?: string;
166
+ content?: string;
167
+ text?: { content?: string };
168
+ };
169
+ if (item.tag === "div" && typeof item.text?.content === "string") {
170
+ texts.push(item.text.content);
171
+ continue;
172
+ }
173
+ if (item.tag === "markdown" && typeof item.content === "string") {
174
+ texts.push(item.content);
175
+ }
176
+ }
177
+ return texts.join("\n").trim() || "[Interactive Card]";
178
+ }
179
+
180
+ function parseQuotedMessageContent(rawContent: string, msgType: string): string {
181
+ if (!rawContent) {
182
+ return "";
183
+ }
184
+
185
+ let parsed: unknown;
186
+ try {
187
+ parsed = JSON.parse(rawContent);
188
+ } catch {
189
+ return rawContent;
190
+ }
191
+
192
+ if (msgType === "text") {
193
+ const text = (parsed as { text?: unknown })?.text;
194
+ return typeof text === "string" ? text : "[Text message]";
195
+ }
196
+
197
+ if (msgType === "post") {
198
+ return parsePostContent(rawContent).textContent;
199
+ }
200
+
201
+ if (msgType === "interactive") {
202
+ return parseInteractiveCardContent(parsed);
203
+ }
204
+
205
+ if (typeof parsed === "string") {
206
+ return parsed;
207
+ }
208
+
209
+ const genericText = (parsed as { text?: unknown; title?: unknown } | null)?.text;
210
+ if (typeof genericText === "string" && genericText.trim()) {
211
+ return genericText;
212
+ }
213
+ const genericTitle = (parsed as { title?: unknown } | null)?.title;
214
+ if (typeof genericTitle === "string" && genericTitle.trim()) {
215
+ return genericTitle;
216
+ }
217
+
218
+ return `[${msgType || "unknown"} message]`;
219
+ }
220
+
221
+ /**
222
+ * Get a message by its ID.
223
+ * Useful for fetching quoted/replied message content.
224
+ */
225
+ export async function getMessageFeishu(params: {
226
+ cfg: ClawdbotConfig;
227
+ messageId: string;
228
+ accountId?: string;
229
+ }): Promise<FeishuMessageInfo | null> {
230
+ const { cfg, messageId, accountId } = params;
231
+ const account = resolveFeishuAccount({ cfg, accountId });
232
+ if (!account.configured) {
233
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
234
+ }
235
+
236
+ const client = createFeishuClient(account);
237
+
238
+ try {
239
+ const response = (await client.im.message.get({
240
+ path: { message_id: messageId },
241
+ })) as FeishuGetMessageResponse;
242
+
243
+ if (response.code !== 0) {
244
+ return null;
245
+ }
246
+
247
+ // Support both list shape (data.items[0]) and single-object shape (data as message)
248
+ const rawItem = response.data?.items?.[0] ?? response.data;
249
+ const item =
250
+ rawItem &&
251
+ (rawItem.body !== undefined || (rawItem as { message_id?: string }).message_id !== undefined)
252
+ ? rawItem
253
+ : null;
254
+ if (!item) {
255
+ return null;
256
+ }
257
+
258
+ const msgType = item.msg_type ?? "text";
259
+ const rawContent = item.body?.content ?? "";
260
+ const content = parseQuotedMessageContent(rawContent, msgType);
261
+
262
+ return {
263
+ messageId: item.message_id ?? messageId,
264
+ chatId: item.chat_id ?? "",
265
+ chatType:
266
+ item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p"
267
+ ? item.chat_type
268
+ : undefined,
269
+ senderId: item.sender?.id,
270
+ senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
271
+ senderType: item.sender?.sender_type,
272
+ content,
273
+ contentType: msgType,
274
+ createTime: item.create_time ? parseInt(String(item.create_time), 10) : undefined,
275
+ };
276
+ } catch {
277
+ return null;
278
+ }
279
+ }
280
+
281
+ export type SendFeishuMessageParams = {
282
+ cfg: ClawdbotConfig;
283
+ to: string;
284
+ text: string;
285
+ replyToMessageId?: string;
286
+ /** When true, reply creates a Feishu topic thread instead of an inline reply */
287
+ replyInThread?: boolean;
288
+ /** Mention target users */
289
+ mentions?: MentionTarget[];
290
+ /** Account ID (optional, uses default if not specified) */
291
+ accountId?: string;
292
+ };
293
+
294
+ function buildFeishuPostMessagePayload(params: { messageText: string }): {
295
+ content: string;
296
+ msgType: string;
297
+ } {
298
+ const { messageText } = params;
299
+ return {
300
+ content: JSON.stringify({
301
+ zh_cn: {
302
+ content: [
303
+ [
304
+ {
305
+ tag: "md",
306
+ text: messageText,
307
+ },
308
+ ],
309
+ ],
310
+ },
311
+ }),
312
+ msgType: "post",
313
+ };
314
+ }
315
+
316
+ export async function sendMessageFeishu(
317
+ params: SendFeishuMessageParams,
318
+ ): Promise<FeishuSendResult> {
319
+ const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId } = params;
320
+ const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
321
+ const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
322
+ cfg,
323
+ channel: "feishu",
324
+ });
325
+
326
+ // Build message content (with @mention support)
327
+ let rawText = text ?? "";
328
+ if (mentions && mentions.length > 0) {
329
+ rawText = buildMentionedMessage(mentions, rawText);
330
+ }
331
+ const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(rawText, tableMode);
332
+
333
+ const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
334
+
335
+ const directParams = { receiveId, receiveIdType, content, msgType };
336
+ return sendReplyOrFallbackDirect(client, {
337
+ replyToMessageId,
338
+ replyInThread,
339
+ content,
340
+ msgType,
341
+ directParams,
342
+ directErrorPrefix: "Feishu send failed",
343
+ replyErrorPrefix: "Feishu reply failed",
344
+ });
345
+ }
346
+
347
+ export type SendFeishuCardParams = {
348
+ cfg: ClawdbotConfig;
349
+ to: string;
350
+ card: Record<string, unknown>;
351
+ replyToMessageId?: string;
352
+ /** When true, reply creates a Feishu topic thread instead of an inline reply */
353
+ replyInThread?: boolean;
354
+ accountId?: string;
355
+ };
356
+
357
+ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<FeishuSendResult> {
358
+ const { cfg, to, card, replyToMessageId, replyInThread, accountId } = params;
359
+ const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
360
+ const content = JSON.stringify(card);
361
+
362
+ const directParams = { receiveId, receiveIdType, content, msgType: "interactive" };
363
+ return sendReplyOrFallbackDirect(client, {
364
+ replyToMessageId,
365
+ replyInThread,
366
+ content,
367
+ msgType: "interactive",
368
+ directParams,
369
+ directErrorPrefix: "Feishu card send failed",
370
+ replyErrorPrefix: "Feishu card reply failed",
371
+ });
372
+ }
373
+
374
+ export async function updateCardFeishu(params: {
375
+ cfg: ClawdbotConfig;
376
+ messageId: string;
377
+ card: Record<string, unknown>;
378
+ accountId?: string;
379
+ }): Promise<void> {
380
+ const { cfg, messageId, card, accountId } = params;
381
+ const account = resolveFeishuAccount({ cfg, accountId });
382
+ if (!account.configured) {
383
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
384
+ }
385
+
386
+ const client = createFeishuClient(account);
387
+ const content = JSON.stringify(card);
388
+
389
+ const response = await client.im.message.patch({
390
+ path: { message_id: messageId },
391
+ data: { content },
392
+ });
393
+
394
+ if (response.code !== 0) {
395
+ throw new Error(`Feishu card update failed: ${response.msg || `code ${response.code}`}`);
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Build a Feishu interactive card with markdown content.
401
+ * Cards render markdown properly (code blocks, tables, links, etc.)
402
+ * Uses schema 2.0 format for proper markdown rendering.
403
+ */
404
+ export function buildMarkdownCard(text: string): Record<string, unknown> {
405
+ return {
406
+ schema: "2.0",
407
+ config: {
408
+ wide_screen_mode: true,
409
+ },
410
+ body: {
411
+ elements: [
412
+ {
413
+ tag: "markdown",
414
+ content: text,
415
+ },
416
+ ],
417
+ },
418
+ };
419
+ }
420
+
421
+ /**
422
+ * Send a message as a markdown card (interactive message).
423
+ * This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.)
424
+ */
425
+ export async function sendMarkdownCardFeishu(params: {
426
+ cfg: ClawdbotConfig;
427
+ to: string;
428
+ text: string;
429
+ replyToMessageId?: string;
430
+ /** When true, reply creates a Feishu topic thread instead of an inline reply */
431
+ replyInThread?: boolean;
432
+ /** Mention target users */
433
+ mentions?: MentionTarget[];
434
+ accountId?: string;
435
+ }): Promise<FeishuSendResult> {
436
+ const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId } = params;
437
+ let cardText = text;
438
+ if (mentions && mentions.length > 0) {
439
+ cardText = buildMentionedCardContent(mentions, text);
440
+ }
441
+ const card = buildMarkdownCard(cardText);
442
+ return sendCardFeishu({ cfg, to, card, replyToMessageId, replyInThread, accountId });
443
+ }
444
+
445
+ /**
446
+ * Edit an existing text message.
447
+ * Note: Feishu only allows editing messages within 24 hours.
448
+ */
449
+ export async function editMessageFeishu(params: {
450
+ cfg: ClawdbotConfig;
451
+ messageId: string;
452
+ text: string;
453
+ accountId?: string;
454
+ }): Promise<void> {
455
+ const { cfg, messageId, text, accountId } = params;
456
+ const account = resolveFeishuAccount({ cfg, accountId });
457
+ if (!account.configured) {
458
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
459
+ }
460
+
461
+ const client = createFeishuClient(account);
462
+ const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
463
+ cfg,
464
+ channel: "feishu",
465
+ });
466
+ const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode);
467
+
468
+ const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
469
+
470
+ const response = await client.im.message.update({
471
+ path: { message_id: messageId },
472
+ data: {
473
+ msg_type: msgType,
474
+ content,
475
+ },
476
+ });
477
+
478
+ if (response.code !== 0) {
479
+ throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`);
480
+ }
481
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { mergeStreamingText, resolveStreamingCardSendMode } from "./streaming-card.js";
3
+
4
+ describe("mergeStreamingText", () => {
5
+ it("prefers the latest full text when it already includes prior text", () => {
6
+ expect(mergeStreamingText("hello", "hello world")).toBe("hello world");
7
+ });
8
+
9
+ it("keeps previous text when the next partial is empty or redundant", () => {
10
+ expect(mergeStreamingText("hello", "")).toBe("hello");
11
+ expect(mergeStreamingText("hello world", "hello")).toBe("hello world");
12
+ });
13
+
14
+ it("appends fragmented chunks without injecting newlines", () => {
15
+ expect(mergeStreamingText("hello wor", "ld")).toBe("hello world");
16
+ expect(mergeStreamingText("line1", "line2")).toBe("line1line2");
17
+ });
18
+
19
+ it("merges overlap between adjacent partial snapshots", () => {
20
+ expect(mergeStreamingText("好的,让我", "让我再读取一遍")).toBe("好的,让我再读取一遍");
21
+ expect(mergeStreamingText("revision_id: 552", "2,一点变化都没有")).toBe(
22
+ "revision_id: 552,一点变化都没有",
23
+ );
24
+ expect(mergeStreamingText("abc", "cabc")).toBe("cabc");
25
+ });
26
+ });
27
+
28
+ describe("resolveStreamingCardSendMode", () => {
29
+ it("prefers message.reply when reply target and root id both exist", () => {
30
+ expect(
31
+ resolveStreamingCardSendMode({
32
+ replyToMessageId: "om_parent",
33
+ rootId: "om_topic_root",
34
+ }),
35
+ ).toBe("reply");
36
+ });
37
+
38
+ it("falls back to root create when reply target is absent", () => {
39
+ expect(
40
+ resolveStreamingCardSendMode({
41
+ rootId: "om_topic_root",
42
+ }),
43
+ ).toBe("root_create");
44
+ });
45
+
46
+ it("uses create mode when no reply routing fields are provided", () => {
47
+ expect(resolveStreamingCardSendMode()).toBe("create");
48
+ expect(
49
+ resolveStreamingCardSendMode({
50
+ replyInThread: true,
51
+ }),
52
+ ).toBe("create");
53
+ });
54
+ });