@openclaw/feishu 2026.2.24 → 2026.3.1

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 (64) hide show
  1. package/index.ts +2 -0
  2. package/package.json +2 -1
  3. package/skills/feishu-doc/SKILL.md +109 -3
  4. package/src/accounts.test.ts +90 -0
  5. package/src/accounts.ts +11 -2
  6. package/src/async.ts +62 -0
  7. package/src/bitable.ts +189 -215
  8. package/src/bot.card-action.test.ts +63 -0
  9. package/src/bot.checkBotMentioned.test.ts +55 -0
  10. package/src/bot.test.ts +863 -9
  11. package/src/bot.ts +414 -200
  12. package/src/card-action.ts +79 -0
  13. package/src/channel.ts +6 -0
  14. package/src/chat-schema.ts +24 -0
  15. package/src/chat.test.ts +89 -0
  16. package/src/chat.ts +130 -0
  17. package/src/client.test.ts +107 -0
  18. package/src/client.ts +13 -0
  19. package/src/config-schema.test.ts +82 -1
  20. package/src/config-schema.ts +54 -3
  21. package/src/doc-schema.ts +141 -0
  22. package/src/docx-batch-insert.ts +190 -0
  23. package/src/docx-color-text.ts +149 -0
  24. package/src/docx-table-ops.ts +298 -0
  25. package/src/docx.account-selection.test.ts +76 -0
  26. package/src/docx.test.ts +470 -0
  27. package/src/docx.ts +996 -72
  28. package/src/drive.ts +38 -33
  29. package/src/media.test.ts +123 -6
  30. package/src/media.ts +31 -10
  31. package/src/monitor.account.ts +286 -0
  32. package/src/monitor.reaction.test.ts +235 -0
  33. package/src/monitor.startup.test.ts +187 -0
  34. package/src/monitor.startup.ts +51 -0
  35. package/src/monitor.state.ts +76 -0
  36. package/src/monitor.transport.ts +163 -0
  37. package/src/monitor.ts +44 -346
  38. package/src/monitor.webhook-security.test.ts +27 -1
  39. package/src/outbound.test.ts +181 -0
  40. package/src/outbound.ts +94 -7
  41. package/src/perm.ts +37 -30
  42. package/src/policy.test.ts +56 -1
  43. package/src/policy.ts +5 -1
  44. package/src/post.test.ts +105 -0
  45. package/src/post.ts +274 -0
  46. package/src/probe.test.ts +253 -0
  47. package/src/probe.ts +99 -7
  48. package/src/reply-dispatcher.test.ts +259 -0
  49. package/src/reply-dispatcher.ts +139 -45
  50. package/src/send.reply-fallback.test.ts +105 -0
  51. package/src/send.test.ts +168 -0
  52. package/src/send.ts +143 -18
  53. package/src/streaming-card.ts +131 -43
  54. package/src/targets.test.ts +26 -1
  55. package/src/targets.ts +11 -6
  56. package/src/tool-account-routing.test.ts +129 -0
  57. package/src/tool-account.ts +70 -0
  58. package/src/tool-factory-test-harness.ts +76 -0
  59. package/src/tools-config.test.ts +21 -0
  60. package/src/tools-config.ts +2 -1
  61. package/src/types.ts +1 -0
  62. package/src/typing.test.ts +144 -0
  63. package/src/typing.ts +140 -10
  64. package/src/wiki.ts +55 -50
@@ -8,6 +8,7 @@ import {
8
8
  } from "openclaw/plugin-sdk";
9
9
  import { resolveFeishuAccount } from "./accounts.js";
10
10
  import { createFeishuClient } from "./client.js";
11
+ import { sendMediaFeishu } from "./media.js";
11
12
  import type { MentionTarget } from "./mention.js";
12
13
  import { buildMentionedCardContent } from "./mention.js";
13
14
  import { getFeishuRuntime } from "./runtime.js";
@@ -21,35 +22,85 @@ function shouldUseCard(text: string): boolean {
21
22
  return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
22
23
  }
23
24
 
25
+ /** Maximum age (ms) for a message to receive a typing indicator reaction.
26
+ * Messages older than this are likely replays after context compaction (#30418). */
27
+ const TYPING_INDICATOR_MAX_AGE_MS = 2 * 60_000;
28
+ const MS_EPOCH_MIN = 1_000_000_000_000;
29
+
30
+ function normalizeEpochMs(timestamp: number | undefined): number | undefined {
31
+ if (!Number.isFinite(timestamp) || timestamp === undefined || timestamp <= 0) {
32
+ return undefined;
33
+ }
34
+ // Defensive normalization: some payloads use seconds, others milliseconds.
35
+ // Values below 1e12 are treated as epoch-seconds.
36
+ return timestamp < MS_EPOCH_MIN ? timestamp * 1000 : timestamp;
37
+ }
38
+
24
39
  export type CreateFeishuReplyDispatcherParams = {
25
40
  cfg: ClawdbotConfig;
26
41
  agentId: string;
27
42
  runtime: RuntimeEnv;
28
43
  chatId: string;
29
44
  replyToMessageId?: string;
45
+ /** When true, preserve typing indicator on reply target but send messages without reply metadata */
46
+ skipReplyToInMessages?: boolean;
47
+ replyInThread?: boolean;
48
+ rootId?: string;
30
49
  mentionTargets?: MentionTarget[];
31
50
  accountId?: string;
51
+ /** Epoch ms when the inbound message was created. Used to suppress typing
52
+ * indicators on old/replayed messages after context compaction (#30418). */
53
+ messageCreateTimeMs?: number;
32
54
  };
33
55
 
34
56
  export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
35
57
  const core = getFeishuRuntime();
36
- const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId } = params;
58
+ const {
59
+ cfg,
60
+ agentId,
61
+ chatId,
62
+ replyToMessageId,
63
+ skipReplyToInMessages,
64
+ replyInThread,
65
+ rootId,
66
+ mentionTargets,
67
+ accountId,
68
+ } = params;
69
+ const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
37
70
  const account = resolveFeishuAccount({ cfg, accountId });
38
71
  const prefixContext = createReplyPrefixContext({ cfg, agentId });
39
72
 
40
73
  let typingState: TypingIndicatorState | null = null;
41
74
  const typingCallbacks = createTypingCallbacks({
42
75
  start: async () => {
76
+ // Check if typing indicator is enabled (default: true)
77
+ if (!(account.config.typingIndicator ?? true)) {
78
+ return;
79
+ }
43
80
  if (!replyToMessageId) {
44
81
  return;
45
82
  }
46
- typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId, accountId });
83
+ // Skip typing indicator for old messages likely replays after context
84
+ // compaction that would flood users with stale notifications (#30418).
85
+ const messageCreateTimeMs = normalizeEpochMs(params.messageCreateTimeMs);
86
+ if (
87
+ messageCreateTimeMs !== undefined &&
88
+ Date.now() - messageCreateTimeMs > TYPING_INDICATOR_MAX_AGE_MS
89
+ ) {
90
+ return;
91
+ }
92
+ typingState = await addTypingIndicator({
93
+ cfg,
94
+ messageId: replyToMessageId,
95
+ accountId,
96
+ runtime: params.runtime,
97
+ });
47
98
  },
48
99
  stop: async () => {
49
100
  if (!typingState) {
50
101
  return;
51
102
  }
52
- await removeTypingIndicator({ cfg, state: typingState, accountId });
103
+ await removeTypingIndicator({ cfg, state: typingState, accountId, runtime: params.runtime });
53
104
  typingState = null;
54
105
  },
55
106
  onStartError: (err) =>
@@ -99,7 +150,11 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
99
150
  params.runtime.log?.(`feishu[${account.accountId}] ${message}`),
100
151
  );
101
152
  try {
102
- await streaming.start(chatId, resolveReceiveIdType(chatId));
153
+ await streaming.start(chatId, resolveReceiveIdType(chatId), {
154
+ replyToMessageId,
155
+ replyInThread,
156
+ rootId,
157
+ });
103
158
  } catch (error) {
104
159
  params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`);
105
160
  streaming = null;
@@ -138,60 +193,99 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
138
193
  },
139
194
  deliver: async (payload: ReplyPayload, info) => {
140
195
  const text = payload.text ?? "";
141
- if (!text.trim()) {
196
+ const mediaList =
197
+ payload.mediaUrls && payload.mediaUrls.length > 0
198
+ ? payload.mediaUrls
199
+ : payload.mediaUrl
200
+ ? [payload.mediaUrl]
201
+ : [];
202
+ const hasText = Boolean(text.trim());
203
+ const hasMedia = mediaList.length > 0;
204
+
205
+ if (!hasText && !hasMedia) {
142
206
  return;
143
207
  }
144
208
 
145
- const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
209
+ if (hasText) {
210
+ const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
146
211
 
147
- if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) {
148
- startStreaming();
149
- if (streamingStartPromise) {
150
- await streamingStartPromise;
212
+ if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) {
213
+ startStreaming();
214
+ if (streamingStartPromise) {
215
+ await streamingStartPromise;
216
+ }
217
+ }
218
+
219
+ if (streaming?.isActive()) {
220
+ if (info?.kind === "final") {
221
+ streamText = text;
222
+ await closeStreaming();
223
+ }
224
+ // Send media even when streaming handled the text
225
+ if (hasMedia) {
226
+ for (const mediaUrl of mediaList) {
227
+ await sendMediaFeishu({
228
+ cfg,
229
+ to: chatId,
230
+ mediaUrl,
231
+ replyToMessageId: sendReplyToMessageId,
232
+ replyInThread,
233
+ accountId,
234
+ });
235
+ }
236
+ }
237
+ return;
151
238
  }
152
- }
153
239
 
154
- if (streaming?.isActive()) {
155
- if (info?.kind === "final") {
156
- streamText = text;
157
- await closeStreaming();
240
+ let first = true;
241
+ if (useCard) {
242
+ for (const chunk of core.channel.text.chunkTextWithMode(
243
+ text,
244
+ textChunkLimit,
245
+ chunkMode,
246
+ )) {
247
+ await sendMarkdownCardFeishu({
248
+ cfg,
249
+ to: chatId,
250
+ text: chunk,
251
+ replyToMessageId: sendReplyToMessageId,
252
+ replyInThread,
253
+ mentions: first ? mentionTargets : undefined,
254
+ accountId,
255
+ });
256
+ first = false;
257
+ }
258
+ } else {
259
+ const converted = core.channel.text.convertMarkdownTables(text, tableMode);
260
+ for (const chunk of core.channel.text.chunkTextWithMode(
261
+ converted,
262
+ textChunkLimit,
263
+ chunkMode,
264
+ )) {
265
+ await sendMessageFeishu({
266
+ cfg,
267
+ to: chatId,
268
+ text: chunk,
269
+ replyToMessageId: sendReplyToMessageId,
270
+ replyInThread,
271
+ mentions: first ? mentionTargets : undefined,
272
+ accountId,
273
+ });
274
+ first = false;
275
+ }
158
276
  }
159
- return;
160
277
  }
161
278
 
162
- let first = true;
163
- if (useCard) {
164
- for (const chunk of core.channel.text.chunkTextWithMode(
165
- text,
166
- textChunkLimit,
167
- chunkMode,
168
- )) {
169
- await sendMarkdownCardFeishu({
170
- cfg,
171
- to: chatId,
172
- text: chunk,
173
- replyToMessageId,
174
- mentions: first ? mentionTargets : undefined,
175
- accountId,
176
- });
177
- first = false;
178
- }
179
- } else {
180
- const converted = core.channel.text.convertMarkdownTables(text, tableMode);
181
- for (const chunk of core.channel.text.chunkTextWithMode(
182
- converted,
183
- textChunkLimit,
184
- chunkMode,
185
- )) {
186
- await sendMessageFeishu({
279
+ if (hasMedia) {
280
+ for (const mediaUrl of mediaList) {
281
+ await sendMediaFeishu({
187
282
  cfg,
188
283
  to: chatId,
189
- text: chunk,
190
- replyToMessageId,
191
- mentions: first ? mentionTargets : undefined,
284
+ mediaUrl,
285
+ replyToMessageId: sendReplyToMessageId,
286
+ replyInThread,
192
287
  accountId,
193
288
  });
194
- first = false;
195
289
  }
196
290
  }
197
291
  },
@@ -0,0 +1,105 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const resolveFeishuSendTargetMock = vi.hoisted(() => vi.fn());
4
+ const resolveMarkdownTableModeMock = vi.hoisted(() => vi.fn(() => "preserve"));
5
+ const convertMarkdownTablesMock = vi.hoisted(() => vi.fn((text: string) => text));
6
+
7
+ vi.mock("./send-target.js", () => ({
8
+ resolveFeishuSendTarget: resolveFeishuSendTargetMock,
9
+ }));
10
+
11
+ vi.mock("./runtime.js", () => ({
12
+ getFeishuRuntime: () => ({
13
+ channel: {
14
+ text: {
15
+ resolveMarkdownTableMode: resolveMarkdownTableModeMock,
16
+ convertMarkdownTables: convertMarkdownTablesMock,
17
+ },
18
+ },
19
+ }),
20
+ }));
21
+
22
+ import { sendCardFeishu, sendMessageFeishu } from "./send.js";
23
+
24
+ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
25
+ const replyMock = vi.fn();
26
+ const createMock = vi.fn();
27
+
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+ resolveFeishuSendTargetMock.mockReturnValue({
31
+ client: {
32
+ im: {
33
+ message: {
34
+ reply: replyMock,
35
+ create: createMock,
36
+ },
37
+ },
38
+ },
39
+ receiveId: "ou_target",
40
+ receiveIdType: "open_id",
41
+ });
42
+ });
43
+
44
+ it("falls back to create for withdrawn post replies", async () => {
45
+ replyMock.mockResolvedValue({
46
+ code: 230011,
47
+ msg: "The message was withdrawn.",
48
+ });
49
+ createMock.mockResolvedValue({
50
+ code: 0,
51
+ data: { message_id: "om_new" },
52
+ });
53
+
54
+ const result = await sendMessageFeishu({
55
+ cfg: {} as never,
56
+ to: "user:ou_target",
57
+ text: "hello",
58
+ replyToMessageId: "om_parent",
59
+ });
60
+
61
+ expect(replyMock).toHaveBeenCalledTimes(1);
62
+ expect(createMock).toHaveBeenCalledTimes(1);
63
+ expect(result.messageId).toBe("om_new");
64
+ });
65
+
66
+ it("falls back to create for withdrawn card replies", async () => {
67
+ replyMock.mockResolvedValue({
68
+ code: 231003,
69
+ msg: "The message is not found",
70
+ });
71
+ createMock.mockResolvedValue({
72
+ code: 0,
73
+ data: { message_id: "om_card_new" },
74
+ });
75
+
76
+ const result = await sendCardFeishu({
77
+ cfg: {} as never,
78
+ to: "user:ou_target",
79
+ card: { schema: "2.0" },
80
+ replyToMessageId: "om_parent",
81
+ });
82
+
83
+ expect(replyMock).toHaveBeenCalledTimes(1);
84
+ expect(createMock).toHaveBeenCalledTimes(1);
85
+ expect(result.messageId).toBe("om_card_new");
86
+ });
87
+
88
+ it("still throws for non-withdrawn reply failures", async () => {
89
+ replyMock.mockResolvedValue({
90
+ code: 999999,
91
+ msg: "unknown failure",
92
+ });
93
+
94
+ await expect(
95
+ sendMessageFeishu({
96
+ cfg: {} as never,
97
+ to: "user:ou_target",
98
+ text: "hello",
99
+ replyToMessageId: "om_parent",
100
+ }),
101
+ ).rejects.toThrow("Feishu reply failed");
102
+
103
+ expect(createMock).not.toHaveBeenCalled();
104
+ });
105
+ });
@@ -0,0 +1,168 @@
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { getMessageFeishu } from "./send.js";
4
+
5
+ const { mockClientGet, mockCreateFeishuClient, mockResolveFeishuAccount } = vi.hoisted(() => ({
6
+ mockClientGet: vi.fn(),
7
+ mockCreateFeishuClient: vi.fn(),
8
+ mockResolveFeishuAccount: vi.fn(),
9
+ }));
10
+
11
+ vi.mock("./client.js", () => ({
12
+ createFeishuClient: mockCreateFeishuClient,
13
+ }));
14
+
15
+ vi.mock("./accounts.js", () => ({
16
+ resolveFeishuAccount: mockResolveFeishuAccount,
17
+ }));
18
+
19
+ describe("getMessageFeishu", () => {
20
+ beforeEach(() => {
21
+ vi.clearAllMocks();
22
+ mockResolveFeishuAccount.mockReturnValue({
23
+ accountId: "default",
24
+ configured: true,
25
+ });
26
+ mockCreateFeishuClient.mockReturnValue({
27
+ im: {
28
+ message: {
29
+ get: mockClientGet,
30
+ },
31
+ },
32
+ });
33
+ });
34
+
35
+ it("extracts text content from interactive card elements", async () => {
36
+ mockClientGet.mockResolvedValueOnce({
37
+ code: 0,
38
+ data: {
39
+ items: [
40
+ {
41
+ message_id: "om_1",
42
+ chat_id: "oc_1",
43
+ msg_type: "interactive",
44
+ body: {
45
+ content: JSON.stringify({
46
+ elements: [
47
+ { tag: "markdown", content: "hello markdown" },
48
+ { tag: "div", text: { content: "hello div" } },
49
+ ],
50
+ }),
51
+ },
52
+ },
53
+ ],
54
+ },
55
+ });
56
+
57
+ const result = await getMessageFeishu({
58
+ cfg: {} as ClawdbotConfig,
59
+ messageId: "om_1",
60
+ });
61
+
62
+ expect(result).toEqual(
63
+ expect.objectContaining({
64
+ messageId: "om_1",
65
+ chatId: "oc_1",
66
+ contentType: "interactive",
67
+ content: "hello markdown\nhello div",
68
+ }),
69
+ );
70
+ });
71
+
72
+ it("extracts text content from post messages", async () => {
73
+ mockClientGet.mockResolvedValueOnce({
74
+ code: 0,
75
+ data: {
76
+ items: [
77
+ {
78
+ message_id: "om_post",
79
+ chat_id: "oc_post",
80
+ msg_type: "post",
81
+ body: {
82
+ content: JSON.stringify({
83
+ zh_cn: {
84
+ title: "Summary",
85
+ content: [[{ tag: "text", text: "post body" }]],
86
+ },
87
+ }),
88
+ },
89
+ },
90
+ ],
91
+ },
92
+ });
93
+
94
+ const result = await getMessageFeishu({
95
+ cfg: {} as ClawdbotConfig,
96
+ messageId: "om_post",
97
+ });
98
+
99
+ expect(result).toEqual(
100
+ expect.objectContaining({
101
+ messageId: "om_post",
102
+ chatId: "oc_post",
103
+ contentType: "post",
104
+ content: "Summary\n\npost body",
105
+ }),
106
+ );
107
+ });
108
+
109
+ it("returns text placeholder instead of raw JSON for unsupported message types", async () => {
110
+ mockClientGet.mockResolvedValueOnce({
111
+ code: 0,
112
+ data: {
113
+ items: [
114
+ {
115
+ message_id: "om_file",
116
+ chat_id: "oc_file",
117
+ msg_type: "file",
118
+ body: {
119
+ content: JSON.stringify({ file_key: "file_v3_123" }),
120
+ },
121
+ },
122
+ ],
123
+ },
124
+ });
125
+
126
+ const result = await getMessageFeishu({
127
+ cfg: {} as ClawdbotConfig,
128
+ messageId: "om_file",
129
+ });
130
+
131
+ expect(result).toEqual(
132
+ expect.objectContaining({
133
+ messageId: "om_file",
134
+ chatId: "oc_file",
135
+ contentType: "file",
136
+ content: "[file message]",
137
+ }),
138
+ );
139
+ });
140
+
141
+ it("supports single-object response shape from Feishu API", async () => {
142
+ mockClientGet.mockResolvedValueOnce({
143
+ code: 0,
144
+ data: {
145
+ message_id: "om_single",
146
+ chat_id: "oc_single",
147
+ msg_type: "text",
148
+ body: {
149
+ content: JSON.stringify({ text: "single payload" }),
150
+ },
151
+ },
152
+ });
153
+
154
+ const result = await getMessageFeishu({
155
+ cfg: {} as ClawdbotConfig,
156
+ messageId: "om_single",
157
+ });
158
+
159
+ expect(result).toEqual(
160
+ expect.objectContaining({
161
+ messageId: "om_single",
162
+ chatId: "oc_single",
163
+ contentType: "text",
164
+ content: "single payload",
165
+ }),
166
+ );
167
+ });
168
+ });