@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.
- package/index.ts +2 -2
- package/package.json +1 -1
- package/src/accounts.test.ts +268 -11
- package/src/accounts.ts +101 -14
- package/src/bitable.ts +40 -28
- package/src/bot.checkBotMentioned.test.ts +9 -1
- package/src/bot.stripBotMention.test.ts +118 -22
- package/src/bot.test.ts +945 -77
- package/src/bot.ts +492 -165
- package/src/card-action.ts +1 -1
- package/src/channel.test.ts +1 -1
- package/src/channel.ts +72 -68
- package/src/chat.test.ts +2 -2
- package/src/chat.ts +1 -1
- package/src/client.test.ts +221 -4
- package/src/client.ts +70 -5
- package/src/config-schema.test.ts +33 -6
- package/src/config-schema.ts +18 -10
- package/src/dedup.ts +47 -1
- package/src/directory.test.ts +40 -0
- package/src/directory.ts +29 -50
- package/src/doc-schema.ts +16 -22
- package/src/docx-batch-insert.test.ts +90 -0
- package/src/docx-batch-insert.ts +8 -11
- package/src/docx.account-selection.test.ts +10 -16
- package/src/docx.test.ts +41 -189
- package/src/docx.ts +1 -1
- package/src/drive.ts +13 -17
- package/src/dynamic-agent.ts +1 -1
- package/src/feishu-command-handler.ts +59 -0
- package/src/media.test.ts +164 -14
- package/src/media.ts +44 -10
- package/src/mention.ts +1 -1
- package/src/monitor.account.ts +284 -25
- package/src/monitor.reaction.test.ts +395 -46
- package/src/monitor.startup.test.ts +25 -8
- package/src/monitor.startup.ts +20 -7
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +88 -9
- package/src/monitor.test-mocks.ts +45 -0
- package/src/monitor.transport.ts +4 -1
- package/src/monitor.ts +4 -4
- package/src/monitor.webhook-security.test.ts +13 -11
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.test.ts +143 -0
- package/src/onboarding.ts +213 -106
- package/src/outbound.test.ts +178 -0
- package/src/outbound.ts +39 -6
- package/src/perm.ts +11 -15
- package/src/policy.test.ts +40 -0
- package/src/policy.ts +9 -10
- package/src/probe.test.ts +54 -36
- package/src/probe.ts +57 -37
- package/src/reactions.ts +1 -1
- package/src/reply-dispatcher.test.ts +216 -0
- package/src/reply-dispatcher.ts +89 -22
- package/src/runtime.ts +1 -1
- package/src/secret-input.ts +13 -0
- package/src/send-message.ts +71 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +7 -3
- package/src/send.reply-fallback.test.ts +74 -0
- package/src/send.test.ts +1 -1
- package/src/send.ts +88 -49
- package/src/streaming-card.test.ts +54 -0
- package/src/streaming-card.ts +96 -28
- package/src/targets.test.ts +29 -0
- package/src/targets.ts +25 -1
- package/src/tool-account-routing.test.ts +3 -3
- package/src/tool-account.ts +1 -1
- package/src/tool-factory-test-harness.ts +1 -1
- package/src/tool-result.test.ts +32 -0
- package/src/tool-result.ts +14 -0
- package/src/types.ts +11 -4
- package/src/typing.ts +1 -1
- 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
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/streaming-card.ts
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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:
|
|
230
|
+
path: { message_id: sendOptions.replyToMessageId! },
|
|
181
231
|
data: {
|
|
182
232
|
msg_type: "interactive",
|
|
183
233
|
content: cardContent,
|
|
184
|
-
...(
|
|
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 =
|
|
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
|
|
252
|
-
|
|
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
|
-
|
|
265
|
-
const text = finalText
|
|
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
|
package/src/targets.test.ts
CHANGED
|
@@ -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
|
},
|
package/src/tool-account.ts
CHANGED
|
@@ -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";
|