@openclaw/feishu 2026.2.25 → 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.
- package/index.ts +2 -0
- package/package.json +2 -1
- package/skills/feishu-doc/SKILL.md +109 -3
- package/src/accounts.test.ts +90 -0
- package/src/accounts.ts +11 -2
- 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 +55 -0
- package/src/bot.test.ts +863 -9
- package/src/bot.ts +414 -200
- package/src/card-action.ts +79 -0
- package/src/channel.ts +6 -0
- 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 +107 -0
- package/src/client.ts +13 -0
- package/src/config-schema.test.ts +82 -1
- package/src/config-schema.ts +54 -3
- package/src/doc-schema.ts +141 -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 +76 -0
- package/src/docx.test.ts +470 -0
- package/src/docx.ts +996 -72
- package/src/drive.ts +38 -33
- package/src/media.test.ts +123 -6
- package/src/media.ts +31 -10
- package/src/monitor.account.ts +286 -0
- package/src/monitor.reaction.test.ts +235 -0
- package/src/monitor.startup.test.ts +187 -0
- package/src/monitor.startup.ts +51 -0
- package/src/monitor.state.ts +76 -0
- package/src/monitor.transport.ts +163 -0
- package/src/monitor.ts +44 -346
- package/src/monitor.webhook-security.test.ts +27 -1
- 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 +253 -0
- package/src/probe.ts +99 -7
- package/src/reply-dispatcher.test.ts +259 -0
- package/src/reply-dispatcher.ts +139 -45
- 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 +26 -1
- package/src/targets.ts +11 -6
- 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 +1 -0
- package/src/typing.test.ts +144 -0
- package/src/typing.ts +140 -10
- package/src/wiki.ts +55 -50
package/src/send.ts
CHANGED
|
@@ -3,21 +3,105 @@ import { resolveFeishuAccount } from "./accounts.js";
|
|
|
3
3
|
import { createFeishuClient } from "./client.js";
|
|
4
4
|
import type { MentionTarget } from "./mention.js";
|
|
5
5
|
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
|
|
6
|
+
import { parsePostContent } from "./post.js";
|
|
6
7
|
import { getFeishuRuntime } from "./runtime.js";
|
|
7
8
|
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
|
|
8
9
|
import { resolveFeishuSendTarget } from "./send-target.js";
|
|
9
10
|
import type { FeishuSendResult } from "./types.js";
|
|
10
11
|
|
|
12
|
+
const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]);
|
|
13
|
+
|
|
14
|
+
function shouldFallbackFromReplyTarget(response: { code?: number; msg?: string }): boolean {
|
|
15
|
+
if (response.code !== undefined && WITHDRAWN_REPLY_ERROR_CODES.has(response.code)) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
const msg = response.msg?.toLowerCase() ?? "";
|
|
19
|
+
return msg.includes("withdrawn") || msg.includes("not found");
|
|
20
|
+
}
|
|
21
|
+
|
|
11
22
|
export type FeishuMessageInfo = {
|
|
12
23
|
messageId: string;
|
|
13
24
|
chatId: string;
|
|
14
25
|
senderId?: string;
|
|
15
26
|
senderOpenId?: string;
|
|
27
|
+
senderType?: string;
|
|
16
28
|
content: string;
|
|
17
29
|
contentType: string;
|
|
18
30
|
createTime?: number;
|
|
19
31
|
};
|
|
20
32
|
|
|
33
|
+
function parseInteractiveCardContent(parsed: unknown): string {
|
|
34
|
+
if (!parsed || typeof parsed !== "object") {
|
|
35
|
+
return "[Interactive Card]";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const candidate = parsed as { elements?: unknown };
|
|
39
|
+
if (!Array.isArray(candidate.elements)) {
|
|
40
|
+
return "[Interactive Card]";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const texts: string[] = [];
|
|
44
|
+
for (const element of candidate.elements) {
|
|
45
|
+
if (!element || typeof element !== "object") {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const item = element as {
|
|
49
|
+
tag?: string;
|
|
50
|
+
content?: string;
|
|
51
|
+
text?: { content?: string };
|
|
52
|
+
};
|
|
53
|
+
if (item.tag === "div" && typeof item.text?.content === "string") {
|
|
54
|
+
texts.push(item.text.content);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (item.tag === "markdown" && typeof item.content === "string") {
|
|
58
|
+
texts.push(item.content);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return texts.join("\n").trim() || "[Interactive Card]";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseQuotedMessageContent(rawContent: string, msgType: string): string {
|
|
65
|
+
if (!rawContent) {
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let parsed: unknown;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(rawContent);
|
|
72
|
+
} catch {
|
|
73
|
+
return rawContent;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (msgType === "text") {
|
|
77
|
+
const text = (parsed as { text?: unknown })?.text;
|
|
78
|
+
return typeof text === "string" ? text : "[Text message]";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (msgType === "post") {
|
|
82
|
+
return parsePostContent(rawContent).textContent;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (msgType === "interactive") {
|
|
86
|
+
return parseInteractiveCardContent(parsed);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (typeof parsed === "string") {
|
|
90
|
+
return parsed;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const genericText = (parsed as { text?: unknown; title?: unknown } | null)?.text;
|
|
94
|
+
if (typeof genericText === "string" && genericText.trim()) {
|
|
95
|
+
return genericText;
|
|
96
|
+
}
|
|
97
|
+
const genericTitle = (parsed as { title?: unknown } | null)?.title;
|
|
98
|
+
if (typeof genericTitle === "string" && genericTitle.trim()) {
|
|
99
|
+
return genericTitle;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return `[${msgType || "unknown"} message]`;
|
|
103
|
+
}
|
|
104
|
+
|
|
21
105
|
/**
|
|
22
106
|
* Get a message by its ID.
|
|
23
107
|
* Useful for fetching quoted/replied message content.
|
|
@@ -54,6 +138,16 @@ export async function getMessageFeishu(params: {
|
|
|
54
138
|
};
|
|
55
139
|
create_time?: string;
|
|
56
140
|
}>;
|
|
141
|
+
message_id?: string;
|
|
142
|
+
chat_id?: string;
|
|
143
|
+
msg_type?: string;
|
|
144
|
+
body?: { content?: string };
|
|
145
|
+
sender?: {
|
|
146
|
+
id?: string;
|
|
147
|
+
id_type?: string;
|
|
148
|
+
sender_type?: string;
|
|
149
|
+
};
|
|
150
|
+
create_time?: string;
|
|
57
151
|
};
|
|
58
152
|
};
|
|
59
153
|
|
|
@@ -61,30 +155,30 @@ export async function getMessageFeishu(params: {
|
|
|
61
155
|
return null;
|
|
62
156
|
}
|
|
63
157
|
|
|
64
|
-
|
|
158
|
+
// Support both list shape (data.items[0]) and single-object shape (data as message)
|
|
159
|
+
const rawItem = response.data?.items?.[0] ?? response.data;
|
|
160
|
+
const item =
|
|
161
|
+
rawItem &&
|
|
162
|
+
(rawItem.body !== undefined || (rawItem as { message_id?: string }).message_id !== undefined)
|
|
163
|
+
? rawItem
|
|
164
|
+
: null;
|
|
65
165
|
if (!item) {
|
|
66
166
|
return null;
|
|
67
167
|
}
|
|
68
168
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const parsed = JSON.parse(content);
|
|
73
|
-
if (item.msg_type === "text" && parsed.text) {
|
|
74
|
-
content = parsed.text;
|
|
75
|
-
}
|
|
76
|
-
} catch {
|
|
77
|
-
// Keep raw content if parsing fails
|
|
78
|
-
}
|
|
169
|
+
const msgType = item.msg_type ?? "text";
|
|
170
|
+
const rawContent = item.body?.content ?? "";
|
|
171
|
+
const content = parseQuotedMessageContent(rawContent, msgType);
|
|
79
172
|
|
|
80
173
|
return {
|
|
81
174
|
messageId: item.message_id ?? messageId,
|
|
82
175
|
chatId: item.chat_id ?? "",
|
|
83
176
|
senderId: item.sender?.id,
|
|
84
177
|
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
|
|
178
|
+
senderType: item.sender?.sender_type,
|
|
85
179
|
content,
|
|
86
|
-
contentType:
|
|
87
|
-
createTime: item.create_time ? parseInt(item.create_time, 10) : undefined,
|
|
180
|
+
contentType: msgType,
|
|
181
|
+
createTime: item.create_time ? parseInt(String(item.create_time), 10) : undefined,
|
|
88
182
|
};
|
|
89
183
|
} catch {
|
|
90
184
|
return null;
|
|
@@ -96,6 +190,8 @@ export type SendFeishuMessageParams = {
|
|
|
96
190
|
to: string;
|
|
97
191
|
text: string;
|
|
98
192
|
replyToMessageId?: string;
|
|
193
|
+
/** When true, reply creates a Feishu topic thread instead of an inline reply */
|
|
194
|
+
replyInThread?: boolean;
|
|
99
195
|
/** Mention target users */
|
|
100
196
|
mentions?: MentionTarget[];
|
|
101
197
|
/** Account ID (optional, uses default if not specified) */
|
|
@@ -127,7 +223,7 @@ function buildFeishuPostMessagePayload(params: { messageText: string }): {
|
|
|
127
223
|
export async function sendMessageFeishu(
|
|
128
224
|
params: SendFeishuMessageParams,
|
|
129
225
|
): Promise<FeishuSendResult> {
|
|
130
|
-
const { cfg, to, text, replyToMessageId, mentions, accountId } = params;
|
|
226
|
+
const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId } = params;
|
|
131
227
|
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
|
|
132
228
|
const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
|
|
133
229
|
cfg,
|
|
@@ -149,8 +245,21 @@ export async function sendMessageFeishu(
|
|
|
149
245
|
data: {
|
|
150
246
|
content,
|
|
151
247
|
msg_type: msgType,
|
|
248
|
+
...(replyInThread ? { reply_in_thread: true } : {}),
|
|
152
249
|
},
|
|
153
250
|
});
|
|
251
|
+
if (shouldFallbackFromReplyTarget(response)) {
|
|
252
|
+
const fallback = await client.im.message.create({
|
|
253
|
+
params: { receive_id_type: receiveIdType },
|
|
254
|
+
data: {
|
|
255
|
+
receive_id: receiveId,
|
|
256
|
+
content,
|
|
257
|
+
msg_type: msgType,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
assertFeishuMessageApiSuccess(fallback, "Feishu send failed");
|
|
261
|
+
return toFeishuSendResult(fallback, receiveId);
|
|
262
|
+
}
|
|
154
263
|
assertFeishuMessageApiSuccess(response, "Feishu reply failed");
|
|
155
264
|
return toFeishuSendResult(response, receiveId);
|
|
156
265
|
}
|
|
@@ -172,11 +281,13 @@ export type SendFeishuCardParams = {
|
|
|
172
281
|
to: string;
|
|
173
282
|
card: Record<string, unknown>;
|
|
174
283
|
replyToMessageId?: string;
|
|
284
|
+
/** When true, reply creates a Feishu topic thread instead of an inline reply */
|
|
285
|
+
replyInThread?: boolean;
|
|
175
286
|
accountId?: string;
|
|
176
287
|
};
|
|
177
288
|
|
|
178
289
|
export async function sendCardFeishu(params: SendFeishuCardParams): Promise<FeishuSendResult> {
|
|
179
|
-
const { cfg, to, card, replyToMessageId, accountId } = params;
|
|
290
|
+
const { cfg, to, card, replyToMessageId, replyInThread, accountId } = params;
|
|
180
291
|
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
|
|
181
292
|
const content = JSON.stringify(card);
|
|
182
293
|
|
|
@@ -186,8 +297,21 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
|
|
|
186
297
|
data: {
|
|
187
298
|
content,
|
|
188
299
|
msg_type: "interactive",
|
|
300
|
+
...(replyInThread ? { reply_in_thread: true } : {}),
|
|
189
301
|
},
|
|
190
302
|
});
|
|
303
|
+
if (shouldFallbackFromReplyTarget(response)) {
|
|
304
|
+
const fallback = await client.im.message.create({
|
|
305
|
+
params: { receive_id_type: receiveIdType },
|
|
306
|
+
data: {
|
|
307
|
+
receive_id: receiveId,
|
|
308
|
+
content,
|
|
309
|
+
msg_type: "interactive",
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
assertFeishuMessageApiSuccess(fallback, "Feishu card send failed");
|
|
313
|
+
return toFeishuSendResult(fallback, receiveId);
|
|
314
|
+
}
|
|
191
315
|
assertFeishuMessageApiSuccess(response, "Feishu card reply failed");
|
|
192
316
|
return toFeishuSendResult(response, receiveId);
|
|
193
317
|
}
|
|
@@ -260,18 +384,19 @@ export async function sendMarkdownCardFeishu(params: {
|
|
|
260
384
|
to: string;
|
|
261
385
|
text: string;
|
|
262
386
|
replyToMessageId?: string;
|
|
387
|
+
/** When true, reply creates a Feishu topic thread instead of an inline reply */
|
|
388
|
+
replyInThread?: boolean;
|
|
263
389
|
/** Mention target users */
|
|
264
390
|
mentions?: MentionTarget[];
|
|
265
391
|
accountId?: string;
|
|
266
392
|
}): Promise<FeishuSendResult> {
|
|
267
|
-
const { cfg, to, text, replyToMessageId, mentions, accountId } = params;
|
|
268
|
-
// Build message content (with @mention support)
|
|
393
|
+
const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId } = params;
|
|
269
394
|
let cardText = text;
|
|
270
395
|
if (mentions && mentions.length > 0) {
|
|
271
396
|
cardText = buildMentionedCardContent(mentions, text);
|
|
272
397
|
}
|
|
273
398
|
const card = buildMarkdownCard(cardText);
|
|
274
|
-
return sendCardFeishu({ cfg, to, card, replyToMessageId, accountId });
|
|
399
|
+
return sendCardFeishu({ cfg, to, card, replyToMessageId, replyInThread, accountId });
|
|
275
400
|
}
|
|
276
401
|
|
|
277
402
|
/**
|
package/src/streaming-card.ts
CHANGED
|
@@ -3,11 +3,19 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { Client } from "@larksuiteoapi/node-sdk";
|
|
6
|
+
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
|
|
6
7
|
import type { FeishuDomain } from "./types.js";
|
|
7
8
|
|
|
8
9
|
type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
|
|
9
10
|
type CardState = { cardId: string; messageId: string; sequence: number; currentText: string };
|
|
10
11
|
|
|
12
|
+
/** Optional header for streaming cards (title bar with color template) */
|
|
13
|
+
export type StreamingCardHeader = {
|
|
14
|
+
title: string;
|
|
15
|
+
/** Color template: blue, green, red, orange, purple, indigo, wathet, turquoise, yellow, grey, carmine, violet, lime */
|
|
16
|
+
template?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
11
19
|
// Token cache (keyed by domain + appId)
|
|
12
20
|
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
|
|
13
21
|
|
|
@@ -21,6 +29,20 @@ function resolveApiBase(domain?: FeishuDomain): string {
|
|
|
21
29
|
return "https://open.feishu.cn/open-apis";
|
|
22
30
|
}
|
|
23
31
|
|
|
32
|
+
function resolveAllowedHostnames(domain?: FeishuDomain): string[] {
|
|
33
|
+
if (domain === "lark") {
|
|
34
|
+
return ["open.larksuite.com"];
|
|
35
|
+
}
|
|
36
|
+
if (domain && domain !== "feishu" && domain.startsWith("http")) {
|
|
37
|
+
try {
|
|
38
|
+
return [new URL(domain).hostname];
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return ["open.feishu.cn"];
|
|
44
|
+
}
|
|
45
|
+
|
|
24
46
|
async function getToken(creds: Credentials): Promise<string> {
|
|
25
47
|
const key = `${creds.domain ?? "feishu"}|${creds.appId}`;
|
|
26
48
|
const cached = tokenCache.get(key);
|
|
@@ -28,17 +50,23 @@ async function getToken(creds: Credentials): Promise<string> {
|
|
|
28
50
|
return cached.token;
|
|
29
51
|
}
|
|
30
52
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
53
|
+
const { response, release } = await fetchWithSsrFGuard({
|
|
54
|
+
url: `${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`,
|
|
55
|
+
init: {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "Content-Type": "application/json" },
|
|
58
|
+
body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }),
|
|
59
|
+
},
|
|
60
|
+
policy: { allowedHostnames: resolveAllowedHostnames(creds.domain) },
|
|
61
|
+
auditContext: "feishu.streaming-card.token",
|
|
35
62
|
});
|
|
36
|
-
const data = (await
|
|
63
|
+
const data = (await response.json()) as {
|
|
37
64
|
code: number;
|
|
38
65
|
msg: string;
|
|
39
66
|
tenant_access_token?: string;
|
|
40
67
|
expire?: number;
|
|
41
68
|
};
|
|
69
|
+
await release();
|
|
42
70
|
if (data.code !== 0 || !data.tenant_access_token) {
|
|
43
71
|
throw new Error(`Token error: ${data.msg}`);
|
|
44
72
|
}
|
|
@@ -78,13 +106,19 @@ export class FeishuStreamingSession {
|
|
|
78
106
|
async start(
|
|
79
107
|
receiveId: string,
|
|
80
108
|
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
|
+
},
|
|
81
115
|
): Promise<void> {
|
|
82
116
|
if (this.state) {
|
|
83
117
|
return;
|
|
84
118
|
}
|
|
85
119
|
|
|
86
120
|
const apiBase = resolveApiBase(this.creds.domain);
|
|
87
|
-
const cardJson = {
|
|
121
|
+
const cardJson: Record<string, unknown> = {
|
|
88
122
|
schema: "2.0",
|
|
89
123
|
config: {
|
|
90
124
|
streaming_mode: true,
|
|
@@ -95,35 +129,71 @@ export class FeishuStreamingSession {
|
|
|
95
129
|
elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }],
|
|
96
130
|
},
|
|
97
131
|
};
|
|
132
|
+
if (options?.header) {
|
|
133
|
+
cardJson.header = {
|
|
134
|
+
title: { tag: "plain_text", content: options.header.title },
|
|
135
|
+
template: options.header.template ?? "blue",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
98
138
|
|
|
99
139
|
// Create card entity
|
|
100
|
-
const createRes = await
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
140
|
+
const { response: createRes, release: releaseCreate } = await fetchWithSsrFGuard({
|
|
141
|
+
url: `${apiBase}/cardkit/v1/cards`,
|
|
142
|
+
init: {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: {
|
|
145
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
146
|
+
"Content-Type": "application/json",
|
|
147
|
+
},
|
|
148
|
+
body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
|
|
105
149
|
},
|
|
106
|
-
|
|
150
|
+
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
|
151
|
+
auditContext: "feishu.streaming-card.create",
|
|
107
152
|
});
|
|
108
153
|
const createData = (await createRes.json()) as {
|
|
109
154
|
code: number;
|
|
110
155
|
msg: string;
|
|
111
156
|
data?: { card_id: string };
|
|
112
157
|
};
|
|
158
|
+
await releaseCreate();
|
|
113
159
|
if (createData.code !== 0 || !createData.data?.card_id) {
|
|
114
160
|
throw new Error(`Create card failed: ${createData.msg}`);
|
|
115
161
|
}
|
|
116
162
|
const cardId = createData.data.card_id;
|
|
163
|
+
const cardContent = JSON.stringify({ type: "card", data: { card_id: cardId } });
|
|
117
164
|
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
165
|
+
// Topic-group replies require root_id routing. Prefer create+root_id when available.
|
|
166
|
+
let sendRes;
|
|
167
|
+
if (options?.rootId) {
|
|
168
|
+
const createData = {
|
|
122
169
|
receive_id: receiveId,
|
|
123
170
|
msg_type: "interactive",
|
|
124
|
-
content:
|
|
125
|
-
|
|
126
|
-
|
|
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) {
|
|
179
|
+
sendRes = await this.client.im.message.reply({
|
|
180
|
+
path: { message_id: options.replyToMessageId },
|
|
181
|
+
data: {
|
|
182
|
+
msg_type: "interactive",
|
|
183
|
+
content: cardContent,
|
|
184
|
+
...(options.replyInThread ? { reply_in_thread: true } : {}),
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
} else {
|
|
188
|
+
sendRes = await this.client.im.message.create({
|
|
189
|
+
params: { receive_id_type: receiveIdType },
|
|
190
|
+
data: {
|
|
191
|
+
receive_id: receiveId,
|
|
192
|
+
msg_type: "interactive",
|
|
193
|
+
content: cardContent,
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|
|
127
197
|
if (sendRes.code !== 0 || !sendRes.data?.message_id) {
|
|
128
198
|
throw new Error(`Send card failed: ${sendRes.msg}`);
|
|
129
199
|
}
|
|
@@ -138,18 +208,27 @@ export class FeishuStreamingSession {
|
|
|
138
208
|
}
|
|
139
209
|
const apiBase = resolveApiBase(this.creds.domain);
|
|
140
210
|
this.state.sequence += 1;
|
|
141
|
-
await
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
211
|
+
await fetchWithSsrFGuard({
|
|
212
|
+
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`,
|
|
213
|
+
init: {
|
|
214
|
+
method: "PUT",
|
|
215
|
+
headers: {
|
|
216
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
217
|
+
"Content-Type": "application/json",
|
|
218
|
+
},
|
|
219
|
+
body: JSON.stringify({
|
|
220
|
+
content: text,
|
|
221
|
+
sequence: this.state.sequence,
|
|
222
|
+
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
|
|
223
|
+
}),
|
|
146
224
|
},
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
225
|
+
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
|
226
|
+
auditContext: "feishu.streaming-card.update",
|
|
227
|
+
})
|
|
228
|
+
.then(async ({ release }) => {
|
|
229
|
+
await release();
|
|
230
|
+
})
|
|
231
|
+
.catch((error) => onError?.(error));
|
|
153
232
|
}
|
|
154
233
|
|
|
155
234
|
async update(text: string): Promise<void> {
|
|
@@ -194,20 +273,29 @@ export class FeishuStreamingSession {
|
|
|
194
273
|
|
|
195
274
|
// Close streaming mode
|
|
196
275
|
this.state.sequence += 1;
|
|
197
|
-
await
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
276
|
+
await fetchWithSsrFGuard({
|
|
277
|
+
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`,
|
|
278
|
+
init: {
|
|
279
|
+
method: "PATCH",
|
|
280
|
+
headers: {
|
|
281
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
282
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
283
|
+
},
|
|
284
|
+
body: JSON.stringify({
|
|
285
|
+
settings: JSON.stringify({
|
|
286
|
+
config: { streaming_mode: false, summary: { content: truncateSummary(text) } },
|
|
287
|
+
}),
|
|
288
|
+
sequence: this.state.sequence,
|
|
289
|
+
uuid: `c_${this.state.cardId}_${this.state.sequence}`,
|
|
206
290
|
}),
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
})
|
|
291
|
+
},
|
|
292
|
+
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
|
293
|
+
auditContext: "feishu.streaming-card.close",
|
|
294
|
+
})
|
|
295
|
+
.then(async ({ release }) => {
|
|
296
|
+
await release();
|
|
297
|
+
})
|
|
298
|
+
.catch((e) => this.log?.(`Close failed: ${String(e)}`));
|
|
211
299
|
|
|
212
300
|
this.log?.(`Closed streaming: cardId=${this.state.cardId}`);
|
|
213
301
|
}
|
package/src/targets.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { resolveReceiveIdType } from "./targets.js";
|
|
2
|
+
import { looksLikeFeishuId, normalizeFeishuTarget, resolveReceiveIdType } from "./targets.js";
|
|
3
3
|
|
|
4
4
|
describe("resolveReceiveIdType", () => {
|
|
5
5
|
it("resolves chat IDs by oc_ prefix", () => {
|
|
@@ -14,3 +14,28 @@ describe("resolveReceiveIdType", () => {
|
|
|
14
14
|
expect(resolveReceiveIdType("u_123")).toBe("user_id");
|
|
15
15
|
});
|
|
16
16
|
});
|
|
17
|
+
|
|
18
|
+
describe("normalizeFeishuTarget", () => {
|
|
19
|
+
it("strips provider and user prefixes", () => {
|
|
20
|
+
expect(normalizeFeishuTarget("feishu:user:ou_123")).toBe("ou_123");
|
|
21
|
+
expect(normalizeFeishuTarget("lark:user:ou_123")).toBe("ou_123");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("strips provider and chat prefixes", () => {
|
|
25
|
+
expect(normalizeFeishuTarget("feishu:chat:oc_123")).toBe("oc_123");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("accepts provider-prefixed raw ids", () => {
|
|
29
|
+
expect(normalizeFeishuTarget("feishu:ou_123")).toBe("ou_123");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("looksLikeFeishuId", () => {
|
|
34
|
+
it("accepts provider-prefixed user targets", () => {
|
|
35
|
+
expect(looksLikeFeishuId("feishu:user:ou_123")).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("accepts provider-prefixed chat targets", () => {
|
|
39
|
+
expect(looksLikeFeishuId("lark:chat:oc_123")).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
package/src/targets.ts
CHANGED
|
@@ -4,6 +4,10 @@ const CHAT_ID_PREFIX = "oc_";
|
|
|
4
4
|
const OPEN_ID_PREFIX = "ou_";
|
|
5
5
|
const USER_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
|
|
6
6
|
|
|
7
|
+
function stripProviderPrefix(raw: string): string {
|
|
8
|
+
return raw.replace(/^(feishu|lark):/i, "").trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
7
11
|
export function detectIdType(id: string): FeishuIdType | null {
|
|
8
12
|
const trimmed = id.trim();
|
|
9
13
|
if (trimmed.startsWith(CHAT_ID_PREFIX)) {
|
|
@@ -24,18 +28,19 @@ export function normalizeFeishuTarget(raw: string): string | null {
|
|
|
24
28
|
return null;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
const
|
|
31
|
+
const withoutProvider = stripProviderPrefix(trimmed);
|
|
32
|
+
const lowered = withoutProvider.toLowerCase();
|
|
28
33
|
if (lowered.startsWith("chat:")) {
|
|
29
|
-
return
|
|
34
|
+
return withoutProvider.slice("chat:".length).trim() || null;
|
|
30
35
|
}
|
|
31
36
|
if (lowered.startsWith("user:")) {
|
|
32
|
-
return
|
|
37
|
+
return withoutProvider.slice("user:".length).trim() || null;
|
|
33
38
|
}
|
|
34
39
|
if (lowered.startsWith("open_id:")) {
|
|
35
|
-
return
|
|
40
|
+
return withoutProvider.slice("open_id:".length).trim() || null;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
|
-
return
|
|
43
|
+
return withoutProvider;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
|
|
@@ -61,7 +66,7 @@ export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_
|
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
export function looksLikeFeishuId(raw: string): boolean {
|
|
64
|
-
const trimmed = raw.trim();
|
|
69
|
+
const trimmed = stripProviderPrefix(raw.trim());
|
|
65
70
|
if (!trimmed) {
|
|
66
71
|
return false;
|
|
67
72
|
}
|