@openclaw/feishu 2026.3.1 → 2026.3.7

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 (76) hide show
  1. package/index.ts +2 -2
  2. package/package.json +1 -1
  3. package/src/accounts.test.ts +268 -11
  4. package/src/accounts.ts +101 -14
  5. package/src/bitable.ts +40 -28
  6. package/src/bot.checkBotMentioned.test.ts +9 -1
  7. package/src/bot.stripBotMention.test.ts +118 -22
  8. package/src/bot.test.ts +945 -77
  9. package/src/bot.ts +492 -165
  10. package/src/card-action.ts +1 -1
  11. package/src/channel.test.ts +1 -1
  12. package/src/channel.ts +72 -68
  13. package/src/chat.test.ts +2 -2
  14. package/src/chat.ts +1 -1
  15. package/src/client.test.ts +221 -4
  16. package/src/client.ts +70 -5
  17. package/src/config-schema.test.ts +33 -6
  18. package/src/config-schema.ts +18 -10
  19. package/src/dedup.ts +47 -1
  20. package/src/directory.test.ts +40 -0
  21. package/src/directory.ts +29 -50
  22. package/src/doc-schema.ts +16 -22
  23. package/src/docx-batch-insert.test.ts +90 -0
  24. package/src/docx-batch-insert.ts +8 -11
  25. package/src/docx.account-selection.test.ts +10 -16
  26. package/src/docx.test.ts +41 -189
  27. package/src/docx.ts +1 -1
  28. package/src/drive.ts +13 -17
  29. package/src/dynamic-agent.ts +1 -1
  30. package/src/feishu-command-handler.ts +59 -0
  31. package/src/media.test.ts +164 -14
  32. package/src/media.ts +44 -10
  33. package/src/mention.ts +1 -1
  34. package/src/monitor.account.ts +284 -25
  35. package/src/monitor.reaction.test.ts +395 -46
  36. package/src/monitor.startup.test.ts +25 -8
  37. package/src/monitor.startup.ts +20 -7
  38. package/src/monitor.state.defaults.test.ts +46 -0
  39. package/src/monitor.state.ts +88 -9
  40. package/src/monitor.test-mocks.ts +45 -0
  41. package/src/monitor.transport.ts +4 -1
  42. package/src/monitor.ts +4 -4
  43. package/src/monitor.webhook-security.test.ts +13 -11
  44. package/src/onboarding.status.test.ts +25 -0
  45. package/src/onboarding.test.ts +143 -0
  46. package/src/onboarding.ts +213 -106
  47. package/src/outbound.test.ts +178 -0
  48. package/src/outbound.ts +39 -6
  49. package/src/perm.ts +11 -15
  50. package/src/policy.test.ts +40 -0
  51. package/src/policy.ts +9 -10
  52. package/src/probe.test.ts +54 -36
  53. package/src/probe.ts +57 -37
  54. package/src/reactions.ts +1 -1
  55. package/src/reply-dispatcher.test.ts +216 -0
  56. package/src/reply-dispatcher.ts +89 -22
  57. package/src/runtime.ts +1 -1
  58. package/src/secret-input.ts +13 -0
  59. package/src/send-message.ts +71 -0
  60. package/src/send-target.test.ts +74 -0
  61. package/src/send-target.ts +7 -3
  62. package/src/send.reply-fallback.test.ts +74 -0
  63. package/src/send.test.ts +1 -1
  64. package/src/send.ts +88 -49
  65. package/src/streaming-card.test.ts +54 -0
  66. package/src/streaming-card.ts +96 -28
  67. package/src/targets.test.ts +29 -0
  68. package/src/targets.ts +25 -1
  69. package/src/tool-account-routing.test.ts +3 -3
  70. package/src/tool-account.ts +1 -1
  71. package/src/tool-factory-test-harness.ts +1 -1
  72. package/src/tool-result.test.ts +32 -0
  73. package/src/tool-result.ts +14 -0
  74. package/src/types.ts +11 -4
  75. package/src/typing.ts +1 -1
  76. package/src/wiki.ts +15 -19
@@ -102,4 +102,78 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
102
102
 
103
103
  expect(createMock).not.toHaveBeenCalled();
104
104
  });
105
+
106
+ it("falls back to create when reply throws a withdrawn SDK error", async () => {
107
+ const sdkError = Object.assign(new Error("request failed"), { code: 230011 });
108
+ replyMock.mockRejectedValue(sdkError);
109
+ createMock.mockResolvedValue({
110
+ code: 0,
111
+ data: { message_id: "om_thrown_fallback" },
112
+ });
113
+
114
+ const result = await sendMessageFeishu({
115
+ cfg: {} as never,
116
+ to: "user:ou_target",
117
+ text: "hello",
118
+ replyToMessageId: "om_parent",
119
+ });
120
+
121
+ expect(replyMock).toHaveBeenCalledTimes(1);
122
+ expect(createMock).toHaveBeenCalledTimes(1);
123
+ expect(result.messageId).toBe("om_thrown_fallback");
124
+ });
125
+
126
+ it("falls back to create when card reply throws a not-found AxiosError", async () => {
127
+ const axiosError = Object.assign(new Error("Request failed"), {
128
+ response: { status: 200, data: { code: 231003, msg: "The message is not found" } },
129
+ });
130
+ replyMock.mockRejectedValue(axiosError);
131
+ createMock.mockResolvedValue({
132
+ code: 0,
133
+ data: { message_id: "om_axios_fallback" },
134
+ });
135
+
136
+ const result = await sendCardFeishu({
137
+ cfg: {} as never,
138
+ to: "user:ou_target",
139
+ card: { schema: "2.0" },
140
+ replyToMessageId: "om_parent",
141
+ });
142
+
143
+ expect(replyMock).toHaveBeenCalledTimes(1);
144
+ expect(createMock).toHaveBeenCalledTimes(1);
145
+ expect(result.messageId).toBe("om_axios_fallback");
146
+ });
147
+
148
+ it("re-throws non-withdrawn thrown errors for text messages", async () => {
149
+ const sdkError = Object.assign(new Error("rate limited"), { code: 99991400 });
150
+ replyMock.mockRejectedValue(sdkError);
151
+
152
+ await expect(
153
+ sendMessageFeishu({
154
+ cfg: {} as never,
155
+ to: "user:ou_target",
156
+ text: "hello",
157
+ replyToMessageId: "om_parent",
158
+ }),
159
+ ).rejects.toThrow("rate limited");
160
+
161
+ expect(createMock).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it("re-throws non-withdrawn thrown errors for card messages", async () => {
165
+ const sdkError = Object.assign(new Error("permission denied"), { code: 99991401 });
166
+ replyMock.mockRejectedValue(sdkError);
167
+
168
+ await expect(
169
+ sendCardFeishu({
170
+ cfg: {} as never,
171
+ to: "user:ou_target",
172
+ card: { schema: "2.0" },
173
+ replyToMessageId: "om_parent",
174
+ }),
175
+ ).rejects.toThrow("permission denied");
176
+
177
+ expect(createMock).not.toHaveBeenCalled();
178
+ });
105
179
  });
package/src/send.test.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ClawdbotConfig } from "openclaw/plugin-sdk";
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { getMessageFeishu } from "./send.js";
4
4
 
package/src/send.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ClawdbotConfig } from "openclaw/plugin-sdk";
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
2
2
  import { resolveFeishuAccount } from "./accounts.js";
3
3
  import { createFeishuClient } from "./client.js";
4
4
  import type { MentionTarget } from "./mention.js";
@@ -19,6 +19,61 @@ function shouldFallbackFromReplyTarget(response: { code?: number; msg?: string }
19
19
  return msg.includes("withdrawn") || msg.includes("not found");
20
20
  }
21
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
+ create: (opts: {
47
+ params: { receive_id_type: "chat_id" | "email" | "open_id" | "union_id" | "user_id" };
48
+ data: { receive_id: string; content: string; msg_type: string };
49
+ }) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>;
50
+ };
51
+ };
52
+ };
53
+
54
+ /** Send a direct message as a fallback when a reply target is unavailable. */
55
+ async function sendFallbackDirect(
56
+ client: FeishuCreateMessageClient,
57
+ params: {
58
+ receiveId: string;
59
+ receiveIdType: "chat_id" | "email" | "open_id" | "union_id" | "user_id";
60
+ content: string;
61
+ msgType: string;
62
+ },
63
+ errorPrefix: string,
64
+ ): Promise<FeishuSendResult> {
65
+ const response = await client.im.message.create({
66
+ params: { receive_id_type: params.receiveIdType },
67
+ data: {
68
+ receive_id: params.receiveId,
69
+ content: params.content,
70
+ msg_type: params.msgType,
71
+ },
72
+ });
73
+ assertFeishuMessageApiSuccess(response, errorPrefix);
74
+ return toFeishuSendResult(response, params.receiveId);
75
+ }
76
+
22
77
  export type FeishuMessageInfo = {
23
78
  messageId: string;
24
79
  chatId: string;
@@ -239,41 +294,33 @@ export async function sendMessageFeishu(
239
294
 
240
295
  const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
241
296
 
297
+ const directParams = { receiveId, receiveIdType, content, msgType };
298
+
242
299
  if (replyToMessageId) {
243
- const response = await client.im.message.reply({
244
- path: { message_id: replyToMessageId },
245
- data: {
246
- content,
247
- msg_type: msgType,
248
- ...(replyInThread ? { reply_in_thread: true } : {}),
249
- },
250
- });
251
- if (shouldFallbackFromReplyTarget(response)) {
252
- const fallback = await client.im.message.create({
253
- params: { receive_id_type: receiveIdType },
300
+ let response: { code?: number; msg?: string; data?: { message_id?: string } };
301
+ try {
302
+ response = await client.im.message.reply({
303
+ path: { message_id: replyToMessageId },
254
304
  data: {
255
- receive_id: receiveId,
256
305
  content,
257
306
  msg_type: msgType,
307
+ ...(replyInThread ? { reply_in_thread: true } : {}),
258
308
  },
259
309
  });
260
- assertFeishuMessageApiSuccess(fallback, "Feishu send failed");
261
- return toFeishuSendResult(fallback, receiveId);
310
+ } catch (err) {
311
+ if (!isWithdrawnReplyError(err)) {
312
+ throw err;
313
+ }
314
+ return sendFallbackDirect(client, directParams, "Feishu send failed");
315
+ }
316
+ if (shouldFallbackFromReplyTarget(response)) {
317
+ return sendFallbackDirect(client, directParams, "Feishu send failed");
262
318
  }
263
319
  assertFeishuMessageApiSuccess(response, "Feishu reply failed");
264
320
  return toFeishuSendResult(response, receiveId);
265
321
  }
266
322
 
267
- const response = await client.im.message.create({
268
- params: { receive_id_type: receiveIdType },
269
- data: {
270
- receive_id: receiveId,
271
- content,
272
- msg_type: msgType,
273
- },
274
- });
275
- assertFeishuMessageApiSuccess(response, "Feishu send failed");
276
- return toFeishuSendResult(response, receiveId);
323
+ return sendFallbackDirect(client, directParams, "Feishu send failed");
277
324
  }
278
325
 
279
326
  export type SendFeishuCardParams = {
@@ -291,41 +338,33 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
291
338
  const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
292
339
  const content = JSON.stringify(card);
293
340
 
341
+ const directParams = { receiveId, receiveIdType, content, msgType: "interactive" };
342
+
294
343
  if (replyToMessageId) {
295
- const response = await client.im.message.reply({
296
- path: { message_id: replyToMessageId },
297
- data: {
298
- content,
299
- msg_type: "interactive",
300
- ...(replyInThread ? { reply_in_thread: true } : {}),
301
- },
302
- });
303
- if (shouldFallbackFromReplyTarget(response)) {
304
- const fallback = await client.im.message.create({
305
- params: { receive_id_type: receiveIdType },
344
+ let response: { code?: number; msg?: string; data?: { message_id?: string } };
345
+ try {
346
+ response = await client.im.message.reply({
347
+ path: { message_id: replyToMessageId },
306
348
  data: {
307
- receive_id: receiveId,
308
349
  content,
309
350
  msg_type: "interactive",
351
+ ...(replyInThread ? { reply_in_thread: true } : {}),
310
352
  },
311
353
  });
312
- assertFeishuMessageApiSuccess(fallback, "Feishu card send failed");
313
- return toFeishuSendResult(fallback, receiveId);
354
+ } catch (err) {
355
+ if (!isWithdrawnReplyError(err)) {
356
+ throw err;
357
+ }
358
+ return sendFallbackDirect(client, directParams, "Feishu card send failed");
359
+ }
360
+ if (shouldFallbackFromReplyTarget(response)) {
361
+ return sendFallbackDirect(client, directParams, "Feishu card send failed");
314
362
  }
315
363
  assertFeishuMessageApiSuccess(response, "Feishu card reply failed");
316
364
  return toFeishuSendResult(response, receiveId);
317
365
  }
318
366
 
319
- const response = await client.im.message.create({
320
- params: { receive_id_type: receiveIdType },
321
- data: {
322
- receive_id: receiveId,
323
- content,
324
- msg_type: "interactive",
325
- },
326
- });
327
- assertFeishuMessageApiSuccess(response, "Feishu card send failed");
328
- return toFeishuSendResult(response, receiveId);
367
+ return sendFallbackDirect(client, directParams, "Feishu card send failed");
329
368
  }
330
369
 
331
370
  export async function updateCardFeishu(params: {
@@ -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
+ });
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { Client } from "@larksuiteoapi/node-sdk";
6
- import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
6
+ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/feishu";
7
7
  import type { FeishuDomain } from "./types.js";
8
8
 
9
9
  type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
@@ -16,6 +16,13 @@ export type StreamingCardHeader = {
16
16
  template?: string;
17
17
  };
18
18
 
19
+ type StreamingStartOptions = {
20
+ replyToMessageId?: string;
21
+ replyInThread?: boolean;
22
+ rootId?: string;
23
+ header?: StreamingCardHeader;
24
+ };
25
+
19
26
  // Token cache (keyed by domain + appId)
20
27
  const tokenCache = new Map<string, { token: string; expiresAt: number }>();
21
28
 
@@ -60,6 +67,10 @@ async function getToken(creds: Credentials): Promise<string> {
60
67
  policy: { allowedHostnames: resolveAllowedHostnames(creds.domain) },
61
68
  auditContext: "feishu.streaming-card.token",
62
69
  });
70
+ if (!response.ok) {
71
+ await release();
72
+ throw new Error(`Token request failed with HTTP ${response.status}`);
73
+ }
63
74
  const data = (await response.json()) as {
64
75
  code: number;
65
76
  msg: string;
@@ -85,6 +96,52 @@ function truncateSummary(text: string, max = 50): string {
85
96
  return clean.length <= max ? clean : clean.slice(0, max - 3) + "...";
86
97
  }
87
98
 
99
+ export function mergeStreamingText(
100
+ previousText: string | undefined,
101
+ nextText: string | undefined,
102
+ ): string {
103
+ const previous = typeof previousText === "string" ? previousText : "";
104
+ const next = typeof nextText === "string" ? nextText : "";
105
+ if (!next) {
106
+ return previous;
107
+ }
108
+ if (!previous || next === previous) {
109
+ return next;
110
+ }
111
+ if (next.startsWith(previous)) {
112
+ return next;
113
+ }
114
+ if (previous.startsWith(next)) {
115
+ return previous;
116
+ }
117
+ if (next.includes(previous)) {
118
+ return next;
119
+ }
120
+ if (previous.includes(next)) {
121
+ return previous;
122
+ }
123
+
124
+ // Merge partial overlaps, e.g. "这" + "这是" => "这是".
125
+ const maxOverlap = Math.min(previous.length, next.length);
126
+ for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
127
+ if (previous.slice(-overlap) === next.slice(0, overlap)) {
128
+ return `${previous}${next.slice(overlap)}`;
129
+ }
130
+ }
131
+ // Fallback for fragmented partial chunks: append as-is to avoid losing tokens.
132
+ return `${previous}${next}`;
133
+ }
134
+
135
+ export function resolveStreamingCardSendMode(options?: StreamingStartOptions) {
136
+ if (options?.replyToMessageId) {
137
+ return "reply";
138
+ }
139
+ if (options?.rootId) {
140
+ return "root_create";
141
+ }
142
+ return "create";
143
+ }
144
+
88
145
  /** Streaming card session manager */
89
146
  export class FeishuStreamingSession {
90
147
  private client: Client;
@@ -106,12 +163,7 @@ export class FeishuStreamingSession {
106
163
  async start(
107
164
  receiveId: string,
108
165
  receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
109
- options?: {
110
- replyToMessageId?: string;
111
- replyInThread?: boolean;
112
- rootId?: string;
113
- header?: StreamingCardHeader;
114
- },
166
+ options?: StreamingStartOptions,
115
167
  ): Promise<void> {
116
168
  if (this.state) {
117
169
  return;
@@ -123,7 +175,7 @@ export class FeishuStreamingSession {
123
175
  config: {
124
176
  streaming_mode: true,
125
177
  summary: { content: "[Generating...]" },
126
- streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 2 } },
178
+ streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 1 } },
127
179
  },
128
180
  body: {
129
181
  elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }],
@@ -150,6 +202,10 @@ export class FeishuStreamingSession {
150
202
  policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
151
203
  auditContext: "feishu.streaming-card.create",
152
204
  });
205
+ if (!createRes.ok) {
206
+ await releaseCreate();
207
+ throw new Error(`Create card request failed with HTTP ${createRes.status}`);
208
+ }
153
209
  const createData = (await createRes.json()) as {
154
210
  code: number;
155
211
  msg: string;
@@ -162,28 +218,31 @@ export class FeishuStreamingSession {
162
218
  const cardId = createData.data.card_id;
163
219
  const cardContent = JSON.stringify({ type: "card", data: { card_id: cardId } });
164
220
 
165
- // Topic-group replies require root_id routing. Prefer create+root_id when available.
221
+ // Prefer message.reply when we have a reply target — reply_in_thread
222
+ // reliably routes streaming cards into Feishu topics, whereas
223
+ // message.create with root_id may silently ignore root_id for card
224
+ // references (card_id format).
166
225
  let sendRes;
167
- if (options?.rootId) {
168
- const createData = {
169
- receive_id: receiveId,
170
- msg_type: "interactive",
171
- content: cardContent,
172
- root_id: options.rootId,
173
- };
174
- sendRes = await this.client.im.message.create({
175
- params: { receive_id_type: receiveIdType },
176
- data: createData,
177
- });
178
- } else if (options?.replyToMessageId) {
226
+ const sendOptions = options ?? {};
227
+ const sendMode = resolveStreamingCardSendMode(sendOptions);
228
+ if (sendMode === "reply") {
179
229
  sendRes = await this.client.im.message.reply({
180
- path: { message_id: options.replyToMessageId },
230
+ path: { message_id: sendOptions.replyToMessageId! },
181
231
  data: {
182
232
  msg_type: "interactive",
183
233
  content: cardContent,
184
- ...(options.replyInThread ? { reply_in_thread: true } : {}),
234
+ ...(sendOptions.replyInThread ? { reply_in_thread: true } : {}),
185
235
  },
186
236
  });
237
+ } else if (sendMode === "root_create") {
238
+ // root_id is undeclared in the SDK types but accepted at runtime
239
+ sendRes = await this.client.im.message.create({
240
+ params: { receive_id_type: receiveIdType },
241
+ data: Object.assign(
242
+ { receive_id: receiveId, msg_type: "interactive", content: cardContent },
243
+ { root_id: sendOptions.rootId },
244
+ ),
245
+ });
187
246
  } else {
188
247
  sendRes = await this.client.im.message.create({
189
248
  params: { receive_id_type: receiveIdType },
@@ -235,10 +294,15 @@ export class FeishuStreamingSession {
235
294
  if (!this.state || this.closed) {
236
295
  return;
237
296
  }
297
+ const mergedInput = mergeStreamingText(this.pendingText ?? this.state.currentText, text);
298
+ if (!mergedInput || mergedInput === this.state.currentText) {
299
+ return;
300
+ }
301
+
238
302
  // Throttle: skip if updated recently, but remember pending text
239
303
  const now = Date.now();
240
304
  if (now - this.lastUpdateTime < this.updateThrottleMs) {
241
- this.pendingText = text;
305
+ this.pendingText = mergedInput;
242
306
  return;
243
307
  }
244
308
  this.pendingText = null;
@@ -248,8 +312,12 @@ export class FeishuStreamingSession {
248
312
  if (!this.state || this.closed) {
249
313
  return;
250
314
  }
251
- this.state.currentText = text;
252
- await this.updateCardContent(text, (e) => this.log?.(`Update failed: ${String(e)}`));
315
+ const mergedText = mergeStreamingText(this.state.currentText, mergedInput);
316
+ if (!mergedText || mergedText === this.state.currentText) {
317
+ return;
318
+ }
319
+ this.state.currentText = mergedText;
320
+ await this.updateCardContent(mergedText, (e) => this.log?.(`Update failed: ${String(e)}`));
253
321
  });
254
322
  await this.queue;
255
323
  }
@@ -261,8 +329,8 @@ export class FeishuStreamingSession {
261
329
  this.closed = true;
262
330
  await this.queue;
263
331
 
264
- // Use finalText, or pending throttled text, or current text
265
- const text = finalText ?? this.pendingText ?? this.state.currentText;
332
+ const pendingMerged = mergeStreamingText(this.state.currentText, this.pendingText ?? undefined);
333
+ const text = finalText ? mergeStreamingText(pendingMerged, finalText) : pendingMerged;
266
334
  const apiBase = resolveApiBase(this.creds.domain);
267
335
 
268
336
  // Only send final update if content differs from what's already displayed
@@ -13,6 +13,18 @@ describe("resolveReceiveIdType", () => {
13
13
  it("defaults unprefixed IDs to user_id", () => {
14
14
  expect(resolveReceiveIdType("u_123")).toBe("user_id");
15
15
  });
16
+
17
+ it("treats explicit group targets as chat_id", () => {
18
+ expect(resolveReceiveIdType("group:oc_123")).toBe("chat_id");
19
+ });
20
+
21
+ it("treats explicit channel targets as chat_id", () => {
22
+ expect(resolveReceiveIdType("channel:oc_123")).toBe("chat_id");
23
+ });
24
+
25
+ it("treats dm-prefixed open IDs as open_id", () => {
26
+ expect(resolveReceiveIdType("dm:ou_123")).toBe("open_id");
27
+ });
16
28
  });
17
29
 
18
30
  describe("normalizeFeishuTarget", () => {
@@ -25,9 +37,20 @@ describe("normalizeFeishuTarget", () => {
25
37
  expect(normalizeFeishuTarget("feishu:chat:oc_123")).toBe("oc_123");
26
38
  });
27
39
 
40
+ it("normalizes group/channel prefixes to chat ids", () => {
41
+ expect(normalizeFeishuTarget("group:oc_123")).toBe("oc_123");
42
+ expect(normalizeFeishuTarget("feishu:group:oc_123")).toBe("oc_123");
43
+ expect(normalizeFeishuTarget("channel:oc_456")).toBe("oc_456");
44
+ expect(normalizeFeishuTarget("lark:channel:oc_456")).toBe("oc_456");
45
+ });
46
+
28
47
  it("accepts provider-prefixed raw ids", () => {
29
48
  expect(normalizeFeishuTarget("feishu:ou_123")).toBe("ou_123");
30
49
  });
50
+
51
+ it("strips provider and dm prefixes", () => {
52
+ expect(normalizeFeishuTarget("lark:dm:ou_123")).toBe("ou_123");
53
+ });
31
54
  });
32
55
 
33
56
  describe("looksLikeFeishuId", () => {
@@ -38,4 +61,10 @@ describe("looksLikeFeishuId", () => {
38
61
  it("accepts provider-prefixed chat targets", () => {
39
62
  expect(looksLikeFeishuId("lark:chat:oc_123")).toBe(true);
40
63
  });
64
+
65
+ it("accepts group/channel targets", () => {
66
+ expect(looksLikeFeishuId("feishu:group:oc_123")).toBe(true);
67
+ expect(looksLikeFeishuId("group:oc_123")).toBe(true);
68
+ expect(looksLikeFeishuId("channel:oc_456")).toBe(true);
69
+ });
41
70
  });
package/src/targets.ts CHANGED
@@ -33,9 +33,18 @@ export function normalizeFeishuTarget(raw: string): string | null {
33
33
  if (lowered.startsWith("chat:")) {
34
34
  return withoutProvider.slice("chat:".length).trim() || null;
35
35
  }
36
+ if (lowered.startsWith("group:")) {
37
+ return withoutProvider.slice("group:".length).trim() || null;
38
+ }
39
+ if (lowered.startsWith("channel:")) {
40
+ return withoutProvider.slice("channel:".length).trim() || null;
41
+ }
36
42
  if (lowered.startsWith("user:")) {
37
43
  return withoutProvider.slice("user:".length).trim() || null;
38
44
  }
45
+ if (lowered.startsWith("dm:")) {
46
+ return withoutProvider.slice("dm:".length).trim() || null;
47
+ }
39
48
  if (lowered.startsWith("open_id:")) {
40
49
  return withoutProvider.slice("open_id:".length).trim() || null;
41
50
  }
@@ -56,6 +65,21 @@ export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
56
65
 
57
66
  export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_id" {
58
67
  const trimmed = id.trim();
68
+ const lowered = trimmed.toLowerCase();
69
+ if (
70
+ lowered.startsWith("chat:") ||
71
+ lowered.startsWith("group:") ||
72
+ lowered.startsWith("channel:")
73
+ ) {
74
+ return "chat_id";
75
+ }
76
+ if (lowered.startsWith("open_id:")) {
77
+ return "open_id";
78
+ }
79
+ if (lowered.startsWith("user:") || lowered.startsWith("dm:")) {
80
+ const normalized = trimmed.replace(/^(user|dm):/i, "").trim();
81
+ return normalized.startsWith(OPEN_ID_PREFIX) ? "open_id" : "user_id";
82
+ }
59
83
  if (trimmed.startsWith(CHAT_ID_PREFIX)) {
60
84
  return "chat_id";
61
85
  }
@@ -70,7 +94,7 @@ export function looksLikeFeishuId(raw: string): boolean {
70
94
  if (!trimmed) {
71
95
  return false;
72
96
  }
73
- if (/^(chat|user|open_id):/i.test(trimmed)) {
97
+ if (/^(chat|group|channel|user|dm|open_id):/i.test(trimmed)) {
74
98
  return true;
75
99
  }
76
100
  if (trimmed.startsWith(CHAT_ID_PREFIX)) {
@@ -1,4 +1,4 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
2
2
  import { beforeEach, describe, expect, test, vi } from "vitest";
3
3
  import { registerFeishuBitableTools } from "./bitable.js";
4
4
  import { registerFeishuDriveTools } from "./drive.js";
@@ -35,12 +35,12 @@ function createConfig(params: {
35
35
  accounts: {
36
36
  a: {
37
37
  appId: "app-a",
38
- appSecret: "sec-a",
38
+ appSecret: "sec-a", // pragma: allowlist secret
39
39
  tools: params.toolsA,
40
40
  },
41
41
  b: {
42
42
  appId: "app-b",
43
- appSecret: "sec-b",
43
+ appSecret: "sec-b", // pragma: allowlist secret
44
44
  tools: params.toolsB,
45
45
  },
46
46
  },
@@ -1,5 +1,5 @@
1
1
  import type * as Lark from "@larksuiteoapi/node-sdk";
2
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
3
3
  import { resolveFeishuAccount } from "./accounts.js";
4
4
  import { createFeishuClient } from "./client.js";
5
5
  import { resolveToolsConfig } from "./tools-config.js";
@@ -1,4 +1,4 @@
1
- import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
1
+ import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
2
2
 
3
3
  type ToolContextLike = {
4
4
  agentAccountId?: string;