@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,264 @@
1
+ import "./lifecycle.test-support.js";
2
+ import { createNonExitingRuntimeEnv } from "autobot/plugin-sdk/plugin-test-runtime";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
5
+ import { FeishuConfigSchema } from "./config-schema.js";
6
+ import {
7
+ getFeishuLifecycleTestMocks,
8
+ resetFeishuLifecycleTestMocks,
9
+ } from "./lifecycle.test-support.js";
10
+ import {
11
+ createFeishuTextMessageEvent,
12
+ createFeishuLifecycleReplyDispatcher,
13
+ installFeishuLifecycleReplyRuntime,
14
+ mockFeishuReplyOnceDispatch,
15
+ restoreFeishuLifecycleStateDir,
16
+ runFeishuLifecycleSequence,
17
+ setFeishuLifecycleStateDir,
18
+ setupFeishuLifecycleHandler,
19
+ } from "./test-support/lifecycle-test-support.js";
20
+ import type { FeishuConfig, ResolvedFeishuAccount } from "./types.js";
21
+
22
+ const {
23
+ createEventDispatcherMock,
24
+ createFeishuReplyDispatcherMock,
25
+ dispatchReplyFromConfigMock,
26
+ finalizeInboundContextMock,
27
+ resolveAgentRouteMock,
28
+ resolveBoundConversationMock,
29
+ withReplyDispatcherMock,
30
+ } = getFeishuLifecycleTestMocks();
31
+
32
+ let handlersByAccount = new Map<string, Record<string, (data: unknown) => Promise<void>>>();
33
+ let runtimesByAccount = new Map<string, RuntimeEnv>();
34
+ const originalStateDir = process.env.AUTOBOT_STATE_DIR;
35
+
36
+ function createLifecycleConfig(): ClawdbotConfig {
37
+ return {
38
+ broadcast: {
39
+ oc_broadcast_group: ["susan", "main"],
40
+ },
41
+ agents: {
42
+ list: [{ id: "main" }, { id: "susan" }],
43
+ },
44
+ channels: {
45
+ feishu: {
46
+ enabled: true,
47
+ groupPolicy: "open",
48
+ requireMention: false,
49
+ resolveSenderNames: false,
50
+ accounts: {
51
+ "account-A": {
52
+ enabled: true,
53
+ appId: "cli_a",
54
+ appSecret: "secret_a", // pragma: allowlist secret
55
+ connectionMode: "websocket",
56
+ groupPolicy: "open",
57
+ requireMention: false,
58
+ resolveSenderNames: false,
59
+ groups: {
60
+ oc_broadcast_group: {
61
+ requireMention: false,
62
+ },
63
+ },
64
+ },
65
+ "account-B": {
66
+ enabled: true,
67
+ appId: "cli_b",
68
+ appSecret: "secret_b", // pragma: allowlist secret
69
+ connectionMode: "websocket",
70
+ groupPolicy: "open",
71
+ requireMention: false,
72
+ resolveSenderNames: false,
73
+ groups: {
74
+ oc_broadcast_group: {
75
+ requireMention: false,
76
+ },
77
+ },
78
+ },
79
+ },
80
+ },
81
+ },
82
+ messages: {
83
+ inbound: {
84
+ debounceMs: 0,
85
+ byChannel: {
86
+ feishu: 0,
87
+ },
88
+ },
89
+ },
90
+ } as ClawdbotConfig;
91
+ }
92
+
93
+ function createLifecycleAccount(accountId: "account-A" | "account-B"): ResolvedFeishuAccount {
94
+ const config: FeishuConfig = FeishuConfigSchema.parse({
95
+ enabled: true,
96
+ connectionMode: "websocket",
97
+ groupPolicy: "open",
98
+ requireMention: false,
99
+ resolveSenderNames: false,
100
+ groups: {
101
+ oc_broadcast_group: {
102
+ requireMention: false,
103
+ },
104
+ },
105
+ });
106
+ return {
107
+ accountId,
108
+ selectionSource: "explicit",
109
+ enabled: true,
110
+ configured: true,
111
+ appId: accountId === "account-A" ? "cli_a" : "cli_b",
112
+ appSecret: accountId === "account-A" ? "secret_a" : "secret_b", // pragma: allowlist secret
113
+ domain: "feishu",
114
+ config,
115
+ };
116
+ }
117
+
118
+ async function setupLifecycleMonitor(accountId: "account-A" | "account-B") {
119
+ const runtime = createNonExitingRuntimeEnv();
120
+ runtimesByAccount.set(accountId, runtime);
121
+ return setupFeishuLifecycleHandler({
122
+ createEventDispatcherMock,
123
+ onRegister: (registered) => {
124
+ handlersByAccount.set(accountId, registered);
125
+ },
126
+ runtime,
127
+ cfg: createLifecycleConfig(),
128
+ account: createLifecycleAccount(accountId),
129
+ handlerKey: "im.message.receive_v1",
130
+ missingHandlerMessage: `missing im.message.receive_v1 handler for ${accountId}`,
131
+ once: true,
132
+ });
133
+ }
134
+
135
+ describe("Feishu broadcast reply-once lifecycle", () => {
136
+ beforeEach(() => {
137
+ vi.useRealTimers();
138
+ resetFeishuLifecycleTestMocks();
139
+ handlersByAccount = new Map();
140
+ runtimesByAccount = new Map();
141
+ setFeishuLifecycleStateDir("autobot-feishu-broadcast");
142
+
143
+ createFeishuReplyDispatcherMock.mockReturnValue(createFeishuLifecycleReplyDispatcher());
144
+
145
+ resolveBoundConversationMock.mockReturnValue(null);
146
+ resolveAgentRouteMock.mockReturnValue({
147
+ agentId: "main",
148
+ channel: "feishu",
149
+ accountId: "account-A",
150
+ sessionKey: "agent:main:feishu:group:oc_broadcast_group",
151
+ mainSessionKey: "agent:main:main",
152
+ matchedBy: "default",
153
+ });
154
+
155
+ mockFeishuReplyOnceDispatch({
156
+ dispatchReplyFromConfigMock,
157
+ replyText: "broadcast reply once",
158
+ shouldSendFinalReply: (ctx) =>
159
+ typeof (ctx as { SessionKey?: string } | undefined)?.SessionKey === "string" &&
160
+ (ctx as { SessionKey: string }).SessionKey.includes("agent:main:"),
161
+ });
162
+
163
+ withReplyDispatcherMock.mockImplementation(async ({ run }) => await run());
164
+
165
+ installFeishuLifecycleReplyRuntime({
166
+ resolveAgentRouteMock,
167
+ finalizeInboundContextMock,
168
+ dispatchReplyFromConfigMock,
169
+ withReplyDispatcherMock,
170
+ storePath: "/tmp/feishu-broadcast-sessions.json",
171
+ });
172
+ });
173
+
174
+ afterEach(() => {
175
+ vi.useRealTimers();
176
+ restoreFeishuLifecycleStateDir(originalStateDir);
177
+ });
178
+
179
+ it("uses one active reply path when the same broadcast event reaches two accounts", async () => {
180
+ const onMessageA = await setupLifecycleMonitor("account-A");
181
+ const onMessageB = await setupLifecycleMonitor("account-B");
182
+ const event = createFeishuTextMessageEvent({
183
+ messageId: "om_broadcast_once",
184
+ chatId: "oc_broadcast_group",
185
+ text: "hello broadcast",
186
+ });
187
+
188
+ await runFeishuLifecycleSequence(
189
+ [() => onMessageA(event), () => onMessageB(event)],
190
+ [
191
+ () => {
192
+ expect(dispatchReplyFromConfigMock.mock.calls.length).toBeGreaterThan(0);
193
+ },
194
+ () => {
195
+ expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(2);
196
+ expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1);
197
+ },
198
+ ],
199
+ );
200
+
201
+ expect(runtimesByAccount.get("account-A")?.error).not.toHaveBeenCalled();
202
+ expect(runtimesByAccount.get("account-B")?.error).not.toHaveBeenCalled();
203
+
204
+ expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(2);
205
+ expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1);
206
+ expect(createFeishuReplyDispatcherMock).toHaveBeenCalledWith(
207
+ expect.objectContaining({
208
+ accountId: "account-a",
209
+ chatId: "oc_broadcast_group",
210
+ replyToMessageId: "om_broadcast_once",
211
+ }),
212
+ );
213
+
214
+ const sessionKeys = finalizeInboundContextMock.mock.calls.map(
215
+ (call) => (call[0] as { SessionKey?: string }).SessionKey,
216
+ );
217
+ expect(sessionKeys).toContain("agent:main:feishu:group:oc_broadcast_group");
218
+ expect(sessionKeys).toContain("agent:susan:feishu:group:oc_broadcast_group");
219
+
220
+ const activeDispatcher = createFeishuReplyDispatcherMock.mock.results[0]?.value.dispatcher as {
221
+ sendFinalReply: ReturnType<typeof vi.fn>;
222
+ };
223
+ expect(activeDispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
224
+ });
225
+
226
+ it("does not duplicate delivery after a post-send failure on the first account", async () => {
227
+ const onMessageA = await setupLifecycleMonitor("account-A");
228
+ const onMessageB = await setupLifecycleMonitor("account-B");
229
+ const event = createFeishuTextMessageEvent({
230
+ messageId: "om_broadcast_retry",
231
+ chatId: "oc_broadcast_group",
232
+ text: "hello broadcast",
233
+ });
234
+
235
+ dispatchReplyFromConfigMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
236
+ if (typeof ctx?.SessionKey === "string" && ctx.SessionKey.includes("agent:susan:")) {
237
+ return { queuedFinal: false, counts: { final: 0 } };
238
+ }
239
+ await dispatcher.sendFinalReply({ text: "broadcast reply once" });
240
+ throw new Error("post-send failure");
241
+ });
242
+
243
+ await runFeishuLifecycleSequence(
244
+ [() => onMessageA(event), () => onMessageB(event)],
245
+ [
246
+ () => {
247
+ expect(dispatchReplyFromConfigMock.mock.calls.length).toBeGreaterThan(0);
248
+ },
249
+ () => {
250
+ expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(2);
251
+ },
252
+ ],
253
+ );
254
+
255
+ expect(runtimesByAccount.get("account-A")?.error).not.toHaveBeenCalled();
256
+ expect(runtimesByAccount.get("account-B")?.error).not.toHaveBeenCalled();
257
+ expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(2);
258
+
259
+ const activeDispatcher = createFeishuReplyDispatcherMock.mock.results[0]?.value.dispatcher as {
260
+ sendFinalReply: ReturnType<typeof vi.fn>;
261
+ };
262
+ expect(activeDispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
263
+ });
264
+ });
@@ -0,0 +1,421 @@
1
+ import { createRuntimeEnv } from "autobot/plugin-sdk/plugin-test-runtime";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import "./lifecycle.test-support.js";
4
+ import { resetProcessedFeishuCardActionTokensForTests } from "./card-action.js";
5
+ import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
6
+ import {
7
+ getFeishuLifecycleTestMocks,
8
+ resetFeishuLifecycleTestMocks,
9
+ } from "./lifecycle.test-support.js";
10
+ import {
11
+ createFeishuLifecycleConfig,
12
+ createFeishuLifecycleReplyDispatcher,
13
+ createResolvedFeishuLifecycleAccount,
14
+ expectFeishuReplyDispatcherSentFinalReplyOnce,
15
+ expectFeishuReplyPipelineDedupedAcrossReplay,
16
+ expectFeishuReplyPipelineDedupedAfterPostSendFailure,
17
+ installFeishuLifecycleReplyRuntime,
18
+ mockFeishuReplyOnceDispatch,
19
+ restoreFeishuLifecycleStateDir,
20
+ setFeishuLifecycleStateDir,
21
+ setupFeishuLifecycleHandler,
22
+ } from "./test-support/lifecycle-test-support.js";
23
+
24
+ const {
25
+ createEventDispatcherMock,
26
+ createFeishuReplyDispatcherMock,
27
+ dispatchReplyFromConfigMock,
28
+ finalizeInboundContextMock,
29
+ resolveAgentRouteMock,
30
+ resolveBoundConversationMock,
31
+ sendCardFeishuMock,
32
+ sendMessageFeishuMock,
33
+ touchBindingMock,
34
+ withReplyDispatcherMock,
35
+ } = getFeishuLifecycleTestMocks();
36
+
37
+ let handlers: Record<string, (data: unknown) => Promise<void>> = {};
38
+ let lastRuntime = createRuntimeEnv();
39
+ const originalStateDir = process.env.AUTOBOT_STATE_DIR;
40
+ const lifecycleConfig = createFeishuLifecycleConfig({
41
+ accountId: "acct-card",
42
+ appId: "cli_test",
43
+ appSecret: "secret_test",
44
+ channelConfig: {
45
+ dmPolicy: "open",
46
+ allowFrom: ["ou_user1"],
47
+ },
48
+ accountConfig: {
49
+ dmPolicy: "open",
50
+ allowFrom: ["ou_user1"],
51
+ },
52
+ });
53
+
54
+ const lifecycleAccount = createResolvedFeishuLifecycleAccount({
55
+ accountId: "acct-card",
56
+ appId: "cli_test",
57
+ appSecret: "secret_test",
58
+ config: {
59
+ dmPolicy: "open",
60
+ allowFrom: ["ou_user1"],
61
+ },
62
+ });
63
+
64
+ function createCardActionEvent(params: {
65
+ token: string;
66
+ action: string;
67
+ command: string;
68
+ chatId?: string;
69
+ chatType?: "group" | "p2p";
70
+ }) {
71
+ const openId = "ou_user1";
72
+ const chatId = params.chatId ?? "p2p:ou_user1";
73
+ const chatType = params.chatType ?? "p2p";
74
+ return {
75
+ operator: {
76
+ open_id: openId,
77
+ user_id: "user_1",
78
+ union_id: "union_1",
79
+ },
80
+ token: params.token,
81
+ action: {
82
+ tag: "button",
83
+ value: createFeishuCardInteractionEnvelope({
84
+ k: "quick",
85
+ a: params.action,
86
+ q: params.command,
87
+ c: {
88
+ u: openId,
89
+ h: chatId,
90
+ t: chatType,
91
+ e: Date.now() + 60_000,
92
+ },
93
+ }),
94
+ },
95
+ context: {
96
+ open_id: openId,
97
+ user_id: "user_1",
98
+ chat_id: chatId,
99
+ },
100
+ };
101
+ }
102
+
103
+ async function setupLifecycleMonitor() {
104
+ lastRuntime = createRuntimeEnv();
105
+ return setupFeishuLifecycleHandler({
106
+ createEventDispatcherMock,
107
+ onRegister: (registered) => {
108
+ handlers = registered;
109
+ },
110
+ runtime: lastRuntime,
111
+ cfg: lifecycleConfig,
112
+ account: lifecycleAccount,
113
+ handlerKey: "card.action.trigger",
114
+ missingHandlerMessage: "missing card.action.trigger handler",
115
+ });
116
+ }
117
+
118
+ function latestReplyDispatcherParams() {
119
+ const call = createFeishuReplyDispatcherMock.mock.calls.at(-1);
120
+ if (!call) {
121
+ throw new Error("expected Feishu reply dispatcher call");
122
+ }
123
+ return call[0] as {
124
+ accountId?: string;
125
+ chatId?: string;
126
+ replyToMessageId?: string;
127
+ };
128
+ }
129
+
130
+ function latestFinalizedContext() {
131
+ const call = finalizeInboundContextMock.mock.calls.at(-1);
132
+ if (!call) {
133
+ throw new Error("expected finalized inbound context call");
134
+ }
135
+ return call[0] as {
136
+ AccountId?: string;
137
+ SessionKey?: string;
138
+ MessageSid?: string;
139
+ };
140
+ }
141
+
142
+ describe("Feishu card-action lifecycle", () => {
143
+ beforeEach(() => {
144
+ vi.useRealTimers();
145
+ resetFeishuLifecycleTestMocks();
146
+ handlers = {};
147
+ lastRuntime = createRuntimeEnv();
148
+ resetProcessedFeishuCardActionTokensForTests();
149
+ setFeishuLifecycleStateDir("autobot-feishu-card-action");
150
+
151
+ createFeishuReplyDispatcherMock.mockReturnValue(createFeishuLifecycleReplyDispatcher());
152
+
153
+ resolveBoundConversationMock.mockImplementation(() => ({
154
+ bindingId: "binding-card",
155
+ targetSessionKey: "agent:bound-agent:feishu:direct:ou_user1",
156
+ }));
157
+
158
+ resolveAgentRouteMock.mockReturnValue({
159
+ agentId: "main",
160
+ channel: "feishu",
161
+ accountId: "acct-card",
162
+ sessionKey: "agent:main:feishu:direct:ou_user1",
163
+ mainSessionKey: "agent:main:main",
164
+ matchedBy: "default",
165
+ });
166
+
167
+ mockFeishuReplyOnceDispatch({
168
+ dispatchReplyFromConfigMock,
169
+ replyText: "card action reply once",
170
+ });
171
+
172
+ withReplyDispatcherMock.mockImplementation(async ({ run }) => await run());
173
+
174
+ installFeishuLifecycleReplyRuntime({
175
+ resolveAgentRouteMock,
176
+ finalizeInboundContextMock,
177
+ dispatchReplyFromConfigMock,
178
+ withReplyDispatcherMock,
179
+ storePath: "/tmp/feishu-card-action-sessions.json",
180
+ });
181
+ });
182
+
183
+ afterEach(() => {
184
+ vi.useRealTimers();
185
+ resetProcessedFeishuCardActionTokensForTests();
186
+ restoreFeishuLifecycleStateDir(originalStateDir);
187
+ });
188
+
189
+ it("routes one reply across duplicate callback delivery", async () => {
190
+ const onCardAction = await setupLifecycleMonitor();
191
+ const event = createCardActionEvent({
192
+ token: "tok-card-once",
193
+ action: "feishu.quick_actions.help",
194
+ command: "/help",
195
+ });
196
+
197
+ await expectFeishuReplyPipelineDedupedAcrossReplay({
198
+ handler: onCardAction,
199
+ event,
200
+ dispatchReplyFromConfigMock,
201
+ createFeishuReplyDispatcherMock,
202
+ });
203
+
204
+ expect(lastRuntime?.error).not.toHaveBeenCalled();
205
+ expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
206
+ expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1);
207
+ const dispatcherParams = latestReplyDispatcherParams();
208
+ expect(dispatcherParams.accountId).toBe("acct-card");
209
+ expect(dispatcherParams.chatId).toBe("p2p:ou_user1");
210
+ expect(dispatcherParams.replyToMessageId).toBeUndefined();
211
+ const finalized = latestFinalizedContext();
212
+ expect(finalized.AccountId).toBe("acct-card");
213
+ expect(finalized.SessionKey).toBe("agent:bound-agent:feishu:direct:ou_user1");
214
+ expect(finalized.MessageSid).toBe("card-action-tok-card-once");
215
+ expect(touchBindingMock).toHaveBeenCalledWith("binding-card");
216
+
217
+ expectFeishuReplyDispatcherSentFinalReplyOnce({ createFeishuReplyDispatcherMock });
218
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
219
+ expect(sendCardFeishuMock).not.toHaveBeenCalled();
220
+ });
221
+
222
+ it("routes v2 callbacks that report open_chat_id instead of chat_id", async () => {
223
+ const onCardAction = await setupLifecycleMonitor();
224
+ const chatId = "oc_group_v2";
225
+
226
+ await onCardAction({
227
+ operator: {
228
+ open_id: "ou_user1",
229
+ },
230
+ token: "tok-card-v2-context",
231
+ action: {
232
+ tag: "button",
233
+ value: createFeishuCardInteractionEnvelope({
234
+ k: "quick",
235
+ a: "feishu.quick_actions.help",
236
+ q: "/help",
237
+ c: {
238
+ u: "ou_user1",
239
+ h: chatId,
240
+ t: "group",
241
+ e: Date.now() + 60_000,
242
+ },
243
+ }),
244
+ },
245
+ context: {
246
+ open_message_id: "om_card_v2",
247
+ open_chat_id: chatId,
248
+ },
249
+ });
250
+
251
+ expect(lastRuntime?.error).not.toHaveBeenCalled();
252
+ expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
253
+ const dispatcherParams = latestReplyDispatcherParams();
254
+ expect(dispatcherParams.accountId).toBe("acct-card");
255
+ expect(dispatcherParams.chatId).toBe(chatId);
256
+ expect(dispatcherParams.replyToMessageId).toBe("om_card_v2");
257
+ expect(latestFinalizedContext().MessageSid).toBe("card-action-tok-card-v2-context");
258
+ });
259
+
260
+ it("routes v2 callbacks with nested operator identity", async () => {
261
+ const onCardAction = await setupLifecycleMonitor();
262
+ const chatId = "p2p:ou_user1";
263
+
264
+ await onCardAction({
265
+ operator: {
266
+ user_id: {
267
+ open_id: "ou_user1",
268
+ user_id: "user_1",
269
+ union_id: "union_1",
270
+ },
271
+ },
272
+ token: "tok-card-v2-nested-operator",
273
+ action: {
274
+ tag: "button",
275
+ value: createFeishuCardInteractionEnvelope({
276
+ k: "quick",
277
+ a: "feishu.quick_actions.help",
278
+ q: "/help",
279
+ c: {
280
+ u: "ou_user1",
281
+ h: chatId,
282
+ t: "p2p",
283
+ e: Date.now() + 60_000,
284
+ },
285
+ }),
286
+ },
287
+ context: {
288
+ open_message_id: "om_card_v2_nested",
289
+ open_chat_id: chatId,
290
+ },
291
+ });
292
+
293
+ expect(lastRuntime?.error).not.toHaveBeenCalled();
294
+ expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
295
+ expect(createFeishuReplyDispatcherMock).toHaveBeenCalledWith(
296
+ expect.objectContaining({
297
+ accountId: "acct-card",
298
+ chatId,
299
+ replyToMessageId: "om_card_v2_nested",
300
+ }),
301
+ );
302
+ expect(finalizeInboundContextMock).toHaveBeenCalledWith(
303
+ expect.objectContaining({
304
+ AccountId: "acct-card",
305
+ SessionKey: "agent:bound-agent:feishu:direct:ou_user1",
306
+ MessageSid: "card-action-tok-card-v2-nested-operator",
307
+ }),
308
+ );
309
+ });
310
+
311
+ it("routes SDK-style card callbacks without context as direct callbacks", async () => {
312
+ const onCardAction = await setupLifecycleMonitor();
313
+
314
+ await onCardAction({
315
+ open_id: "ou_user1",
316
+ user_id: "user_1",
317
+ tenant_key: "tenant_1",
318
+ open_message_id: "om_sdk_card",
319
+ token: "tok-card-sdk-flat",
320
+ action: {
321
+ tag: "button",
322
+ value: {
323
+ command: "/help",
324
+ },
325
+ },
326
+ });
327
+
328
+ expect(lastRuntime?.error).not.toHaveBeenCalled();
329
+ expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
330
+ const dispatcherParams = latestReplyDispatcherParams();
331
+ expect(dispatcherParams.accountId).toBe("acct-card");
332
+ expect(dispatcherParams.chatId).toBe("ou_user1");
333
+ expect(dispatcherParams.replyToMessageId).toBe("om_sdk_card");
334
+ expect(latestFinalizedContext().MessageSid).toBe("card-action-tok-card-sdk-flat");
335
+ });
336
+
337
+ it("plain-sends card action replies when Feishu provides no real message id", async () => {
338
+ const onCardAction = await setupLifecycleMonitor();
339
+
340
+ await onCardAction({
341
+ open_id: "ou_user1",
342
+ token: "tok-card-no-reply-target",
343
+ action: {
344
+ tag: "button",
345
+ value: {
346
+ command: "/help",
347
+ },
348
+ },
349
+ });
350
+
351
+ expect(lastRuntime?.error).not.toHaveBeenCalled();
352
+ expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
353
+ const dispatcherParams = latestReplyDispatcherParams();
354
+ expect(dispatcherParams.accountId).toBe("acct-card");
355
+ expect(dispatcherParams.chatId).toBe("ou_user1");
356
+ expect(dispatcherParams.replyToMessageId).toBeUndefined();
357
+ expect(latestFinalizedContext().MessageSid).toBe("card-action-tok-card-no-reply-target");
358
+ });
359
+
360
+ it("does not duplicate delivery when retrying after a post-send failure", async () => {
361
+ const onCardAction = await setupLifecycleMonitor();
362
+ const event = createCardActionEvent({
363
+ token: "tok-card-retry",
364
+ action: "feishu.quick_actions.help",
365
+ command: "/help",
366
+ });
367
+
368
+ dispatchReplyFromConfigMock.mockImplementationOnce(async ({ dispatcher }) => {
369
+ await dispatcher.sendFinalReply({ text: "card action reply once" });
370
+ throw new Error("post-send failure");
371
+ });
372
+
373
+ await expectFeishuReplyPipelineDedupedAfterPostSendFailure({
374
+ handler: onCardAction,
375
+ event,
376
+ dispatchReplyFromConfigMock,
377
+ runtimeErrorMock: lastRuntime?.error as ReturnType<typeof vi.fn>,
378
+ });
379
+
380
+ expect(lastRuntime?.error).toHaveBeenCalledTimes(1);
381
+ expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
382
+ expectFeishuReplyDispatcherSentFinalReplyOnce({ createFeishuReplyDispatcherMock });
383
+ });
384
+
385
+ it("drops malformed card-action events with empty tokens before handler dispatch", async () => {
386
+ const onCardAction = await setupLifecycleMonitor();
387
+
388
+ await onCardAction({
389
+ operator: {
390
+ open_id: "ou_user1",
391
+ user_id: "user_1",
392
+ union_id: "union_1",
393
+ },
394
+ token: "",
395
+ action: {
396
+ tag: "button",
397
+ value: createFeishuCardInteractionEnvelope({
398
+ k: "quick",
399
+ a: "feishu.quick_actions.help",
400
+ q: "/help",
401
+ c: {
402
+ u: "ou_user1",
403
+ h: "p2p:ou_user1",
404
+ t: "p2p",
405
+ e: Date.now() + 60_000,
406
+ },
407
+ }),
408
+ },
409
+ context: {
410
+ open_id: "ou_user1",
411
+ user_id: "user_1",
412
+ chat_id: "p2p:ou_user1",
413
+ },
414
+ });
415
+
416
+ expect(lastRuntime?.error).toHaveBeenCalledWith(
417
+ "feishu[acct-card]: ignoring malformed card action payload",
418
+ );
419
+ expect(dispatchReplyFromConfigMock).not.toHaveBeenCalled();
420
+ });
421
+ });