@gakr-gakr/feishu 0.1.0

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 (133) hide show
  1. package/api.ts +32 -0
  2. package/autobot.plugin.json +180 -0
  3. package/channel-entry.ts +20 -0
  4. package/channel-plugin-api.ts +1 -0
  5. package/contract-api.ts +16 -0
  6. package/index.ts +82 -0
  7. package/package.json +62 -0
  8. package/runtime-api.ts +55 -0
  9. package/secret-contract-api.ts +5 -0
  10. package/security-contract-api.ts +1 -0
  11. package/session-key-api.ts +1 -0
  12. package/setup-api.ts +3 -0
  13. package/setup-entry.ts +13 -0
  14. package/skills/feishu-doc/SKILL.md +211 -0
  15. package/skills/feishu-doc/references/block-types.md +103 -0
  16. package/skills/feishu-drive/SKILL.md +97 -0
  17. package/skills/feishu-perm/SKILL.md +119 -0
  18. package/skills/feishu-wiki/SKILL.md +113 -0
  19. package/src/accounts.ts +333 -0
  20. package/src/agent-config.ts +21 -0
  21. package/src/app-registration.ts +331 -0
  22. package/src/approval-auth.ts +25 -0
  23. package/src/async.ts +104 -0
  24. package/src/audio-preflight.runtime.ts +9 -0
  25. package/src/bitable.ts +762 -0
  26. package/src/bot-content.ts +485 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.ts +1703 -0
  30. package/src/card-action.ts +447 -0
  31. package/src/card-interaction.ts +159 -0
  32. package/src/card-test-helpers.ts +54 -0
  33. package/src/card-ux-approval.ts +65 -0
  34. package/src/card-ux-launcher.ts +121 -0
  35. package/src/card-ux-shared.ts +33 -0
  36. package/src/channel-runtime-api.ts +16 -0
  37. package/src/channel.runtime.ts +47 -0
  38. package/src/channel.ts +1423 -0
  39. package/src/chat-schema.ts +25 -0
  40. package/src/chat.ts +188 -0
  41. package/src/client-timeout.ts +42 -0
  42. package/src/client.ts +262 -0
  43. package/src/comment-dispatcher-runtime-api.ts +6 -0
  44. package/src/comment-dispatcher.ts +107 -0
  45. package/src/comment-handler-runtime-api.ts +3 -0
  46. package/src/comment-handler.ts +303 -0
  47. package/src/comment-reaction.ts +259 -0
  48. package/src/comment-shared.ts +406 -0
  49. package/src/comment-target.ts +44 -0
  50. package/src/config-schema.ts +335 -0
  51. package/src/conversation-id.ts +199 -0
  52. package/src/dedup-runtime-api.ts +1 -0
  53. package/src/dedup.ts +141 -0
  54. package/src/dedupe-key.ts +72 -0
  55. package/src/directory.static.ts +61 -0
  56. package/src/directory.ts +124 -0
  57. package/src/doc-schema.ts +182 -0
  58. package/src/docx-batch-insert.ts +223 -0
  59. package/src/docx-color-text.ts +154 -0
  60. package/src/docx-table-ops.ts +316 -0
  61. package/src/docx-types.ts +38 -0
  62. package/src/docx.ts +1596 -0
  63. package/src/drive-schema.ts +92 -0
  64. package/src/drive.ts +829 -0
  65. package/src/dynamic-agent.ts +143 -0
  66. package/src/event-types.ts +45 -0
  67. package/src/external-keys.ts +19 -0
  68. package/src/lifecycle.test-support.ts +220 -0
  69. package/src/media.ts +1105 -0
  70. package/src/mention-target.types.ts +5 -0
  71. package/src/mention.ts +114 -0
  72. package/src/message-action-contract.ts +13 -0
  73. package/src/monitor-state-runtime-api.ts +7 -0
  74. package/src/monitor-transport-runtime-api.ts +10 -0
  75. package/src/monitor.account.ts +492 -0
  76. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  77. package/src/monitor.bot-identity.ts +86 -0
  78. package/src/monitor.bot-menu-handler.ts +165 -0
  79. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  80. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  81. package/src/monitor.card-action.lifecycle.test-support.ts +421 -0
  82. package/src/monitor.comment-notice-handler.ts +105 -0
  83. package/src/monitor.comment.ts +1386 -0
  84. package/src/monitor.message-handler.ts +350 -0
  85. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  86. package/src/monitor.startup.ts +74 -0
  87. package/src/monitor.state.ts +170 -0
  88. package/src/monitor.synthetic-error.ts +18 -0
  89. package/src/monitor.test-mocks.ts +46 -0
  90. package/src/monitor.transport.ts +451 -0
  91. package/src/monitor.ts +100 -0
  92. package/src/outbound-runtime-api.ts +1 -0
  93. package/src/outbound.ts +785 -0
  94. package/src/perm-schema.ts +52 -0
  95. package/src/perm.ts +170 -0
  96. package/src/pins.ts +108 -0
  97. package/src/policy.ts +321 -0
  98. package/src/post.ts +275 -0
  99. package/src/probe.ts +166 -0
  100. package/src/processing-claims.ts +59 -0
  101. package/src/qr-terminal.ts +1 -0
  102. package/src/reactions.ts +123 -0
  103. package/src/reasoning-preview.ts +28 -0
  104. package/src/reply-dispatcher-runtime-api.ts +7 -0
  105. package/src/reply-dispatcher.ts +748 -0
  106. package/src/runtime.ts +9 -0
  107. package/src/secret-contract.ts +145 -0
  108. package/src/secret-input.ts +1 -0
  109. package/src/security-audit-shared.ts +69 -0
  110. package/src/security-audit.ts +1 -0
  111. package/src/send-result.ts +80 -0
  112. package/src/send-target.ts +35 -0
  113. package/src/send.ts +861 -0
  114. package/src/sequential-key.ts +28 -0
  115. package/src/sequential-queue.ts +86 -0
  116. package/src/session-conversation.ts +42 -0
  117. package/src/session-route.ts +48 -0
  118. package/src/setup-core.ts +51 -0
  119. package/src/setup-surface.ts +618 -0
  120. package/src/streaming-card.ts +571 -0
  121. package/src/subagent-hooks.ts +413 -0
  122. package/src/targets.ts +97 -0
  123. package/src/thread-bindings.ts +331 -0
  124. package/src/tool-account.ts +93 -0
  125. package/src/tool-factory-test-harness.ts +79 -0
  126. package/src/tool-result.ts +16 -0
  127. package/src/tools-config.ts +22 -0
  128. package/src/types.ts +106 -0
  129. package/src/typing.ts +214 -0
  130. package/src/wiki-schema.ts +69 -0
  131. package/src/wiki.ts +270 -0
  132. package/subagent-hooks-api.ts +31 -0
  133. package/tsconfig.json +16 -0
@@ -0,0 +1,447 @@
1
+ import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
2
+ import { resolveFeishuRuntimeAccount } from "./accounts.js";
3
+ import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
4
+ import { decodeFeishuCardAction, buildFeishuCardActionTextFallback } from "./card-interaction.js";
5
+ import {
6
+ createApprovalCard,
7
+ FEISHU_APPROVAL_CANCEL_ACTION,
8
+ FEISHU_APPROVAL_CONFIRM_ACTION,
9
+ FEISHU_APPROVAL_REQUEST_ACTION,
10
+ } from "./card-ux-approval.js";
11
+ import { createFeishuClient } from "./client.js";
12
+ import { sendCardFeishu, sendMessageFeishu } from "./send.js";
13
+
14
+ export type FeishuCardActionEvent = {
15
+ operator: {
16
+ open_id: string;
17
+ user_id?: string;
18
+ union_id?: string;
19
+ };
20
+ token: string;
21
+ action: {
22
+ value: Record<string, unknown>;
23
+ tag: string;
24
+ };
25
+ open_message_id?: string;
26
+ context: {
27
+ open_message_id?: string;
28
+ open_id?: string;
29
+ user_id?: string;
30
+ chat_id?: string;
31
+ };
32
+ };
33
+
34
+ const FEISHU_APPROVAL_CARD_TTL_MS = 5 * 60_000;
35
+ const FEISHU_CARD_ACTION_TOKEN_TTL_MS = 15 * 60_000;
36
+ const processedCardActionTokens = new Map<
37
+ string,
38
+ { status: "inflight" | "completed"; expiresAt: number }
39
+ >();
40
+
41
+ export class FeishuRetryableCardActionError extends Error {
42
+ constructor(message: string, options?: ErrorOptions) {
43
+ super(message, options);
44
+ this.name = "FeishuRetryableCardActionError";
45
+ }
46
+ }
47
+
48
+ export function resetProcessedFeishuCardActionTokensForTests(): void {
49
+ processedCardActionTokens.clear();
50
+ }
51
+
52
+ function pruneProcessedCardActionTokens(now: number): void {
53
+ for (const [key, entry] of processedCardActionTokens.entries()) {
54
+ if (entry.expiresAt <= now) {
55
+ processedCardActionTokens.delete(key);
56
+ }
57
+ }
58
+ }
59
+
60
+ function beginFeishuCardActionToken(params: {
61
+ token: string;
62
+ accountId: string;
63
+ now?: number;
64
+ }): boolean {
65
+ const now = params.now ?? Date.now();
66
+ pruneProcessedCardActionTokens(now);
67
+ const normalizedToken = params.token.trim();
68
+ if (!normalizedToken) {
69
+ return false;
70
+ }
71
+ const key = `${params.accountId}:${normalizedToken}`;
72
+ const existing = processedCardActionTokens.get(key);
73
+ if (existing && existing.expiresAt > now) {
74
+ return false;
75
+ }
76
+ processedCardActionTokens.set(key, {
77
+ status: "inflight",
78
+ expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS,
79
+ });
80
+ return true;
81
+ }
82
+
83
+ function completeFeishuCardActionToken(params: {
84
+ token: string;
85
+ accountId: string;
86
+ now?: number;
87
+ }): void {
88
+ const now = params.now ?? Date.now();
89
+ const normalizedToken = params.token.trim();
90
+ if (!normalizedToken) {
91
+ return;
92
+ }
93
+ processedCardActionTokens.set(`${params.accountId}:${normalizedToken}`, {
94
+ status: "completed",
95
+ expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS,
96
+ });
97
+ }
98
+
99
+ function releaseFeishuCardActionToken(params: { token: string; accountId: string }): void {
100
+ const normalizedToken = params.token.trim();
101
+ if (!normalizedToken) {
102
+ return;
103
+ }
104
+ processedCardActionTokens.delete(`${params.accountId}:${normalizedToken}`);
105
+ }
106
+
107
+ function buildSyntheticMessageEvent(
108
+ event: FeishuCardActionEvent,
109
+ content: string,
110
+ chatType: "p2p" | "group",
111
+ ): FeishuMessageEvent {
112
+ const replyTargetMessageId = event.context.open_message_id ?? event.open_message_id;
113
+ return {
114
+ sender: {
115
+ sender_id: {
116
+ open_id: event.operator.open_id,
117
+ user_id: event.operator.user_id,
118
+ union_id: event.operator.union_id,
119
+ },
120
+ },
121
+ message: {
122
+ message_id: `card-action-${event.token}`,
123
+ ...(replyTargetMessageId ? { reply_target_message_id: replyTargetMessageId } : {}),
124
+ ...(!replyTargetMessageId ? { suppress_reply_target: true } : {}),
125
+ chat_id: event.context.chat_id || event.operator.open_id,
126
+ chat_type: chatType,
127
+ message_type: "text",
128
+ content: JSON.stringify({ text: content }),
129
+ },
130
+ };
131
+ }
132
+
133
+ function resolveCallbackTarget(event: FeishuCardActionEvent): string {
134
+ const chatId = event.context.chat_id?.trim();
135
+ if (chatId) {
136
+ return `chat:${chatId}`;
137
+ }
138
+ return `user:${event.operator.open_id}`;
139
+ }
140
+
141
+ async function dispatchSyntheticCommand(params: {
142
+ cfg: ClawdbotConfig;
143
+ event: FeishuCardActionEvent;
144
+ command: string;
145
+ account: ReturnType<typeof resolveFeishuRuntimeAccount>;
146
+ botOpenId?: string;
147
+ runtime?: RuntimeEnv;
148
+ accountId?: string;
149
+ chatType?: "p2p" | "group";
150
+ }): Promise<void> {
151
+ const resolvedChatType = await resolveCardActionChatType({
152
+ event: params.event,
153
+ account: params.account,
154
+ chatType: params.chatType,
155
+ log: params.runtime?.log ?? console.log,
156
+ });
157
+ await handleFeishuMessage({
158
+ cfg: params.cfg,
159
+ event: buildSyntheticMessageEvent(params.event, params.command, resolvedChatType),
160
+ botOpenId: params.botOpenId,
161
+ runtime: params.runtime,
162
+ accountId: params.accountId,
163
+ });
164
+ }
165
+
166
+ // Feishu's im.chat.get returns two fields:
167
+ // chat_mode: conversation type — "p2p" | "group" | "topic"
168
+ // chat_type: privacy classification — "private" | "public"
169
+ // We check chat_mode first because it directly indicates conversation type.
170
+ // "private" maps to "p2p" as the safe-failure direction (restrictive DM
171
+ // policy) — a private group chat misclassified as p2p is safer than the
172
+ // reverse. "topic" and "public" are treated as group semantics.
173
+ function normalizeResolvedCardActionChatType(value: unknown): "p2p" | "group" | undefined {
174
+ if (value === "group" || value === "topic" || value === "public") {
175
+ return "group";
176
+ }
177
+ if (value === "p2p" || value === "private") {
178
+ return "p2p";
179
+ }
180
+ return undefined;
181
+ }
182
+
183
+ const resolvedChatTypeCache = new Map<string, { value: "p2p" | "group"; expiresAt: number }>();
184
+ const CHAT_TYPE_CACHE_TTL_MS = 30 * 60_000;
185
+ const CHAT_TYPE_CACHE_MAX_SIZE = 5_000;
186
+
187
+ function pruneChatTypeCache(now: number): void {
188
+ for (const [key, entry] of resolvedChatTypeCache.entries()) {
189
+ if (entry.expiresAt <= now) {
190
+ resolvedChatTypeCache.delete(key);
191
+ }
192
+ }
193
+ if (resolvedChatTypeCache.size > CHAT_TYPE_CACHE_MAX_SIZE) {
194
+ const excess = resolvedChatTypeCache.size - CHAT_TYPE_CACHE_MAX_SIZE;
195
+ const iter = resolvedChatTypeCache.keys();
196
+ for (let i = 0; i < excess; i++) {
197
+ const key = iter.next().value;
198
+ if (key !== undefined) {
199
+ resolvedChatTypeCache.delete(key);
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ function sanitizeLogValue(v: string): string {
206
+ return v.replace(/[\r\n]/g, " ").slice(0, 500);
207
+ }
208
+
209
+ async function resolveCardActionChatType(params: {
210
+ event: FeishuCardActionEvent;
211
+ account: ReturnType<typeof resolveFeishuRuntimeAccount>;
212
+ chatType?: "p2p" | "group";
213
+ log: (message: string) => void;
214
+ }): Promise<"p2p" | "group"> {
215
+ const explicitChatType = normalizeResolvedCardActionChatType(params.chatType);
216
+ if (explicitChatType) {
217
+ return explicitChatType;
218
+ }
219
+
220
+ const chatId = params.event.context.chat_id?.trim();
221
+ if (!chatId) {
222
+ return "p2p";
223
+ }
224
+
225
+ const cacheKey = `${params.account.accountId}:${chatId}`;
226
+ const now = Date.now();
227
+ pruneChatTypeCache(now);
228
+ const cached = resolvedChatTypeCache.get(cacheKey);
229
+ if (cached) {
230
+ return cached.value;
231
+ }
232
+
233
+ try {
234
+ const response = (await createFeishuClient(params.account).im.chat.get({
235
+ path: { chat_id: chatId },
236
+ })) as { code?: number; msg?: string; data?: { chat_type?: unknown; chat_mode?: unknown } };
237
+ if (response.code === 0) {
238
+ const resolvedChatType =
239
+ normalizeResolvedCardActionChatType(response.data?.chat_mode) ??
240
+ normalizeResolvedCardActionChatType(response.data?.chat_type);
241
+ if (resolvedChatType) {
242
+ resolvedChatTypeCache.set(cacheKey, {
243
+ value: resolvedChatType,
244
+ expiresAt: now + CHAT_TYPE_CACHE_TTL_MS,
245
+ });
246
+ return resolvedChatType;
247
+ }
248
+ params.log(
249
+ `feishu[${params.account.accountId}]: card action missing chat type for chat; defaulting to p2p`,
250
+ );
251
+ } else {
252
+ params.log(
253
+ `feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(response.msg ?? "unknown error")}; defaulting to p2p`,
254
+ );
255
+ }
256
+ } catch (err) {
257
+ const message = err instanceof Error ? err.message : "unknown";
258
+ params.log(
259
+ `feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(message)}; defaulting to p2p`,
260
+ );
261
+ }
262
+
263
+ return "p2p";
264
+ }
265
+
266
+ async function sendInvalidInteractionNotice(params: {
267
+ cfg: ClawdbotConfig;
268
+ event: FeishuCardActionEvent;
269
+ reason: "malformed" | "stale" | "wrong_user" | "wrong_conversation";
270
+ accountId?: string;
271
+ }): Promise<void> {
272
+ const reasonText =
273
+ params.reason === "stale"
274
+ ? "This card action has expired. Open a fresh launcher card and try again."
275
+ : params.reason === "wrong_user"
276
+ ? "This card action belongs to a different user."
277
+ : params.reason === "wrong_conversation"
278
+ ? "This card action belongs to a different conversation."
279
+ : "This card action payload is invalid.";
280
+
281
+ await sendMessageFeishu({
282
+ cfg: params.cfg,
283
+ to: resolveCallbackTarget(params.event),
284
+ text: `⚠️ ${reasonText}`,
285
+ accountId: params.accountId,
286
+ });
287
+ }
288
+
289
+ export async function handleFeishuCardAction(params: {
290
+ cfg: ClawdbotConfig;
291
+ event: FeishuCardActionEvent;
292
+ botOpenId?: string;
293
+ runtime?: RuntimeEnv;
294
+ accountId?: string;
295
+ }): Promise<void> {
296
+ const { cfg, event, runtime, accountId } = params;
297
+ const account = resolveFeishuRuntimeAccount({ cfg, accountId });
298
+ const log = runtime?.log ?? console.log;
299
+ if (!event.token.trim()) {
300
+ log(
301
+ `feishu[${account.accountId}]: rejected card action from ${event.operator.open_id}: missing token`,
302
+ );
303
+ return;
304
+ }
305
+ const decoded = decodeFeishuCardAction({ event });
306
+ const claimedToken = beginFeishuCardActionToken({
307
+ token: event.token,
308
+ accountId: account.accountId,
309
+ });
310
+ if (!claimedToken) {
311
+ log(`feishu[${account.accountId}]: skipping duplicate card action token ${event.token}`);
312
+ return;
313
+ }
314
+
315
+ try {
316
+ if (decoded.kind === "invalid") {
317
+ log(
318
+ `feishu[${account.accountId}]: rejected card action from ${event.operator.open_id}: ${decoded.reason}`,
319
+ );
320
+ await sendInvalidInteractionNotice({
321
+ cfg,
322
+ event,
323
+ reason: decoded.reason,
324
+ accountId,
325
+ });
326
+ completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
327
+ return;
328
+ }
329
+
330
+ if (decoded.kind === "structured") {
331
+ const { envelope } = decoded;
332
+ log(
333
+ `feishu[${account.accountId}]: handling structured card action ${envelope.a} from ${event.operator.open_id}`,
334
+ );
335
+
336
+ if (envelope.a === FEISHU_APPROVAL_REQUEST_ACTION) {
337
+ const command = typeof envelope.m?.command === "string" ? envelope.m.command.trim() : "";
338
+ if (!command) {
339
+ await sendInvalidInteractionNotice({
340
+ cfg,
341
+ event,
342
+ reason: "malformed",
343
+ accountId,
344
+ });
345
+ completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
346
+ return;
347
+ }
348
+ const prompt =
349
+ typeof envelope.m?.prompt === "string" && envelope.m.prompt.trim()
350
+ ? envelope.m.prompt
351
+ : `Run \`${command}\` in this Feishu conversation?`;
352
+ await sendCardFeishu({
353
+ cfg,
354
+ to: resolveCallbackTarget(event),
355
+ card: createApprovalCard({
356
+ operatorOpenId: event.operator.open_id,
357
+ chatId: event.context.chat_id || undefined,
358
+ command,
359
+ prompt,
360
+ sessionKey: envelope.c?.s,
361
+ expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS,
362
+ chatType: await resolveCardActionChatType({
363
+ event,
364
+ account,
365
+ chatType: envelope.c?.t,
366
+ log,
367
+ }),
368
+ confirmLabel: command === "/reset" ? "Reset" : "Confirm",
369
+ }),
370
+ accountId,
371
+ });
372
+ completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
373
+ return;
374
+ }
375
+
376
+ if (envelope.a === FEISHU_APPROVAL_CANCEL_ACTION) {
377
+ await sendMessageFeishu({
378
+ cfg,
379
+ to: resolveCallbackTarget(event),
380
+ text: "Cancelled.",
381
+ accountId,
382
+ });
383
+ completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
384
+ return;
385
+ }
386
+
387
+ if (envelope.a === FEISHU_APPROVAL_CONFIRM_ACTION || envelope.k === "quick") {
388
+ const command = envelope.q?.trim();
389
+ if (!command) {
390
+ await sendInvalidInteractionNotice({
391
+ cfg,
392
+ event,
393
+ reason: "malformed",
394
+ accountId,
395
+ });
396
+ completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
397
+ return;
398
+ }
399
+ await dispatchSyntheticCommand({
400
+ cfg,
401
+ event,
402
+ command,
403
+ account,
404
+ botOpenId: params.botOpenId,
405
+ runtime,
406
+ accountId,
407
+ chatType: envelope.c?.t,
408
+ });
409
+ completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
410
+ return;
411
+ }
412
+
413
+ await sendInvalidInteractionNotice({
414
+ cfg,
415
+ event,
416
+ reason: "malformed",
417
+ accountId,
418
+ });
419
+ completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
420
+ return;
421
+ }
422
+
423
+ const content = buildFeishuCardActionTextFallback(event);
424
+
425
+ log(
426
+ `feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`,
427
+ );
428
+
429
+ await dispatchSyntheticCommand({
430
+ cfg,
431
+ event,
432
+ command: content,
433
+ account,
434
+ botOpenId: params.botOpenId,
435
+ runtime,
436
+ accountId,
437
+ });
438
+ completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
439
+ } catch (err) {
440
+ if (err instanceof FeishuRetryableCardActionError) {
441
+ releaseFeishuCardActionToken({ token: event.token, accountId: account.accountId });
442
+ } else {
443
+ completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
444
+ }
445
+ throw err;
446
+ }
447
+ }
@@ -0,0 +1,159 @@
1
+ import { isRecord } from "./comment-shared.js";
2
+
3
+ export const FEISHU_CARD_INTERACTION_VERSION = "ocf1";
4
+
5
+ type FeishuCardInteractionKind = "button" | "quick" | "meta";
6
+ type FeishuCardInteractionReason = "malformed" | "stale" | "wrong_user" | "wrong_conversation";
7
+
8
+ type FeishuCardInteractionMetadata = Record<string, string | number | boolean | null | undefined>;
9
+
10
+ export type FeishuCardInteractionEnvelope = {
11
+ oc: typeof FEISHU_CARD_INTERACTION_VERSION;
12
+ k: FeishuCardInteractionKind;
13
+ a: string;
14
+ q?: string;
15
+ m?: FeishuCardInteractionMetadata;
16
+ c?: {
17
+ u?: string;
18
+ h?: string;
19
+ s?: string;
20
+ e?: number;
21
+ t?: "p2p" | "group";
22
+ };
23
+ };
24
+
25
+ type FeishuCardActionEventLike = {
26
+ operator: {
27
+ open_id?: string;
28
+ };
29
+ action: {
30
+ value: unknown;
31
+ };
32
+ context: {
33
+ chat_id?: string;
34
+ };
35
+ };
36
+
37
+ type DecodedFeishuCardAction =
38
+ | {
39
+ kind: "structured";
40
+ envelope: FeishuCardInteractionEnvelope;
41
+ }
42
+ | {
43
+ kind: "legacy";
44
+ text: string;
45
+ }
46
+ | {
47
+ kind: "invalid";
48
+ reason: FeishuCardInteractionReason;
49
+ };
50
+
51
+ function isInteractionKind(value: unknown): value is FeishuCardInteractionKind {
52
+ return value === "button" || value === "quick" || value === "meta";
53
+ }
54
+
55
+ function isMetadataValue(value: unknown): value is string | number | boolean | null | undefined {
56
+ return (
57
+ value === null ||
58
+ value === undefined ||
59
+ typeof value === "string" ||
60
+ typeof value === "number" ||
61
+ typeof value === "boolean"
62
+ );
63
+ }
64
+
65
+ export function createFeishuCardInteractionEnvelope(
66
+ envelope: Omit<FeishuCardInteractionEnvelope, "oc">,
67
+ ): FeishuCardInteractionEnvelope {
68
+ return {
69
+ oc: FEISHU_CARD_INTERACTION_VERSION,
70
+ ...envelope,
71
+ };
72
+ }
73
+
74
+ export function buildFeishuCardActionTextFallback(event: FeishuCardActionEventLike): string {
75
+ const actionValue = event.action.value;
76
+ if (isRecord(actionValue)) {
77
+ if (typeof actionValue.text === "string") {
78
+ return actionValue.text;
79
+ }
80
+ if (typeof actionValue.command === "string") {
81
+ return actionValue.command;
82
+ }
83
+ return JSON.stringify(actionValue);
84
+ }
85
+ return String(actionValue);
86
+ }
87
+
88
+ export function decodeFeishuCardAction(params: {
89
+ event: FeishuCardActionEventLike;
90
+ now?: number;
91
+ }): DecodedFeishuCardAction {
92
+ const { event, now = Date.now() } = params;
93
+ const actionValue = event.action.value;
94
+ if (!isRecord(actionValue) || actionValue.oc !== FEISHU_CARD_INTERACTION_VERSION) {
95
+ return {
96
+ kind: "legacy",
97
+ text: buildFeishuCardActionTextFallback(event),
98
+ };
99
+ }
100
+
101
+ if (!isInteractionKind(actionValue.k) || typeof actionValue.a !== "string" || !actionValue.a) {
102
+ return { kind: "invalid", reason: "malformed" };
103
+ }
104
+
105
+ if (actionValue.q !== undefined && typeof actionValue.q !== "string") {
106
+ return { kind: "invalid", reason: "malformed" };
107
+ }
108
+
109
+ if (actionValue.m !== undefined) {
110
+ if (!isRecord(actionValue.m)) {
111
+ return { kind: "invalid", reason: "malformed" };
112
+ }
113
+ for (const value of Object.values(actionValue.m)) {
114
+ if (!isMetadataValue(value)) {
115
+ return { kind: "invalid", reason: "malformed" };
116
+ }
117
+ }
118
+ }
119
+
120
+ if (actionValue.c !== undefined) {
121
+ if (!isRecord(actionValue.c)) {
122
+ return { kind: "invalid", reason: "malformed" };
123
+ }
124
+ if (actionValue.c.u !== undefined && typeof actionValue.c.u !== "string") {
125
+ return { kind: "invalid", reason: "malformed" };
126
+ }
127
+ if (actionValue.c.h !== undefined && typeof actionValue.c.h !== "string") {
128
+ return { kind: "invalid", reason: "malformed" };
129
+ }
130
+ if (actionValue.c.s !== undefined && typeof actionValue.c.s !== "string") {
131
+ return { kind: "invalid", reason: "malformed" };
132
+ }
133
+ if (actionValue.c.e !== undefined && !Number.isFinite(actionValue.c.e)) {
134
+ return { kind: "invalid", reason: "malformed" };
135
+ }
136
+ if (actionValue.c.t !== undefined && actionValue.c.t !== "p2p" && actionValue.c.t !== "group") {
137
+ return { kind: "invalid", reason: "malformed" };
138
+ }
139
+
140
+ if (typeof actionValue.c.e === "number" && actionValue.c.e < now) {
141
+ return { kind: "invalid", reason: "stale" };
142
+ }
143
+
144
+ const expectedUser = actionValue.c.u?.trim();
145
+ if (expectedUser && expectedUser !== (event.operator.open_id ?? "").trim()) {
146
+ return { kind: "invalid", reason: "wrong_user" };
147
+ }
148
+
149
+ const expectedChat = actionValue.c.h?.trim();
150
+ if (expectedChat && expectedChat !== (event.context.chat_id ?? "").trim()) {
151
+ return { kind: "invalid", reason: "wrong_conversation" };
152
+ }
153
+ }
154
+
155
+ return {
156
+ kind: "structured",
157
+ envelope: actionValue as FeishuCardInteractionEnvelope,
158
+ };
159
+ }
@@ -0,0 +1,54 @@
1
+ import { expect } from "vitest";
2
+
3
+ type MockCalls = {
4
+ mock: { calls: unknown[][] };
5
+ };
6
+
7
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
8
+ return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
9
+ }
10
+
11
+ function asArray(value: unknown): unknown[] {
12
+ return Array.isArray(value) ? value : [];
13
+ }
14
+
15
+ export function expectFirstSentCardUsesFillWidthOnly(sendCardMock: {
16
+ mock: { calls: unknown[][] };
17
+ }) {
18
+ const firstSendArg = sendCardMock.mock.calls.at(0)?.[0] as
19
+ | {
20
+ card?: {
21
+ config?: {
22
+ width_mode?: string;
23
+ wide_screen_mode?: boolean;
24
+ enable_forward?: boolean;
25
+ };
26
+ };
27
+ }
28
+ | undefined;
29
+ const sentCard = firstSendArg?.card;
30
+ expect(sentCard).toBeDefined();
31
+ expect(sentCard?.config?.width_mode).toBe("fill");
32
+ expect(sentCard?.config?.wide_screen_mode).toBeUndefined();
33
+ expect(sentCard?.config?.enable_forward).toBeUndefined();
34
+ }
35
+
36
+ export function expectSentCardHasP2pAction(sendCardMock: MockCalls) {
37
+ const hasP2pAction = sendCardMock.mock.calls.some(([arg]) => {
38
+ const card = asRecord(asRecord(arg)?.card);
39
+ const body = asRecord(card?.body);
40
+ return asArray(body?.elements).some((element) => {
41
+ const elementRecord = asRecord(element);
42
+ if (elementRecord?.tag !== "action") {
43
+ return false;
44
+ }
45
+ return asArray(elementRecord.actions).some((action) => {
46
+ const actionRecord = asRecord(action);
47
+ const value = asRecord(actionRecord?.value);
48
+ const command = asRecord(value?.c);
49
+ return command?.t === "p2p";
50
+ });
51
+ });
52
+ });
53
+ expect(hasP2pAction).toBe(true);
54
+ }