@openclaw/feishu 2026.2.25 → 2026.3.2
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/index.ts +2 -0
- package/package.json +2 -1
- package/skills/feishu-doc/SKILL.md +109 -3
- package/src/accounts.test.ts +161 -0
- package/src/accounts.ts +76 -8
- package/src/async.ts +62 -0
- package/src/bitable.ts +189 -215
- package/src/bot.card-action.test.ts +63 -0
- package/src/bot.checkBotMentioned.test.ts +56 -1
- package/src/bot.test.ts +1271 -56
- package/src/bot.ts +499 -215
- package/src/card-action.ts +79 -0
- package/src/channel.ts +26 -4
- package/src/chat-schema.ts +24 -0
- package/src/chat.test.ts +89 -0
- package/src/chat.ts +130 -0
- package/src/client.test.ts +121 -0
- package/src/client.ts +13 -0
- package/src/config-schema.test.ts +101 -1
- package/src/config-schema.ts +66 -11
- package/src/dedup.ts +47 -1
- package/src/doc-schema.ts +135 -0
- package/src/docx-batch-insert.ts +190 -0
- package/src/docx-color-text.ts +149 -0
- package/src/docx-table-ops.ts +298 -0
- package/src/docx.account-selection.test.ts +70 -0
- package/src/docx.test.ts +331 -9
- package/src/docx.ts +996 -72
- package/src/drive.ts +38 -33
- package/src/media.test.ts +227 -7
- package/src/media.ts +52 -11
- package/src/mention.ts +1 -1
- package/src/monitor.account.ts +534 -0
- package/src/monitor.reaction.test.ts +578 -0
- package/src/monitor.startup.test.ts +203 -0
- package/src/monitor.startup.ts +51 -0
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +152 -0
- package/src/monitor.test-mocks.ts +12 -0
- package/src/monitor.transport.ts +163 -0
- package/src/monitor.ts +44 -346
- package/src/monitor.webhook-security.test.ts +53 -10
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.ts +144 -52
- package/src/outbound.test.ts +181 -0
- package/src/outbound.ts +94 -7
- package/src/perm.ts +37 -30
- package/src/policy.test.ts +56 -1
- package/src/policy.ts +5 -1
- package/src/post.test.ts +105 -0
- package/src/post.ts +274 -0
- package/src/probe.test.ts +271 -0
- package/src/probe.ts +131 -19
- package/src/reply-dispatcher.test.ts +300 -0
- package/src/reply-dispatcher.ts +159 -46
- package/src/secret-input.ts +19 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +6 -2
- package/src/send.reply-fallback.test.ts +105 -0
- package/src/send.test.ts +168 -0
- package/src/send.ts +143 -18
- package/src/streaming-card.ts +131 -43
- package/src/targets.test.ts +55 -1
- package/src/targets.ts +32 -7
- package/src/tool-account-routing.test.ts +129 -0
- package/src/tool-account.ts +70 -0
- package/src/tool-factory-test-harness.ts +76 -0
- package/src/tools-config.test.ts +21 -0
- package/src/tools-config.ts +2 -1
- package/src/types.ts +10 -1
- package/src/typing.test.ts +144 -0
- package/src/typing.ts +140 -10
- package/src/wiki.ts +55 -50
|
@@ -4,9 +4,12 @@ const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
|
|
|
4
4
|
const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
|
|
5
5
|
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
|
|
6
6
|
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
|
|
7
|
+
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
|
|
7
8
|
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
|
8
9
|
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
|
|
9
10
|
const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
|
|
11
|
+
const addTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "om_msg" })));
|
|
12
|
+
const removeTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
10
13
|
const streamingInstances = vi.hoisted(() => [] as any[]);
|
|
11
14
|
|
|
12
15
|
vi.mock("./accounts.js", () => ({ resolveFeishuAccount: resolveFeishuAccountMock }));
|
|
@@ -15,8 +18,13 @@ vi.mock("./send.js", () => ({
|
|
|
15
18
|
sendMessageFeishu: sendMessageFeishuMock,
|
|
16
19
|
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
|
|
17
20
|
}));
|
|
21
|
+
vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock }));
|
|
18
22
|
vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
|
|
19
23
|
vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock }));
|
|
24
|
+
vi.mock("./typing.js", () => ({
|
|
25
|
+
addTypingIndicator: addTypingIndicatorMock,
|
|
26
|
+
removeTypingIndicator: removeTypingIndicatorMock,
|
|
27
|
+
}));
|
|
20
28
|
vi.mock("./streaming-card.js", () => ({
|
|
21
29
|
FeishuStreamingSession: class {
|
|
22
30
|
active = false;
|
|
@@ -41,6 +49,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
41
49
|
beforeEach(() => {
|
|
42
50
|
vi.clearAllMocks();
|
|
43
51
|
streamingInstances.length = 0;
|
|
52
|
+
sendMediaFeishuMock.mockResolvedValue(undefined);
|
|
44
53
|
|
|
45
54
|
resolveFeishuAccountMock.mockReturnValue({
|
|
46
55
|
accountId: "main",
|
|
@@ -80,6 +89,86 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
80
89
|
});
|
|
81
90
|
});
|
|
82
91
|
|
|
92
|
+
it("skips typing indicator when account typingIndicator is disabled", async () => {
|
|
93
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
94
|
+
accountId: "main",
|
|
95
|
+
appId: "app_id",
|
|
96
|
+
appSecret: "app_secret",
|
|
97
|
+
domain: "feishu",
|
|
98
|
+
config: {
|
|
99
|
+
renderMode: "auto",
|
|
100
|
+
streaming: true,
|
|
101
|
+
typingIndicator: false,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
createFeishuReplyDispatcher({
|
|
106
|
+
cfg: {} as never,
|
|
107
|
+
agentId: "agent",
|
|
108
|
+
runtime: {} as never,
|
|
109
|
+
chatId: "oc_chat",
|
|
110
|
+
replyToMessageId: "om_parent",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
114
|
+
await options.onReplyStart?.();
|
|
115
|
+
|
|
116
|
+
expect(addTypingIndicatorMock).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("skips typing indicator for stale replayed messages", async () => {
|
|
120
|
+
createFeishuReplyDispatcher({
|
|
121
|
+
cfg: {} as never,
|
|
122
|
+
agentId: "agent",
|
|
123
|
+
runtime: {} as never,
|
|
124
|
+
chatId: "oc_chat",
|
|
125
|
+
replyToMessageId: "om_parent",
|
|
126
|
+
messageCreateTimeMs: Date.now() - 3 * 60_000,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
130
|
+
await options.onReplyStart?.();
|
|
131
|
+
|
|
132
|
+
expect(addTypingIndicatorMock).not.toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("treats second-based timestamps as stale for typing suppression", async () => {
|
|
136
|
+
createFeishuReplyDispatcher({
|
|
137
|
+
cfg: {} as never,
|
|
138
|
+
agentId: "agent",
|
|
139
|
+
runtime: {} as never,
|
|
140
|
+
chatId: "oc_chat",
|
|
141
|
+
replyToMessageId: "om_parent",
|
|
142
|
+
messageCreateTimeMs: Math.floor((Date.now() - 3 * 60_000) / 1000),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
146
|
+
await options.onReplyStart?.();
|
|
147
|
+
|
|
148
|
+
expect(addTypingIndicatorMock).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("keeps typing indicator for fresh messages", async () => {
|
|
152
|
+
createFeishuReplyDispatcher({
|
|
153
|
+
cfg: {} as never,
|
|
154
|
+
agentId: "agent",
|
|
155
|
+
runtime: {} as never,
|
|
156
|
+
chatId: "oc_chat",
|
|
157
|
+
replyToMessageId: "om_parent",
|
|
158
|
+
messageCreateTimeMs: Date.now() - 30_000,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
162
|
+
await options.onReplyStart?.();
|
|
163
|
+
|
|
164
|
+
expect(addTypingIndicatorMock).toHaveBeenCalledTimes(1);
|
|
165
|
+
expect(addTypingIndicatorMock).toHaveBeenCalledWith(
|
|
166
|
+
expect.objectContaining({
|
|
167
|
+
messageId: "om_parent",
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
83
172
|
it("keeps auto mode plain text on non-streaming send path", async () => {
|
|
84
173
|
createFeishuReplyDispatcher({
|
|
85
174
|
cfg: {} as never,
|
|
@@ -96,12 +185,30 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
96
185
|
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
97
186
|
});
|
|
98
187
|
|
|
188
|
+
it("suppresses internal block payload delivery", async () => {
|
|
189
|
+
createFeishuReplyDispatcher({
|
|
190
|
+
cfg: {} as never,
|
|
191
|
+
agentId: "agent",
|
|
192
|
+
runtime: {} as never,
|
|
193
|
+
chatId: "oc_chat",
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
197
|
+
await options.deliver({ text: "internal reasoning chunk" }, { kind: "block" });
|
|
198
|
+
|
|
199
|
+
expect(streamingInstances).toHaveLength(0);
|
|
200
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
201
|
+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
202
|
+
expect(sendMediaFeishuMock).not.toHaveBeenCalled();
|
|
203
|
+
});
|
|
204
|
+
|
|
99
205
|
it("uses streaming session for auto mode markdown payloads", async () => {
|
|
100
206
|
createFeishuReplyDispatcher({
|
|
101
207
|
cfg: {} as never,
|
|
102
208
|
agentId: "agent",
|
|
103
209
|
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
104
210
|
chatId: "oc_chat",
|
|
211
|
+
rootId: "om_root_topic",
|
|
105
212
|
});
|
|
106
213
|
|
|
107
214
|
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
@@ -109,8 +216,201 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
109
216
|
|
|
110
217
|
expect(streamingInstances).toHaveLength(1);
|
|
111
218
|
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
|
|
219
|
+
expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
|
|
220
|
+
replyToMessageId: undefined,
|
|
221
|
+
replyInThread: undefined,
|
|
222
|
+
rootId: "om_root_topic",
|
|
223
|
+
});
|
|
112
224
|
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
113
225
|
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
114
226
|
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
115
227
|
});
|
|
228
|
+
|
|
229
|
+
it("sends media-only payloads as attachments", async () => {
|
|
230
|
+
createFeishuReplyDispatcher({
|
|
231
|
+
cfg: {} as never,
|
|
232
|
+
agentId: "agent",
|
|
233
|
+
runtime: {} as never,
|
|
234
|
+
chatId: "oc_chat",
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
238
|
+
await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
|
|
239
|
+
|
|
240
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
|
|
241
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
242
|
+
expect.objectContaining({
|
|
243
|
+
to: "oc_chat",
|
|
244
|
+
mediaUrl: "https://example.com/a.png",
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
248
|
+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => {
|
|
252
|
+
createFeishuReplyDispatcher({
|
|
253
|
+
cfg: {} as never,
|
|
254
|
+
agentId: "agent",
|
|
255
|
+
runtime: {} as never,
|
|
256
|
+
chatId: "oc_chat",
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
260
|
+
await options.deliver(
|
|
261
|
+
{ text: "caption", mediaUrl: "https://example.com/a.png", mediaUrls: [] },
|
|
262
|
+
{ kind: "final" },
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
|
|
266
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
|
|
267
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
268
|
+
expect.objectContaining({
|
|
269
|
+
mediaUrl: "https://example.com/a.png",
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("sends attachments after streaming final markdown replies", async () => {
|
|
275
|
+
createFeishuReplyDispatcher({
|
|
276
|
+
cfg: {} as never,
|
|
277
|
+
agentId: "agent",
|
|
278
|
+
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
279
|
+
chatId: "oc_chat",
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
283
|
+
await options.deliver(
|
|
284
|
+
{ text: "```ts\nconst x = 1\n```", mediaUrls: ["https://example.com/a.png"] },
|
|
285
|
+
{ kind: "final" },
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
expect(streamingInstances).toHaveLength(1);
|
|
289
|
+
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
|
|
290
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
291
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
|
|
292
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
293
|
+
expect.objectContaining({
|
|
294
|
+
mediaUrl: "https://example.com/a.png",
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("passes replyInThread to sendMessageFeishu for plain text", async () => {
|
|
300
|
+
createFeishuReplyDispatcher({
|
|
301
|
+
cfg: {} as never,
|
|
302
|
+
agentId: "agent",
|
|
303
|
+
runtime: {} as never,
|
|
304
|
+
chatId: "oc_chat",
|
|
305
|
+
replyToMessageId: "om_msg",
|
|
306
|
+
replyInThread: true,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
310
|
+
await options.deliver({ text: "plain text" }, { kind: "final" });
|
|
311
|
+
|
|
312
|
+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
|
|
313
|
+
expect.objectContaining({
|
|
314
|
+
replyToMessageId: "om_msg",
|
|
315
|
+
replyInThread: true,
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("passes replyInThread to sendMarkdownCardFeishu for card text", async () => {
|
|
321
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
322
|
+
accountId: "main",
|
|
323
|
+
appId: "app_id",
|
|
324
|
+
appSecret: "app_secret",
|
|
325
|
+
domain: "feishu",
|
|
326
|
+
config: {
|
|
327
|
+
renderMode: "card",
|
|
328
|
+
streaming: false,
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
createFeishuReplyDispatcher({
|
|
333
|
+
cfg: {} as never,
|
|
334
|
+
agentId: "agent",
|
|
335
|
+
runtime: {} as never,
|
|
336
|
+
chatId: "oc_chat",
|
|
337
|
+
replyToMessageId: "om_msg",
|
|
338
|
+
replyInThread: true,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
342
|
+
await options.deliver({ text: "card text" }, { kind: "final" });
|
|
343
|
+
|
|
344
|
+
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
|
345
|
+
expect.objectContaining({
|
|
346
|
+
replyToMessageId: "om_msg",
|
|
347
|
+
replyInThread: true,
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("passes replyToMessageId and replyInThread to streaming.start()", async () => {
|
|
353
|
+
createFeishuReplyDispatcher({
|
|
354
|
+
cfg: {} as never,
|
|
355
|
+
agentId: "agent",
|
|
356
|
+
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
357
|
+
chatId: "oc_chat",
|
|
358
|
+
replyToMessageId: "om_msg",
|
|
359
|
+
replyInThread: true,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
363
|
+
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
|
364
|
+
|
|
365
|
+
expect(streamingInstances).toHaveLength(1);
|
|
366
|
+
expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
|
|
367
|
+
replyToMessageId: "om_msg",
|
|
368
|
+
replyInThread: true,
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("disables streaming for thread replies and keeps reply metadata", async () => {
|
|
373
|
+
createFeishuReplyDispatcher({
|
|
374
|
+
cfg: {} as never,
|
|
375
|
+
agentId: "agent",
|
|
376
|
+
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
377
|
+
chatId: "oc_chat",
|
|
378
|
+
replyToMessageId: "om_msg",
|
|
379
|
+
replyInThread: false,
|
|
380
|
+
threadReply: true,
|
|
381
|
+
rootId: "om_root_topic",
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
385
|
+
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
|
386
|
+
|
|
387
|
+
expect(streamingInstances).toHaveLength(0);
|
|
388
|
+
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
|
389
|
+
expect.objectContaining({
|
|
390
|
+
replyToMessageId: "om_msg",
|
|
391
|
+
replyInThread: true,
|
|
392
|
+
}),
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("passes replyInThread to media attachments", async () => {
|
|
397
|
+
createFeishuReplyDispatcher({
|
|
398
|
+
cfg: {} as never,
|
|
399
|
+
agentId: "agent",
|
|
400
|
+
runtime: {} as never,
|
|
401
|
+
chatId: "oc_chat",
|
|
402
|
+
replyToMessageId: "om_msg",
|
|
403
|
+
replyInThread: true,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
407
|
+
await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
|
|
408
|
+
|
|
409
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
410
|
+
expect.objectContaining({
|
|
411
|
+
replyToMessageId: "om_msg",
|
|
412
|
+
replyInThread: true,
|
|
413
|
+
}),
|
|
414
|
+
);
|
|
415
|
+
});
|
|
116
416
|
});
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -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,96 @@ 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
|
+
/** True when inbound message is already inside a thread/topic context */
|
|
49
|
+
threadReply?: boolean;
|
|
50
|
+
rootId?: string;
|
|
30
51
|
mentionTargets?: MentionTarget[];
|
|
31
52
|
accountId?: string;
|
|
53
|
+
/** Epoch ms when the inbound message was created. Used to suppress typing
|
|
54
|
+
* indicators on old/replayed messages after context compaction (#30418). */
|
|
55
|
+
messageCreateTimeMs?: number;
|
|
32
56
|
};
|
|
33
57
|
|
|
34
58
|
export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
|
|
35
59
|
const core = getFeishuRuntime();
|
|
36
|
-
const {
|
|
60
|
+
const {
|
|
61
|
+
cfg,
|
|
62
|
+
agentId,
|
|
63
|
+
chatId,
|
|
64
|
+
replyToMessageId,
|
|
65
|
+
skipReplyToInMessages,
|
|
66
|
+
replyInThread,
|
|
67
|
+
threadReply,
|
|
68
|
+
rootId,
|
|
69
|
+
mentionTargets,
|
|
70
|
+
accountId,
|
|
71
|
+
} = params;
|
|
72
|
+
const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
|
|
73
|
+
const threadReplyMode = threadReply === true;
|
|
74
|
+
const effectiveReplyInThread = threadReplyMode ? true : replyInThread;
|
|
37
75
|
const account = resolveFeishuAccount({ cfg, accountId });
|
|
38
76
|
const prefixContext = createReplyPrefixContext({ cfg, agentId });
|
|
39
77
|
|
|
40
78
|
let typingState: TypingIndicatorState | null = null;
|
|
41
79
|
const typingCallbacks = createTypingCallbacks({
|
|
42
80
|
start: async () => {
|
|
81
|
+
// Check if typing indicator is enabled (default: true)
|
|
82
|
+
if (!(account.config.typingIndicator ?? true)) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
43
85
|
if (!replyToMessageId) {
|
|
44
86
|
return;
|
|
45
87
|
}
|
|
46
|
-
|
|
88
|
+
// Skip typing indicator for old messages — likely replays after context
|
|
89
|
+
// compaction that would flood users with stale notifications (#30418).
|
|
90
|
+
const messageCreateTimeMs = normalizeEpochMs(params.messageCreateTimeMs);
|
|
91
|
+
if (
|
|
92
|
+
messageCreateTimeMs !== undefined &&
|
|
93
|
+
Date.now() - messageCreateTimeMs > TYPING_INDICATOR_MAX_AGE_MS
|
|
94
|
+
) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// Feishu reactions persist until explicitly removed, so skip keepalive
|
|
98
|
+
// re-adds when a reaction already exists. Re-adding the same emoji
|
|
99
|
+
// triggers a new push notification for every call (#28660).
|
|
100
|
+
if (typingState?.reactionId) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
typingState = await addTypingIndicator({
|
|
104
|
+
cfg,
|
|
105
|
+
messageId: replyToMessageId,
|
|
106
|
+
accountId,
|
|
107
|
+
runtime: params.runtime,
|
|
108
|
+
});
|
|
47
109
|
},
|
|
48
110
|
stop: async () => {
|
|
49
111
|
if (!typingState) {
|
|
50
112
|
return;
|
|
51
113
|
}
|
|
52
|
-
await removeTypingIndicator({ cfg, state: typingState, accountId });
|
|
114
|
+
await removeTypingIndicator({ cfg, state: typingState, accountId, runtime: params.runtime });
|
|
53
115
|
typingState = null;
|
|
54
116
|
},
|
|
55
117
|
onStartError: (err) =>
|
|
@@ -74,7 +136,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
74
136
|
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
|
|
75
137
|
const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu" });
|
|
76
138
|
const renderMode = account.config?.renderMode ?? "auto";
|
|
77
|
-
|
|
139
|
+
// Card streaming may miss thread affinity in topic contexts; use direct replies there.
|
|
140
|
+
const streamingEnabled =
|
|
141
|
+
!threadReplyMode && account.config?.streaming !== false && renderMode !== "raw";
|
|
78
142
|
|
|
79
143
|
let streaming: FeishuStreamingSession | null = null;
|
|
80
144
|
let streamText = "";
|
|
@@ -99,7 +163,11 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
99
163
|
params.runtime.log?.(`feishu[${account.accountId}] ${message}`),
|
|
100
164
|
);
|
|
101
165
|
try {
|
|
102
|
-
await streaming.start(chatId, resolveReceiveIdType(chatId)
|
|
166
|
+
await streaming.start(chatId, resolveReceiveIdType(chatId), {
|
|
167
|
+
replyToMessageId,
|
|
168
|
+
replyInThread: effectiveReplyInThread,
|
|
169
|
+
rootId,
|
|
170
|
+
});
|
|
103
171
|
} catch (error) {
|
|
104
172
|
params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`);
|
|
105
173
|
streaming = null;
|
|
@@ -137,61 +205,106 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
137
205
|
void typingCallbacks.onReplyStart?.();
|
|
138
206
|
},
|
|
139
207
|
deliver: async (payload: ReplyPayload, info) => {
|
|
208
|
+
// FIX: Filter out internal 'block' reasoning chunks immediately to prevent
|
|
209
|
+
// data leak and race conditions with streaming state initialization.
|
|
210
|
+
if (info?.kind === "block") {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
140
214
|
const text = payload.text ?? "";
|
|
141
|
-
|
|
215
|
+
const mediaList =
|
|
216
|
+
payload.mediaUrls && payload.mediaUrls.length > 0
|
|
217
|
+
? payload.mediaUrls
|
|
218
|
+
: payload.mediaUrl
|
|
219
|
+
? [payload.mediaUrl]
|
|
220
|
+
: [];
|
|
221
|
+
const hasText = Boolean(text.trim());
|
|
222
|
+
const hasMedia = mediaList.length > 0;
|
|
223
|
+
|
|
224
|
+
if (!hasText && !hasMedia) {
|
|
142
225
|
return;
|
|
143
226
|
}
|
|
144
227
|
|
|
145
|
-
|
|
228
|
+
if (hasText) {
|
|
229
|
+
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
|
146
230
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
231
|
+
if (info?.kind === "final" && streamingEnabled && useCard) {
|
|
232
|
+
startStreaming();
|
|
233
|
+
if (streamingStartPromise) {
|
|
234
|
+
await streamingStartPromise;
|
|
235
|
+
}
|
|
151
236
|
}
|
|
152
|
-
}
|
|
153
237
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
238
|
+
if (streaming?.isActive()) {
|
|
239
|
+
if (info?.kind === "final") {
|
|
240
|
+
streamText = text;
|
|
241
|
+
await closeStreaming();
|
|
242
|
+
}
|
|
243
|
+
// Send media even when streaming handled the text
|
|
244
|
+
if (hasMedia) {
|
|
245
|
+
for (const mediaUrl of mediaList) {
|
|
246
|
+
await sendMediaFeishu({
|
|
247
|
+
cfg,
|
|
248
|
+
to: chatId,
|
|
249
|
+
mediaUrl,
|
|
250
|
+
replyToMessageId: sendReplyToMessageId,
|
|
251
|
+
replyInThread: effectiveReplyInThread,
|
|
252
|
+
accountId,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
158
257
|
}
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
258
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
259
|
+
let first = true;
|
|
260
|
+
if (useCard) {
|
|
261
|
+
for (const chunk of core.channel.text.chunkTextWithMode(
|
|
262
|
+
text,
|
|
263
|
+
textChunkLimit,
|
|
264
|
+
chunkMode,
|
|
265
|
+
)) {
|
|
266
|
+
await sendMarkdownCardFeishu({
|
|
267
|
+
cfg,
|
|
268
|
+
to: chatId,
|
|
269
|
+
text: chunk,
|
|
270
|
+
replyToMessageId: sendReplyToMessageId,
|
|
271
|
+
replyInThread: effectiveReplyInThread,
|
|
272
|
+
mentions: first ? mentionTargets : undefined,
|
|
273
|
+
accountId,
|
|
274
|
+
});
|
|
275
|
+
first = false;
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
|
|
279
|
+
for (const chunk of core.channel.text.chunkTextWithMode(
|
|
280
|
+
converted,
|
|
281
|
+
textChunkLimit,
|
|
282
|
+
chunkMode,
|
|
283
|
+
)) {
|
|
284
|
+
await sendMessageFeishu({
|
|
285
|
+
cfg,
|
|
286
|
+
to: chatId,
|
|
287
|
+
text: chunk,
|
|
288
|
+
replyToMessageId: sendReplyToMessageId,
|
|
289
|
+
replyInThread: effectiveReplyInThread,
|
|
290
|
+
mentions: first ? mentionTargets : undefined,
|
|
291
|
+
accountId,
|
|
292
|
+
});
|
|
293
|
+
first = false;
|
|
294
|
+
}
|
|
178
295
|
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
chunkMode,
|
|
185
|
-
)) {
|
|
186
|
-
await sendMessageFeishu({
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (hasMedia) {
|
|
299
|
+
for (const mediaUrl of mediaList) {
|
|
300
|
+
await sendMediaFeishu({
|
|
187
301
|
cfg,
|
|
188
302
|
to: chatId,
|
|
189
|
-
|
|
190
|
-
replyToMessageId,
|
|
191
|
-
|
|
303
|
+
mediaUrl,
|
|
304
|
+
replyToMessageId: sendReplyToMessageId,
|
|
305
|
+
replyInThread: effectiveReplyInThread,
|
|
192
306
|
accountId,
|
|
193
307
|
});
|
|
194
|
-
first = false;
|
|
195
308
|
}
|
|
196
309
|
}
|
|
197
310
|
},
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hasConfiguredSecretInput,
|
|
3
|
+
normalizeResolvedSecretInputString,
|
|
4
|
+
normalizeSecretInputString,
|
|
5
|
+
} from "openclaw/plugin-sdk";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString };
|
|
9
|
+
|
|
10
|
+
export function buildSecretInputSchema() {
|
|
11
|
+
return z.union([
|
|
12
|
+
z.string(),
|
|
13
|
+
z.object({
|
|
14
|
+
source: z.enum(["env", "file", "exec"]),
|
|
15
|
+
provider: z.string().min(1),
|
|
16
|
+
id: z.string().min(1),
|
|
17
|
+
}),
|
|
18
|
+
]);
|
|
19
|
+
}
|