@actagent/feishu 2026.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +11 -0
  2. package/actagent.plugin.json +224 -0
  3. package/api.ts +33 -0
  4. package/channel-entry.ts +21 -0
  5. package/channel-plugin-api.ts +2 -0
  6. package/contract-api.ts +17 -0
  7. package/index.ts +83 -0
  8. package/legacy-state-migrations-api.ts +2 -0
  9. package/npm-shrinkwrap.json +539 -0
  10. package/package.json +64 -0
  11. package/runtime-api.ts +58 -0
  12. package/runtime-setter-api.ts +3 -0
  13. package/secret-contract-api.ts +6 -0
  14. package/security-contract-api.ts +2 -0
  15. package/session-key-api.ts +2 -0
  16. package/setup-api.ts +4 -0
  17. package/setup-entry.test.ts +33 -0
  18. package/setup-entry.ts +25 -0
  19. package/skills/feishu-doc/SKILL.md +211 -0
  20. package/skills/feishu-doc/references/block-types.md +103 -0
  21. package/skills/feishu-drive/SKILL.md +97 -0
  22. package/skills/feishu-perm/SKILL.md +119 -0
  23. package/skills/feishu-wiki/SKILL.md +113 -0
  24. package/src/accounts.test.ts +481 -0
  25. package/src/accounts.ts +380 -0
  26. package/src/agent-config.ts +22 -0
  27. package/src/app-registration.test.ts +62 -0
  28. package/src/app-registration.ts +355 -0
  29. package/src/approval-auth.test.ts +25 -0
  30. package/src/approval-auth.ts +26 -0
  31. package/src/async.test.ts +68 -0
  32. package/src/async.ts +109 -0
  33. package/src/audio-preflight.runtime.ts +10 -0
  34. package/src/bitable.test.ts +174 -0
  35. package/src/bitable.ts +781 -0
  36. package/src/bot-content.ts +488 -0
  37. package/src/bot-group-name.test.ts +148 -0
  38. package/src/bot-runtime-api.ts +13 -0
  39. package/src/bot-sender-name.test.ts +68 -0
  40. package/src/bot-sender-name.ts +137 -0
  41. package/src/bot.broadcast.test.ts +643 -0
  42. package/src/bot.card-action.test.ts +647 -0
  43. package/src/bot.checkBotMentioned.test.ts +266 -0
  44. package/src/bot.helpers.test.ts +136 -0
  45. package/src/bot.stripBotMention.test.ts +127 -0
  46. package/src/bot.test.ts +3817 -0
  47. package/src/bot.ts +1788 -0
  48. package/src/card-action.ts +515 -0
  49. package/src/card-interaction.test.ts +132 -0
  50. package/src/card-interaction.ts +160 -0
  51. package/src/card-test-helpers.ts +55 -0
  52. package/src/card-ux-approval.ts +66 -0
  53. package/src/card-ux-launcher.test.ts +126 -0
  54. package/src/card-ux-launcher.ts +136 -0
  55. package/src/card-ux-shared.ts +34 -0
  56. package/src/channel-runtime-api.ts +17 -0
  57. package/src/channel.runtime.ts +48 -0
  58. package/src/channel.test.ts +1337 -0
  59. package/src/channel.ts +1401 -0
  60. package/src/chat-schema.ts +30 -0
  61. package/src/chat.test.ts +295 -0
  62. package/src/chat.ts +198 -0
  63. package/src/client-timeout.ts +44 -0
  64. package/src/client.test.ts +463 -0
  65. package/src/client.ts +263 -0
  66. package/src/comment-dispatcher-runtime-api.ts +7 -0
  67. package/src/comment-dispatcher.test.ts +186 -0
  68. package/src/comment-dispatcher.ts +108 -0
  69. package/src/comment-handler-runtime-api.ts +4 -0
  70. package/src/comment-handler.test.ts +588 -0
  71. package/src/comment-handler.ts +304 -0
  72. package/src/comment-reaction.test.ts +139 -0
  73. package/src/comment-reaction.ts +260 -0
  74. package/src/comment-shared.test.ts +184 -0
  75. package/src/comment-shared.ts +405 -0
  76. package/src/comment-target.ts +45 -0
  77. package/src/config-schema.test.ts +327 -0
  78. package/src/config-schema.ts +338 -0
  79. package/src/conversation-id.test.ts +19 -0
  80. package/src/conversation-id.ts +199 -0
  81. package/src/dedup-migrations.test.ts +90 -0
  82. package/src/dedup-migrations.ts +103 -0
  83. package/src/dedup.test.ts +95 -0
  84. package/src/dedup.ts +304 -0
  85. package/src/dedupe-key.ts +68 -0
  86. package/src/directory.static.ts +62 -0
  87. package/src/directory.test.ts +142 -0
  88. package/src/directory.ts +125 -0
  89. package/src/doc-schema.ts +183 -0
  90. package/src/doctor.test.ts +382 -0
  91. package/src/doctor.ts +876 -0
  92. package/src/docx-batch-insert.test.ts +117 -0
  93. package/src/docx-batch-insert.ts +223 -0
  94. package/src/docx-color-text.ts +154 -0
  95. package/src/docx-table-ops.test.ts +54 -0
  96. package/src/docx-table-ops.ts +316 -0
  97. package/src/docx-types.ts +39 -0
  98. package/src/docx.account-selection.test.ts +96 -0
  99. package/src/docx.test.ts +706 -0
  100. package/src/docx.ts +1598 -0
  101. package/src/drive-schema.ts +93 -0
  102. package/src/drive.test.ts +1240 -0
  103. package/src/drive.ts +830 -0
  104. package/src/dynamic-agent.test.ts +156 -0
  105. package/src/dynamic-agent.ts +144 -0
  106. package/src/event-types.ts +46 -0
  107. package/src/external-keys.test.ts +21 -0
  108. package/src/external-keys.ts +20 -0
  109. package/src/lifecycle.test-support.ts +223 -0
  110. package/src/media.test.ts +956 -0
  111. package/src/media.ts +1106 -0
  112. package/src/mention-target.types.ts +6 -0
  113. package/src/mention.ts +115 -0
  114. package/src/message-action-contract.ts +14 -0
  115. package/src/monitor-state-runtime-api.ts +8 -0
  116. package/src/monitor-transport-runtime-api.ts +11 -0
  117. package/src/monitor.account.ts +501 -0
  118. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +215 -0
  119. package/src/monitor.bot-identity.ts +87 -0
  120. package/src/monitor.bot-menu-handler.ts +164 -0
  121. package/src/monitor.bot-menu.lifecycle.test-support.ts +221 -0
  122. package/src/monitor.bot-menu.test.ts +200 -0
  123. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +265 -0
  124. package/src/monitor.card-action.lifecycle.test-support.ts +418 -0
  125. package/src/monitor.cleanup.test.ts +384 -0
  126. package/src/monitor.comment-notice-handler.ts +106 -0
  127. package/src/monitor.comment.test.ts +968 -0
  128. package/src/monitor.comment.ts +1386 -0
  129. package/src/monitor.lifecycle.test.ts +5 -0
  130. package/src/monitor.message-handler.ts +346 -0
  131. package/src/monitor.reaction.test.ts +770 -0
  132. package/src/monitor.startup.test.ts +232 -0
  133. package/src/monitor.startup.ts +76 -0
  134. package/src/monitor.state.defaults.test.ts +47 -0
  135. package/src/monitor.state.ts +171 -0
  136. package/src/monitor.synthetic-error.ts +19 -0
  137. package/src/monitor.test-mocks.ts +47 -0
  138. package/src/monitor.transport.ts +451 -0
  139. package/src/monitor.ts +104 -0
  140. package/src/monitor.webhook-e2e.test.ts +284 -0
  141. package/src/monitor.webhook-security.test.ts +394 -0
  142. package/src/monitor.webhook.test-helpers.ts +138 -0
  143. package/src/outbound-runtime-api.ts +2 -0
  144. package/src/outbound.test.ts +1255 -0
  145. package/src/outbound.ts +742 -0
  146. package/src/perm-schema.ts +53 -0
  147. package/src/perm.ts +171 -0
  148. package/src/pins.ts +109 -0
  149. package/src/policy.test.ts +224 -0
  150. package/src/policy.ts +322 -0
  151. package/src/post.test.ts +106 -0
  152. package/src/post.ts +276 -0
  153. package/src/presentation-card.ts +204 -0
  154. package/src/probe.test.ts +310 -0
  155. package/src/probe.ts +181 -0
  156. package/src/processing-claims.ts +60 -0
  157. package/src/qr-terminal.ts +2 -0
  158. package/src/reactions.ts +124 -0
  159. package/src/reasoning-preview.test.ts +114 -0
  160. package/src/reasoning-preview.ts +29 -0
  161. package/src/reply-dispatcher-runtime-api.ts +8 -0
  162. package/src/reply-dispatcher.test.ts +2009 -0
  163. package/src/reply-dispatcher.ts +865 -0
  164. package/src/runtime.ts +10 -0
  165. package/src/secret-contract.ts +146 -0
  166. package/src/secret-input.ts +2 -0
  167. package/src/security-audit-shared.ts +70 -0
  168. package/src/security-audit.test.ts +60 -0
  169. package/src/security-audit.ts +2 -0
  170. package/src/send-result.ts +81 -0
  171. package/src/send-target.test.ts +87 -0
  172. package/src/send-target.ts +36 -0
  173. package/src/send.reply-fallback.test.ts +418 -0
  174. package/src/send.test.ts +661 -0
  175. package/src/send.ts +860 -0
  176. package/src/sequential-key.test.ts +73 -0
  177. package/src/sequential-key.ts +29 -0
  178. package/src/sequential-queue.test.ts +184 -0
  179. package/src/sequential-queue.ts +90 -0
  180. package/src/session-conversation.ts +42 -0
  181. package/src/session-route.ts +49 -0
  182. package/src/setup-core.ts +52 -0
  183. package/src/setup-surface.test.ts +485 -0
  184. package/src/setup-surface.ts +620 -0
  185. package/src/streaming-card.test.ts +549 -0
  186. package/src/streaming-card.ts +611 -0
  187. package/src/subagent-hooks.test.ts +632 -0
  188. package/src/subagent-hooks.ts +414 -0
  189. package/src/targets.ts +98 -0
  190. package/src/test-support/lifecycle-test-support.ts +459 -0
  191. package/src/thread-bindings.test.ts +181 -0
  192. package/src/thread-bindings.ts +332 -0
  193. package/src/tool-account-routing.test.ts +419 -0
  194. package/src/tool-account.test.ts +45 -0
  195. package/src/tool-account.ts +98 -0
  196. package/src/tool-factory-test-harness.ts +83 -0
  197. package/src/tool-result.test.ts +33 -0
  198. package/src/tool-result.ts +17 -0
  199. package/src/tools-config.test.ts +52 -0
  200. package/src/tools-config.ts +29 -0
  201. package/src/types.ts +111 -0
  202. package/src/typing.test.ts +145 -0
  203. package/src/typing.ts +215 -0
  204. package/src/wiki-schema.ts +70 -0
  205. package/src/wiki.ts +271 -0
  206. package/subagent-hooks-api.ts +22 -0
  207. package/tsconfig.json +16 -0
package/src/dedup.ts ADDED
@@ -0,0 +1,304 @@
1
+ // Feishu plugin module implements dedup behavior.
2
+ import { createHash } from "node:crypto";
3
+ import type { PluginStateSyncKeyedStore } from "actagent/plugin-sdk/plugin-state-runtime";
4
+ import {
5
+ releaseFeishuMessageProcessing,
6
+ tryBeginFeishuMessageProcessing,
7
+ } from "./processing-claims.js";
8
+ import { getFeishuRuntime } from "./runtime.js";
9
+
10
+ // Persistent TTL: 24 hours — survives restarts & WebSocket reconnects.
11
+ const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
12
+ const MEMORY_MAX_SIZE = 1_000;
13
+ const STORE_MAX_ENTRIES = 10_000;
14
+ type FeishuDedupStoreEntry = {
15
+ namespace: string;
16
+ messageId: string;
17
+ seenAt: number;
18
+ };
19
+
20
+ const memory = new Map<string, number>();
21
+ const cachedDedupStores = new Map<string, PluginStateSyncKeyedStore<FeishuDedupStoreEntry>>();
22
+
23
+ function normalizeMessageId(messageId: string | undefined | null): string | null {
24
+ const trimmed = messageId?.trim();
25
+ return trimmed ? trimmed : null;
26
+ }
27
+
28
+ function normalizeNamespace(namespace?: string): string {
29
+ return namespace?.trim() || "global";
30
+ }
31
+
32
+ function pluginStateNamespace(namespace: string): string {
33
+ return `dedup.${namespace.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
34
+ }
35
+
36
+ function openDedupStore(namespace: string): PluginStateSyncKeyedStore<FeishuDedupStoreEntry> {
37
+ const stateNamespace = pluginStateNamespace(namespace);
38
+ const cached = cachedDedupStores.get(stateNamespace);
39
+ if (cached) {
40
+ return cached;
41
+ }
42
+ const store = getFeishuRuntime().state.openSyncKeyedStore<FeishuDedupStoreEntry>({
43
+ namespace: stateNamespace,
44
+ maxEntries: STORE_MAX_ENTRIES,
45
+ defaultTtlMs: DEDUP_TTL_MS,
46
+ });
47
+ cachedDedupStores.set(stateNamespace, store);
48
+ return store;
49
+ }
50
+
51
+ function dedupeStoreKey(namespace: string, messageId: string): string {
52
+ return createHash("sha256")
53
+ .update(`${namespace}\0${messageId}`, "utf8")
54
+ .digest("hex")
55
+ .slice(0, 32);
56
+ }
57
+
58
+ function memoryKey(namespace: string, messageId: string): string {
59
+ return `${namespace}\0${messageId}`;
60
+ }
61
+
62
+ function isRecent(seenAt: number | undefined, now = Date.now()): boolean {
63
+ return typeof seenAt === "number" && Number.isFinite(seenAt) && now - seenAt < DEDUP_TTL_MS;
64
+ }
65
+
66
+ function pruneMemory(now = Date.now()): void {
67
+ for (const [key, seenAt] of memory) {
68
+ if (!isRecent(seenAt, now)) {
69
+ memory.delete(key);
70
+ }
71
+ }
72
+ if (memory.size <= MEMORY_MAX_SIZE) {
73
+ return;
74
+ }
75
+ const toRemove = Array.from(memory.entries())
76
+ .toSorted(([, left], [, right]) => left - right)
77
+ .slice(0, memory.size - MEMORY_MAX_SIZE);
78
+ for (const [key] of toRemove) {
79
+ memory.delete(key);
80
+ }
81
+ }
82
+
83
+ function remember(namespace: string, messageId: string, seenAt = Date.now()): void {
84
+ memory.set(memoryKey(namespace, messageId), seenAt);
85
+ pruneMemory(seenAt);
86
+ }
87
+
88
+ function hasMemory(namespace: string, messageId: string, now = Date.now()): boolean {
89
+ const key = memoryKey(namespace, messageId);
90
+ const seenAt = memory.get(key);
91
+ if (isRecent(seenAt, now)) {
92
+ return true;
93
+ }
94
+ memory.delete(key);
95
+ return false;
96
+ }
97
+
98
+ export { releaseFeishuMessageProcessing, tryBeginFeishuMessageProcessing };
99
+
100
+ export async function claimUnprocessedFeishuMessage(params: {
101
+ messageId: string | undefined | null;
102
+ namespace?: string;
103
+ log?: (...args: unknown[]) => void;
104
+ }): Promise<"claimed" | "duplicate" | "inflight" | "invalid"> {
105
+ const { messageId, namespace = "global", log } = params;
106
+ const normalizedMessageId = normalizeMessageId(messageId);
107
+ if (!normalizedMessageId) {
108
+ return "invalid";
109
+ }
110
+ if (await hasProcessedFeishuMessage(normalizedMessageId, namespace, log)) {
111
+ return "duplicate";
112
+ }
113
+ if (!tryBeginFeishuMessageProcessing(normalizedMessageId, namespace)) {
114
+ return "inflight";
115
+ }
116
+ return "claimed";
117
+ }
118
+
119
+ export async function finalizeFeishuMessageProcessing(params: {
120
+ messageId: string | undefined | null;
121
+ namespace?: string;
122
+ log?: (...args: unknown[]) => void;
123
+ claimHeld?: boolean;
124
+ }): Promise<boolean> {
125
+ const { messageId, namespace = "global", log, claimHeld = false } = params;
126
+ const normalizedMessageId = normalizeMessageId(messageId);
127
+ if (!normalizedMessageId) {
128
+ return false;
129
+ }
130
+ if (!claimHeld && !tryBeginFeishuMessageProcessing(normalizedMessageId, namespace)) {
131
+ return false;
132
+ }
133
+ if (!(await tryRecordMessagePersistent(normalizedMessageId, namespace, log))) {
134
+ releaseFeishuMessageProcessing(normalizedMessageId, namespace);
135
+ return false;
136
+ }
137
+ return true;
138
+ }
139
+
140
+ export async function recordProcessedFeishuMessage(
141
+ messageId: string | undefined | null,
142
+ namespace = "global",
143
+ log?: (...args: unknown[]) => void,
144
+ ): Promise<boolean> {
145
+ const normalizedMessageId = normalizeMessageId(messageId);
146
+ if (!normalizedMessageId) {
147
+ return false;
148
+ }
149
+ return await tryRecordMessagePersistent(normalizedMessageId, namespace, log);
150
+ }
151
+
152
+ export async function forgetProcessedFeishuMessage(
153
+ messageId: string | undefined | null,
154
+ namespace = "global",
155
+ log?: (...args: unknown[]) => void,
156
+ ): Promise<boolean> {
157
+ const normalizedNamespace = normalizeNamespace(namespace);
158
+ const normalizedMessageId = normalizeMessageId(messageId);
159
+ if (!normalizedMessageId) {
160
+ return false;
161
+ }
162
+ memory.delete(memoryKey(normalizedNamespace, normalizedMessageId));
163
+ const key = dedupeStoreKey(normalizedNamespace, normalizedMessageId);
164
+ try {
165
+ return openDedupStore(normalizedNamespace).delete(key);
166
+ } catch (error) {
167
+ log?.(`feishu-dedup: persistent delete failed: ${String(error)}`);
168
+ return false;
169
+ }
170
+ }
171
+
172
+ export async function hasProcessedFeishuMessage(
173
+ messageId: string | undefined | null,
174
+ namespace = "global",
175
+ log?: (...args: unknown[]) => void,
176
+ ): Promise<boolean> {
177
+ const normalizedMessageId = normalizeMessageId(messageId);
178
+ if (!normalizedMessageId) {
179
+ return false;
180
+ }
181
+ return hasRecordedMessagePersistent(normalizedMessageId, namespace, log);
182
+ }
183
+
184
+ export async function tryRecordMessagePersistent(
185
+ messageId: string,
186
+ namespace = "global",
187
+ log?: (...args: unknown[]) => void,
188
+ ): Promise<boolean> {
189
+ const normalizedNamespace = normalizeNamespace(namespace);
190
+ const normalizedMessageId = normalizeMessageId(messageId);
191
+ if (!normalizedMessageId) {
192
+ return true;
193
+ }
194
+ const now = Date.now();
195
+ if (hasMemory(normalizedNamespace, normalizedMessageId, now)) {
196
+ return false;
197
+ }
198
+ const key = dedupeStoreKey(normalizedNamespace, normalizedMessageId);
199
+ try {
200
+ const store = openDedupStore(normalizedNamespace);
201
+ const existing = store.lookup(key);
202
+ const existingSeenAt = existing?.seenAt;
203
+ if (isRecent(existingSeenAt, now)) {
204
+ remember(normalizedNamespace, normalizedMessageId, existingSeenAt);
205
+ return false;
206
+ }
207
+ const recorded = store.registerIfAbsent(
208
+ key,
209
+ {
210
+ namespace: normalizedNamespace,
211
+ messageId: normalizedMessageId,
212
+ seenAt: now,
213
+ },
214
+ { ttlMs: DEDUP_TTL_MS },
215
+ );
216
+ if (!recorded) {
217
+ const current = store.lookup(key);
218
+ const currentSeenAt = current?.seenAt;
219
+ if (isRecent(currentSeenAt, now)) {
220
+ remember(normalizedNamespace, normalizedMessageId, currentSeenAt);
221
+ return false;
222
+ }
223
+ store.register(
224
+ key,
225
+ {
226
+ namespace: normalizedNamespace,
227
+ messageId: normalizedMessageId,
228
+ seenAt: now,
229
+ },
230
+ { ttlMs: DEDUP_TTL_MS },
231
+ );
232
+ }
233
+ remember(normalizedNamespace, normalizedMessageId, now);
234
+ return true;
235
+ } catch (error) {
236
+ log?.(`feishu-dedup: persistent state error, falling back to memory: ${String(error)}`);
237
+ remember(normalizedNamespace, normalizedMessageId, now);
238
+ return true;
239
+ }
240
+ }
241
+
242
+ async function hasRecordedMessagePersistent(
243
+ messageId: string,
244
+ namespace = "global",
245
+ log?: (...args: unknown[]) => void,
246
+ ): Promise<boolean> {
247
+ const normalizedNamespace = normalizeNamespace(namespace);
248
+ const normalizedMessageId = normalizeMessageId(messageId);
249
+ if (!normalizedMessageId) {
250
+ return false;
251
+ }
252
+ const now = Date.now();
253
+ if (hasMemory(normalizedNamespace, normalizedMessageId, now)) {
254
+ return true;
255
+ }
256
+ try {
257
+ const store = openDedupStore(normalizedNamespace);
258
+ const existing = store.lookup(dedupeStoreKey(normalizedNamespace, normalizedMessageId));
259
+ const existingSeenAt = existing?.seenAt;
260
+ if (!isRecent(existingSeenAt, now)) {
261
+ return false;
262
+ }
263
+ remember(normalizedNamespace, normalizedMessageId, existingSeenAt);
264
+ return true;
265
+ } catch (error) {
266
+ log?.(`feishu-dedup: persistent peek failed: ${String(error)}`);
267
+ return hasMemory(normalizedNamespace, normalizedMessageId, now);
268
+ }
269
+ }
270
+
271
+ export async function warmupDedupFromPluginState(
272
+ namespace: string,
273
+ log?: (...args: unknown[]) => void,
274
+ ): Promise<number> {
275
+ const normalizedNamespace = normalizeNamespace(namespace);
276
+ try {
277
+ let loaded = 0;
278
+ const now = Date.now();
279
+ for (const entry of openDedupStore(normalizedNamespace).entries()) {
280
+ if (entry.value.namespace !== normalizedNamespace || !isRecent(entry.value.seenAt, now)) {
281
+ continue;
282
+ }
283
+ remember(normalizedNamespace, entry.value.messageId, entry.value.seenAt);
284
+ loaded++;
285
+ }
286
+ return loaded;
287
+ } catch (error) {
288
+ log?.(`feishu-dedup: warmup persistent state error: ${String(error)}`);
289
+ return 0;
290
+ }
291
+ }
292
+
293
+ export const testingHooks = {
294
+ resetFeishuDedupForTests() {
295
+ memory.clear();
296
+ for (const store of cachedDedupStores.values()) {
297
+ store.clear();
298
+ }
299
+ cachedDedupStores.clear();
300
+ },
301
+ resetFeishuDedupMemoryForTests() {
302
+ memory.clear();
303
+ },
304
+ };
@@ -0,0 +1,68 @@
1
+ // Feishu plugin module implements dedupe key behavior.
2
+ import { asNullableRecord as readRecord } from "actagent/plugin-sdk/string-coerce-runtime";
3
+ import type { FeishuMessageEvent } from "./event-types.js";
4
+ import { normalizeFeishuExternalKey } from "./external-keys.js";
5
+ import { parsePostContent } from "./post.js";
6
+
7
+ type FeishuMessageDedupeInput = Pick<FeishuMessageEvent, "message">;
8
+
9
+ function readExternalKey(value: unknown): string | undefined {
10
+ return normalizeFeishuExternalKey(typeof value === "string" ? value : "");
11
+ }
12
+
13
+ function parseContentRecord(content: string): Record<string, unknown> | null {
14
+ try {
15
+ return readRecord(JSON.parse(content));
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function buildMediaDedupeKey(messageId: string, mediaParts: string[]): string {
22
+ return JSON.stringify([messageId, ...mediaParts]);
23
+ }
24
+
25
+ function resolvePostMediaParts(content: string): string[] {
26
+ const parsed = parsePostContent(content);
27
+ return [
28
+ ...parsed.imageKeys.map((imageKey) => `image_key:${imageKey}`),
29
+ ...parsed.mediaKeys.map((media) => `file_key:${media.fileKey}`),
30
+ ];
31
+ }
32
+
33
+ function resolveMessageMediaParts(messageType: string, content: string): string[] {
34
+ if (messageType === "post") {
35
+ return resolvePostMediaParts(content);
36
+ }
37
+
38
+ const parsed = parseContentRecord(content);
39
+ if (!parsed) {
40
+ return [];
41
+ }
42
+
43
+ const imageKey = readExternalKey(parsed.image_key);
44
+ const fileKey = readExternalKey(parsed.file_key);
45
+ switch (messageType) {
46
+ case "image":
47
+ return imageKey ? [`image_key:${imageKey}`] : [];
48
+ case "file":
49
+ case "audio":
50
+ case "sticker":
51
+ return fileKey ? [`file_key:${fileKey}`] : [];
52
+ case "video":
53
+ case "media":
54
+ return fileKey ? [`file_key:${fileKey}`] : imageKey ? [`image_key:${imageKey}`] : [];
55
+ default:
56
+ return fileKey ? [`file_key:${fileKey}`] : imageKey ? [`image_key:${imageKey}`] : [];
57
+ }
58
+ }
59
+
60
+ export function resolveFeishuMessageDedupeKey(event: FeishuMessageDedupeInput): string | undefined {
61
+ const messageId = event.message.message_id?.trim();
62
+ if (!messageId) {
63
+ return undefined;
64
+ }
65
+ const messageType = event.message.message_type.trim();
66
+ const mediaParts = resolveMessageMediaParts(messageType, event.message.content);
67
+ return mediaParts.length > 0 ? buildMediaDedupeKey(messageId, mediaParts) : messageId;
68
+ }
@@ -0,0 +1,62 @@
1
+ // Feishu plugin module implements directory.static behavior.
2
+ import {
3
+ listDirectoryGroupEntriesFromMapKeysAndAllowFrom,
4
+ listDirectoryUserEntriesFromAllowFromAndMapKeys,
5
+ } from "actagent/plugin-sdk/directory-runtime";
6
+ import type { ACTAgentBotConfig } from "../runtime-api.js";
7
+ import { resolveFeishuAccount } from "./accounts.js";
8
+ import { normalizeFeishuTarget } from "./targets.js";
9
+
10
+ export type FeishuDirectoryPeer = {
11
+ kind: "user";
12
+ id: string;
13
+ name?: string;
14
+ };
15
+
16
+ export type FeishuDirectoryGroup = {
17
+ kind: "group";
18
+ id: string;
19
+ name?: string;
20
+ };
21
+
22
+ function toFeishuDirectoryPeers(ids: string[]): FeishuDirectoryPeer[] {
23
+ return ids.map((id) => ({ kind: "user", id }));
24
+ }
25
+
26
+ function toFeishuDirectoryGroups(ids: string[]): FeishuDirectoryGroup[] {
27
+ return ids.map((id) => ({ kind: "group", id }));
28
+ }
29
+
30
+ export async function listFeishuDirectoryPeers(params: {
31
+ cfg: ACTAgentBotConfig;
32
+ query?: string;
33
+ limit?: number;
34
+ accountId?: string;
35
+ }): Promise<FeishuDirectoryPeer[]> {
36
+ const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
37
+ const entries = listDirectoryUserEntriesFromAllowFromAndMapKeys({
38
+ allowFrom: account.config.allowFrom,
39
+ map: account.config.dms,
40
+ query: params.query,
41
+ limit: params.limit,
42
+ normalizeAllowFromId: (entry) => normalizeFeishuTarget(entry) ?? entry,
43
+ normalizeMapKeyId: (entry) => normalizeFeishuTarget(entry) ?? entry,
44
+ });
45
+ return toFeishuDirectoryPeers(entries.map((entry) => entry.id));
46
+ }
47
+
48
+ export async function listFeishuDirectoryGroups(params: {
49
+ cfg: ACTAgentBotConfig;
50
+ query?: string;
51
+ limit?: number;
52
+ accountId?: string;
53
+ }): Promise<FeishuDirectoryGroup[]> {
54
+ const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
55
+ const entries = listDirectoryGroupEntriesFromMapKeysAndAllowFrom({
56
+ groups: account.config.groups,
57
+ allowFrom: account.config.groupAllowFrom,
58
+ query: params.query,
59
+ limit: params.limit,
60
+ });
61
+ return toFeishuDirectoryGroups(entries.map((entry) => entry.id));
62
+ }
@@ -0,0 +1,142 @@
1
+ // Feishu tests cover directory plugin behavior.
2
+ import { importFreshModule } from "actagent/plugin-sdk/test-fixtures";
3
+ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import type { ACTAgentBotConfig } from "../runtime-api.js";
5
+
6
+ const createFeishuClientMock = vi.hoisted(() => vi.fn());
7
+
8
+ vi.mock("./client.js", () => ({
9
+ createFeishuClient: createFeishuClientMock,
10
+ }));
11
+
12
+ const { listFeishuDirectoryGroupsLive, listFeishuDirectoryPeersLive } = await importFreshModule<
13
+ typeof import("./directory.js")
14
+ >(import.meta.url, "./directory.js?directory-test");
15
+ const { listFeishuDirectoryGroups, listFeishuDirectoryPeers } = await importFreshModule<
16
+ typeof import("./directory.static.js")
17
+ >(import.meta.url, "./directory.static.js?directory-test");
18
+
19
+ function makeStaticCfg(): ACTAgentBotConfig {
20
+ return {
21
+ channels: {
22
+ feishu: {
23
+ allowFrom: ["user:alice", "user:bob"],
24
+ dms: {
25
+ "user:carla": {},
26
+ },
27
+ groups: {
28
+ "chat-1": {},
29
+ },
30
+ groupAllowFrom: ["chat-2"],
31
+ },
32
+ },
33
+ } as ACTAgentBotConfig;
34
+ }
35
+
36
+ function makeConfiguredCfg(): ACTAgentBotConfig {
37
+ return {
38
+ channels: {
39
+ feishu: {
40
+ ...makeStaticCfg().channels?.feishu,
41
+ appId: "cli_test_app_id",
42
+ appSecret: "cli_test_app_secret",
43
+ },
44
+ },
45
+ } as ACTAgentBotConfig;
46
+ }
47
+
48
+ describe("feishu directory (config-backed)", () => {
49
+ afterAll(() => {
50
+ vi.doUnmock("./client.js");
51
+ vi.resetModules();
52
+ });
53
+
54
+ beforeEach(() => {
55
+ createFeishuClientMock.mockReset();
56
+ });
57
+
58
+ it("merges allowFrom + dms into peer entries", async () => {
59
+ const peers = await listFeishuDirectoryPeers({ cfg: makeStaticCfg(), query: "a" });
60
+ expect(peers).toEqual([
61
+ { kind: "user", id: "alice" },
62
+ { kind: "user", id: "carla" },
63
+ ]);
64
+ });
65
+
66
+ it("normalizes spaced provider-prefixed peer entries", async () => {
67
+ const cfg = {
68
+ channels: {
69
+ feishu: {
70
+ allowFrom: [" feishu:user:ou_alice "],
71
+ dms: {
72
+ " lark:dm:ou_carla ": {},
73
+ },
74
+ groups: {},
75
+ groupAllowFrom: [],
76
+ },
77
+ },
78
+ } as ACTAgentBotConfig;
79
+
80
+ const peers = await listFeishuDirectoryPeers({ cfg });
81
+ expect(peers).toEqual([
82
+ { kind: "user", id: "ou_alice" },
83
+ { kind: "user", id: "ou_carla" },
84
+ ]);
85
+ });
86
+
87
+ it("merges groups map + groupAllowFrom into group entries", async () => {
88
+ const groups = await listFeishuDirectoryGroups({ cfg: makeStaticCfg() });
89
+ expect(groups).toEqual([
90
+ { kind: "group", id: "chat-1" },
91
+ { kind: "group", id: "chat-2" },
92
+ ]);
93
+ });
94
+
95
+ it("falls back to static peers on live lookup failure by default", async () => {
96
+ createFeishuClientMock.mockReturnValueOnce({
97
+ contact: {
98
+ user: {
99
+ list: vi.fn(async () => {
100
+ throw new Error("token expired");
101
+ }),
102
+ },
103
+ },
104
+ });
105
+
106
+ const peers = await listFeishuDirectoryPeersLive({ cfg: makeConfiguredCfg(), query: "a" });
107
+ expect(peers).toEqual([
108
+ { kind: "user", id: "alice" },
109
+ { kind: "user", id: "carla" },
110
+ ]);
111
+ });
112
+
113
+ it("surfaces live peer lookup failures when fallback is disabled", async () => {
114
+ createFeishuClientMock.mockReturnValueOnce({
115
+ contact: {
116
+ user: {
117
+ list: vi.fn(async () => {
118
+ throw new Error("token expired");
119
+ }),
120
+ },
121
+ },
122
+ });
123
+
124
+ await expect(
125
+ listFeishuDirectoryPeersLive({ cfg: makeConfiguredCfg(), fallbackToStatic: false }),
126
+ ).rejects.toThrow("token expired");
127
+ });
128
+
129
+ it("surfaces live group lookup failures when fallback is disabled", async () => {
130
+ createFeishuClientMock.mockReturnValueOnce({
131
+ im: {
132
+ chat: {
133
+ list: vi.fn(async () => ({ code: 999, msg: "forbidden" })),
134
+ },
135
+ },
136
+ });
137
+
138
+ await expect(
139
+ listFeishuDirectoryGroupsLive({ cfg: makeConfiguredCfg(), fallbackToStatic: false }),
140
+ ).rejects.toThrow("forbidden");
141
+ });
142
+ });
@@ -0,0 +1,125 @@
1
+ // Feishu plugin module implements directory behavior.
2
+ import { normalizeLowercaseStringOrEmpty } from "actagent/plugin-sdk/string-coerce-runtime";
3
+ import type { ACTAgentBotConfig } from "../runtime-api.js";
4
+ import { resolveFeishuAccount } from "./accounts.js";
5
+ import { createFeishuClient } from "./client.js";
6
+ import {
7
+ listFeishuDirectoryGroups,
8
+ listFeishuDirectoryPeers,
9
+ type FeishuDirectoryGroup,
10
+ type FeishuDirectoryPeer,
11
+ } from "./directory.static.js";
12
+
13
+ export async function listFeishuDirectoryPeersLive(params: {
14
+ cfg: ACTAgentBotConfig;
15
+ query?: string;
16
+ limit?: number;
17
+ accountId?: string;
18
+ fallbackToStatic?: boolean;
19
+ }): Promise<FeishuDirectoryPeer[]> {
20
+ const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
21
+ if (!account.configured) {
22
+ return listFeishuDirectoryPeers(params);
23
+ }
24
+
25
+ try {
26
+ const client = createFeishuClient(account);
27
+ const peers: FeishuDirectoryPeer[] = [];
28
+ const limit = params.limit ?? 50;
29
+
30
+ const response = await client.contact.user.list({
31
+ params: {
32
+ page_size: Math.min(limit, 50),
33
+ },
34
+ });
35
+
36
+ if (response.code !== 0) {
37
+ throw new Error(response.msg || `code ${response.code}`);
38
+ }
39
+
40
+ const q = normalizeLowercaseStringOrEmpty(params.query);
41
+ for (const user of response.data?.items ?? []) {
42
+ if (user.open_id) {
43
+ const name = user.name || "";
44
+ if (
45
+ !q ||
46
+ normalizeLowercaseStringOrEmpty(user.open_id).includes(q) ||
47
+ normalizeLowercaseStringOrEmpty(name).includes(q)
48
+ ) {
49
+ peers.push({
50
+ kind: "user",
51
+ id: user.open_id,
52
+ name: name || undefined,
53
+ });
54
+ }
55
+ }
56
+ if (peers.length >= limit) {
57
+ break;
58
+ }
59
+ }
60
+
61
+ return peers;
62
+ } catch (err) {
63
+ if (params.fallbackToStatic === false) {
64
+ throw err instanceof Error ? err : new Error("Feishu live peer lookup failed");
65
+ }
66
+ return listFeishuDirectoryPeers(params);
67
+ }
68
+ }
69
+
70
+ export async function listFeishuDirectoryGroupsLive(params: {
71
+ cfg: ACTAgentBotConfig;
72
+ query?: string;
73
+ limit?: number;
74
+ accountId?: string;
75
+ fallbackToStatic?: boolean;
76
+ }): Promise<FeishuDirectoryGroup[]> {
77
+ const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
78
+ if (!account.configured) {
79
+ return listFeishuDirectoryGroups(params);
80
+ }
81
+
82
+ try {
83
+ const client = createFeishuClient(account);
84
+ const groups: FeishuDirectoryGroup[] = [];
85
+ const limit = params.limit ?? 50;
86
+
87
+ const response = await client.im.chat.list({
88
+ params: {
89
+ page_size: Math.min(limit, 100),
90
+ },
91
+ });
92
+
93
+ if (response.code !== 0) {
94
+ throw new Error(response.msg || `code ${response.code}`);
95
+ }
96
+
97
+ const q = normalizeLowercaseStringOrEmpty(params.query);
98
+ for (const chat of response.data?.items ?? []) {
99
+ if (chat.chat_id) {
100
+ const name = chat.name || "";
101
+ if (
102
+ !q ||
103
+ normalizeLowercaseStringOrEmpty(chat.chat_id).includes(q) ||
104
+ normalizeLowercaseStringOrEmpty(name).includes(q)
105
+ ) {
106
+ groups.push({
107
+ kind: "group",
108
+ id: chat.chat_id,
109
+ name: name || undefined,
110
+ });
111
+ }
112
+ }
113
+ if (groups.length >= limit) {
114
+ break;
115
+ }
116
+ }
117
+
118
+ return groups;
119
+ } catch (err) {
120
+ if (params.fallbackToStatic === false) {
121
+ throw err instanceof Error ? err : new Error("Feishu live group lookup failed");
122
+ }
123
+ return listFeishuDirectoryGroups(params);
124
+ }
125
+ }