@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.
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
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
- const item = response.data?.items?.[0];
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
- // Parse content based on message type
70
- let content = item.body?.content ?? "";
71
- try {
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: item.msg_type ?? "text",
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
  /**
@@ -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 res = await fetch(`${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`, {
32
- method: "POST",
33
- headers: { "Content-Type": "application/json" },
34
- body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }),
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 res.json()) as {
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 fetch(`${apiBase}/cardkit/v1/cards`, {
101
- method: "POST",
102
- headers: {
103
- Authorization: `Bearer ${await getToken(this.creds)}`,
104
- "Content-Type": "application/json",
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
- body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
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
- // Send card message
119
- const sendRes = await this.client.im.message.create({
120
- params: { receive_id_type: receiveIdType },
121
- data: {
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: JSON.stringify({ type: "card", data: { card_id: cardId } }),
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 fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
142
- method: "PUT",
143
- headers: {
144
- Authorization: `Bearer ${await getToken(this.creds)}`,
145
- "Content-Type": "application/json",
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
- body: JSON.stringify({
148
- content: text,
149
- sequence: this.state.sequence,
150
- uuid: `s_${this.state.cardId}_${this.state.sequence}`,
151
- }),
152
- }).catch((error) => onError?.(error));
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 fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`, {
198
- method: "PATCH",
199
- headers: {
200
- Authorization: `Bearer ${await getToken(this.creds)}`,
201
- "Content-Type": "application/json; charset=utf-8",
202
- },
203
- body: JSON.stringify({
204
- settings: JSON.stringify({
205
- config: { streaming_mode: false, summary: { content: truncateSummary(text) } },
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
- sequence: this.state.sequence,
208
- uuid: `c_${this.state.cardId}_${this.state.sequence}`,
209
- }),
210
- }).catch((e) => this.log?.(`Close failed: ${String(e)}`));
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
  }
@@ -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 lowered = trimmed.toLowerCase();
31
+ const withoutProvider = stripProviderPrefix(trimmed);
32
+ const lowered = withoutProvider.toLowerCase();
28
33
  if (lowered.startsWith("chat:")) {
29
- return trimmed.slice("chat:".length).trim() || null;
34
+ return withoutProvider.slice("chat:".length).trim() || null;
30
35
  }
31
36
  if (lowered.startsWith("user:")) {
32
- return trimmed.slice("user:".length).trim() || null;
37
+ return withoutProvider.slice("user:".length).trim() || null;
33
38
  }
34
39
  if (lowered.startsWith("open_id:")) {
35
- return trimmed.slice("open_id:".length).trim() || null;
40
+ return withoutProvider.slice("open_id:".length).trim() || null;
36
41
  }
37
42
 
38
- return trimmed;
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
  }