@openclaw/feishu 2026.3.13 → 2026.5.2-beta.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 (187) hide show
  1. package/api.ts +31 -0
  2. package/channel-entry.ts +20 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +16 -0
  5. package/index.ts +70 -53
  6. package/openclaw.plugin.json +1827 -4
  7. package/package.json +32 -7
  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.test.ts +14 -0
  14. package/setup-entry.ts +13 -0
  15. package/src/accounts.test.ts +95 -7
  16. package/src/accounts.ts +199 -117
  17. package/src/app-registration.ts +331 -0
  18. package/src/approval-auth.test.ts +24 -0
  19. package/src/approval-auth.ts +25 -0
  20. package/src/async.test.ts +35 -0
  21. package/src/async.ts +43 -1
  22. package/src/audio-preflight.runtime.ts +9 -0
  23. package/src/bitable.test.ts +131 -0
  24. package/src/bitable.ts +59 -22
  25. package/src/bot-content.ts +474 -0
  26. package/src/bot-group-name.test.ts +108 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.broadcast.test.ts +463 -0
  30. package/src/bot.card-action.test.ts +519 -5
  31. package/src/bot.checkBotMentioned.test.ts +92 -20
  32. package/src/bot.helpers.test.ts +118 -0
  33. package/src/bot.stripBotMention.test.ts +13 -21
  34. package/src/bot.test.ts +1334 -401
  35. package/src/bot.ts +778 -775
  36. package/src/card-action.ts +408 -40
  37. package/src/card-interaction.test.ts +129 -0
  38. package/src/card-interaction.ts +159 -0
  39. package/src/card-test-helpers.ts +47 -0
  40. package/src/card-ux-approval.ts +65 -0
  41. package/src/card-ux-launcher.test.ts +99 -0
  42. package/src/card-ux-launcher.ts +121 -0
  43. package/src/card-ux-shared.ts +33 -0
  44. package/src/channel-runtime-api.ts +16 -0
  45. package/src/channel.runtime.ts +47 -0
  46. package/src/channel.test.ts +914 -3
  47. package/src/channel.ts +1253 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +135 -28
  50. package/src/chat.ts +68 -10
  51. package/src/client.test.ts +212 -103
  52. package/src/client.ts +115 -21
  53. package/src/comment-dispatcher-runtime-api.ts +6 -0
  54. package/src/comment-dispatcher.test.ts +169 -0
  55. package/src/comment-dispatcher.ts +107 -0
  56. package/src/comment-handler-runtime-api.ts +3 -0
  57. package/src/comment-handler.test.ts +486 -0
  58. package/src/comment-handler.ts +309 -0
  59. package/src/comment-reaction.test.ts +166 -0
  60. package/src/comment-reaction.ts +259 -0
  61. package/src/comment-shared.test.ts +182 -0
  62. package/src/comment-shared.ts +406 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +63 -1
  65. package/src/config-schema.ts +31 -4
  66. package/src/conversation-id.test.ts +18 -0
  67. package/src/conversation-id.ts +199 -0
  68. package/src/dedup-runtime-api.ts +1 -0
  69. package/src/dedup.ts +33 -95
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +116 -20
  72. package/src/directory.ts +60 -92
  73. package/src/doc-schema.ts +1 -1
  74. package/src/docx-batch-insert.test.ts +39 -38
  75. package/src/docx-batch-insert.ts +55 -19
  76. package/src/docx-color-text.ts +9 -4
  77. package/src/docx-table-ops.test.ts +53 -0
  78. package/src/docx-table-ops.ts +52 -34
  79. package/src/docx-types.ts +38 -0
  80. package/src/docx.account-selection.test.ts +12 -3
  81. package/src/docx.test.ts +314 -74
  82. package/src/docx.ts +278 -122
  83. package/src/drive-schema.ts +47 -1
  84. package/src/drive.test.ts +1219 -0
  85. package/src/drive.ts +614 -13
  86. package/src/dynamic-agent.ts +10 -4
  87. package/src/event-types.ts +45 -0
  88. package/src/external-keys.ts +1 -1
  89. package/src/lifecycle.test-support.ts +220 -0
  90. package/src/media.test.ts +403 -26
  91. package/src/media.ts +509 -132
  92. package/src/mention-target.types.ts +5 -0
  93. package/src/mention.ts +32 -51
  94. package/src/message-action-contract.ts +13 -0
  95. package/src/monitor-state-runtime-api.ts +7 -0
  96. package/src/monitor-transport-runtime-api.ts +7 -0
  97. package/src/monitor.account.ts +218 -312
  98. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  99. package/src/monitor.bot-identity.ts +86 -0
  100. package/src/monitor.bot-menu-handler.ts +165 -0
  101. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  102. package/src/monitor.bot-menu.test.ts +178 -0
  103. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  104. package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
  105. package/src/monitor.cleanup.test.ts +376 -0
  106. package/src/monitor.comment-notice-handler.ts +105 -0
  107. package/src/monitor.comment.test.ts +937 -0
  108. package/src/monitor.comment.ts +1386 -0
  109. package/src/monitor.lifecycle.test.ts +4 -0
  110. package/src/monitor.message-handler.ts +339 -0
  111. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  112. package/src/monitor.reaction.test.ts +108 -48
  113. package/src/monitor.startup.test.ts +11 -9
  114. package/src/monitor.startup.ts +26 -16
  115. package/src/monitor.state.ts +20 -5
  116. package/src/monitor.synthetic-error.ts +18 -0
  117. package/src/monitor.test-mocks.ts +2 -2
  118. package/src/monitor.transport.ts +220 -60
  119. package/src/monitor.ts +15 -10
  120. package/src/monitor.webhook-e2e.test.ts +65 -7
  121. package/src/monitor.webhook-security.test.ts +122 -0
  122. package/src/monitor.webhook.test-helpers.ts +44 -26
  123. package/src/outbound-runtime-api.ts +1 -0
  124. package/src/outbound.test.ts +616 -37
  125. package/src/outbound.ts +623 -81
  126. package/src/perm-schema.ts +1 -1
  127. package/src/perm.ts +1 -7
  128. package/src/pins.ts +108 -0
  129. package/src/policy.test.ts +297 -117
  130. package/src/policy.ts +142 -29
  131. package/src/post.ts +7 -6
  132. package/src/probe.test.ts +14 -9
  133. package/src/probe.ts +26 -16
  134. package/src/processing-claims.ts +59 -0
  135. package/src/qr-terminal.ts +1 -0
  136. package/src/reactions.ts +4 -34
  137. package/src/reasoning-preview.test.ts +59 -0
  138. package/src/reasoning-preview.ts +20 -0
  139. package/src/reply-dispatcher-runtime-api.ts +7 -0
  140. package/src/reply-dispatcher.test.ts +660 -29
  141. package/src/reply-dispatcher.ts +407 -154
  142. package/src/runtime.ts +6 -3
  143. package/src/secret-contract.ts +145 -0
  144. package/src/secret-input.ts +1 -13
  145. package/src/security-audit-shared.ts +69 -0
  146. package/src/security-audit.test.ts +61 -0
  147. package/src/security-audit.ts +1 -0
  148. package/src/send-result.ts +1 -1
  149. package/src/send-target.test.ts +9 -3
  150. package/src/send-target.ts +10 -4
  151. package/src/send.reply-fallback.test.ts +105 -2
  152. package/src/send.test.ts +386 -4
  153. package/src/send.ts +414 -95
  154. package/src/sequential-key.test.ts +72 -0
  155. package/src/sequential-key.ts +28 -0
  156. package/src/sequential-queue.test.ts +92 -0
  157. package/src/sequential-queue.ts +16 -0
  158. package/src/session-conversation.ts +42 -0
  159. package/src/session-route.ts +48 -0
  160. package/src/setup-core.ts +51 -0
  161. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  162. package/src/setup-surface.ts +581 -0
  163. package/src/streaming-card.test.ts +138 -2
  164. package/src/streaming-card.ts +134 -18
  165. package/src/subagent-hooks.test.ts +603 -0
  166. package/src/subagent-hooks.ts +397 -0
  167. package/src/targets.ts +3 -13
  168. package/src/test-support/lifecycle-test-support.ts +453 -0
  169. package/src/thread-bindings.test.ts +143 -0
  170. package/src/thread-bindings.ts +330 -0
  171. package/src/tool-account-routing.test.ts +66 -8
  172. package/src/tool-account.test.ts +44 -0
  173. package/src/tool-account.ts +40 -17
  174. package/src/tool-factory-test-harness.ts +11 -8
  175. package/src/tool-result.ts +3 -1
  176. package/src/tools-config.ts +1 -1
  177. package/src/types.ts +16 -15
  178. package/src/typing.ts +10 -6
  179. package/src/wiki-schema.ts +1 -1
  180. package/src/wiki.ts +1 -7
  181. package/subagent-hooks-api.ts +31 -0
  182. package/tsconfig.json +16 -0
  183. package/src/feishu-command-handler.ts +0 -59
  184. package/src/onboarding.status.test.ts +0 -25
  185. package/src/onboarding.ts +0 -489
  186. package/src/send-message.ts +0 -71
  187. package/src/targets.test.ts +0 -70
@@ -0,0 +1,309 @@
1
+ import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
2
+ import { resolveOpenDmAllowlistAccess } from "openclaw/plugin-sdk/security-runtime";
3
+ import { resolveFeishuRuntimeAccount } from "./accounts.js";
4
+ import { createFeishuClient } from "./client.js";
5
+ import { createFeishuCommentReplyDispatcher } from "./comment-dispatcher.js";
6
+ import {
7
+ createChannelPairingController,
8
+ type ClawdbotConfig,
9
+ type RuntimeEnv,
10
+ } from "./comment-handler-runtime-api.js";
11
+ import { buildFeishuCommentTarget } from "./comment-target.js";
12
+ import { deliverCommentThreadText } from "./drive.js";
13
+ import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
14
+ import {
15
+ resolveDriveCommentEventTurn,
16
+ type FeishuDriveCommentNoticeEvent,
17
+ } from "./monitor.comment.js";
18
+ import { resolveFeishuAllowlistMatch } from "./policy.js";
19
+ import { getFeishuRuntime } from "./runtime.js";
20
+ import type { DynamicAgentCreationConfig } from "./types.js";
21
+
22
+ type HandleFeishuCommentEventParams = {
23
+ cfg: ClawdbotConfig;
24
+ accountId: string;
25
+ runtime?: RuntimeEnv;
26
+ event: FeishuDriveCommentNoticeEvent;
27
+ botOpenId?: string;
28
+ };
29
+
30
+ function buildCommentSessionKey(params: {
31
+ core: ReturnType<typeof getFeishuRuntime>;
32
+ route: ResolvedAgentRoute;
33
+ fileType: string;
34
+ fileToken: string;
35
+ }): string {
36
+ return params.core.channel.routing.buildAgentSessionKey({
37
+ agentId: params.route.agentId,
38
+ channel: "feishu",
39
+ accountId: params.route.accountId,
40
+ peer: {
41
+ kind: "direct",
42
+ id: `comment-doc:${params.fileType}:${params.fileToken}`,
43
+ },
44
+ dmScope: "per-account-channel-peer",
45
+ });
46
+ }
47
+
48
+ function parseTimestampMs(value: string | undefined): number {
49
+ const parsed = value ? Number.parseInt(value, 10) : Number.NaN;
50
+ return Number.isFinite(parsed) ? parsed : Date.now();
51
+ }
52
+
53
+ export async function handleFeishuCommentEvent(
54
+ params: HandleFeishuCommentEventParams,
55
+ ): Promise<void> {
56
+ const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId });
57
+ const feishuCfg = account.config;
58
+ const core = getFeishuRuntime();
59
+ const log = params.runtime?.log ?? console.log;
60
+ const error = params.runtime?.error ?? console.error;
61
+ const runtime = (params.runtime ?? { log, error }) as RuntimeEnv;
62
+
63
+ const turn = await resolveDriveCommentEventTurn({
64
+ cfg: params.cfg,
65
+ accountId: account.accountId,
66
+ event: params.event,
67
+ botOpenId: params.botOpenId,
68
+ logger: log,
69
+ });
70
+ if (!turn) {
71
+ log(
72
+ `feishu[${account.accountId}]: drive comment notice skipped ` +
73
+ `event=${params.event.event_id ?? "unknown"} comment=${params.event.comment_id ?? "unknown"}`,
74
+ );
75
+ return;
76
+ }
77
+
78
+ const commentTarget = buildFeishuCommentTarget({
79
+ fileType: turn.fileType,
80
+ fileToken: turn.fileToken,
81
+ commentId: turn.commentId,
82
+ });
83
+ const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
84
+ const configAllowFrom = feishuCfg?.allowFrom ?? [];
85
+ const pairing = createChannelPairingController({
86
+ core,
87
+ channel: "feishu",
88
+ accountId: account.accountId,
89
+ });
90
+ const storeAllowFrom =
91
+ dmPolicy !== "allowlist" && dmPolicy !== "open"
92
+ ? await pairing.readAllowFromStore().catch(() => [])
93
+ : [];
94
+ const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
95
+ const senderAllowed = resolveFeishuAllowlistMatch({
96
+ allowFrom: effectiveDmAllowFrom,
97
+ senderId: turn.senderId,
98
+ senderIds: [turn.senderUserId],
99
+ }).allowed;
100
+ const dmAccessAllowed =
101
+ dmPolicy === "open"
102
+ ? resolveOpenDmAllowlistAccess({
103
+ effectiveAllowFrom: effectiveDmAllowFrom,
104
+ isSenderAllowed: (allowFrom) =>
105
+ resolveFeishuAllowlistMatch({
106
+ allowFrom,
107
+ senderId: turn.senderId,
108
+ senderIds: [turn.senderUserId],
109
+ }).allowed,
110
+ }).decision === "allow"
111
+ : senderAllowed;
112
+ if (!dmAccessAllowed) {
113
+ if (dmPolicy === "pairing") {
114
+ const client = createFeishuClient(account);
115
+ await pairing.issueChallenge({
116
+ senderId: turn.senderId,
117
+ senderIdLine: `Your Feishu user id: ${turn.senderId}`,
118
+ meta: { name: turn.senderId },
119
+ onCreated: ({ code }) => {
120
+ log(
121
+ `feishu[${account.accountId}]: comment pairing request sender=${turn.senderId} code=${code}`,
122
+ );
123
+ },
124
+ sendPairingReply: async (text) => {
125
+ await deliverCommentThreadText(client, {
126
+ file_token: turn.fileToken,
127
+ file_type: turn.fileType,
128
+ comment_id: turn.commentId,
129
+ content: text,
130
+ is_whole_comment: turn.isWholeComment,
131
+ });
132
+ },
133
+ onReplyError: (err) => {
134
+ log(
135
+ `feishu[${account.accountId}]: comment pairing reply failed for ${turn.senderId}: ${String(err)}`,
136
+ );
137
+ },
138
+ });
139
+ } else {
140
+ log(
141
+ `feishu[${account.accountId}]: blocked unauthorized comment sender ${turn.senderId} ` +
142
+ `(dmPolicy=${dmPolicy}, comment=${turn.commentId})`,
143
+ );
144
+ }
145
+ return;
146
+ }
147
+
148
+ let effectiveCfg = params.cfg;
149
+ let route = core.channel.routing.resolveAgentRoute({
150
+ cfg: params.cfg,
151
+ channel: "feishu",
152
+ accountId: account.accountId,
153
+ peer: {
154
+ kind: "direct",
155
+ id: turn.senderId,
156
+ },
157
+ });
158
+ if (route.matchedBy === "default") {
159
+ const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
160
+ if (dynamicCfg?.enabled) {
161
+ const dynamicResult = await maybeCreateDynamicAgent({
162
+ cfg: params.cfg,
163
+ runtime: core,
164
+ senderOpenId: turn.senderId,
165
+ dynamicCfg,
166
+ log: (message) => log(message),
167
+ });
168
+ if (dynamicResult.created) {
169
+ effectiveCfg = dynamicResult.updatedCfg;
170
+ route = core.channel.routing.resolveAgentRoute({
171
+ cfg: dynamicResult.updatedCfg,
172
+ channel: "feishu",
173
+ accountId: account.accountId,
174
+ peer: {
175
+ kind: "direct",
176
+ id: turn.senderId,
177
+ },
178
+ });
179
+ log(
180
+ `feishu[${account.accountId}]: dynamic agent created for comment flow, route=${route.sessionKey}`,
181
+ );
182
+ }
183
+ }
184
+ }
185
+
186
+ const commentSessionKey = buildCommentSessionKey({
187
+ core,
188
+ route,
189
+ fileType: turn.fileType,
190
+ fileToken: turn.fileToken,
191
+ });
192
+ const bodyForAgent = `[message_id: ${turn.messageId}]\n${turn.prompt}`;
193
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
194
+ Body: bodyForAgent,
195
+ BodyForAgent: bodyForAgent,
196
+ RawBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt,
197
+ CommandBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt,
198
+ From: `feishu:${turn.senderId}`,
199
+ To: commentTarget,
200
+ SessionKey: commentSessionKey,
201
+ AccountId: route.accountId,
202
+ ChatType: "direct",
203
+ ConversationLabel: turn.documentTitle
204
+ ? `Feishu comment · ${turn.documentTitle}`
205
+ : "Feishu comment",
206
+ SenderName: turn.senderId,
207
+ SenderId: turn.senderId,
208
+ Provider: "feishu",
209
+ Surface: "feishu-comment",
210
+ MessageSid: turn.messageId,
211
+ // For Feishu comment turns, MessageThreadId carries the inbound reply_id so
212
+ // comment-aware tools can clean typing reaction before sending visible output.
213
+ MessageThreadId: turn.replyId,
214
+ Timestamp: parseTimestampMs(turn.timestamp),
215
+ WasMentioned: turn.isMentioned,
216
+ CommandAuthorized: false,
217
+ OriginatingChannel: "feishu",
218
+ OriginatingTo: commentTarget,
219
+ });
220
+
221
+ const storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, {
222
+ agentId: route.agentId,
223
+ });
224
+
225
+ const { dispatcher, replyOptions, markDispatchIdle, markRunComplete, cleanupTypingReaction } =
226
+ createFeishuCommentReplyDispatcher({
227
+ cfg: effectiveCfg,
228
+ agentId: route.agentId,
229
+ runtime,
230
+ accountId: account.accountId,
231
+ fileToken: turn.fileToken,
232
+ fileType: turn.fileType,
233
+ commentId: turn.commentId,
234
+ replyId: turn.replyId,
235
+ isWholeComment: turn.isWholeComment,
236
+ });
237
+
238
+ let dispatchSettledBeforeStart = false;
239
+ try {
240
+ log(
241
+ `feishu[${account.accountId}]: dispatching drive comment to agent ` +
242
+ `(session=${commentSessionKey} comment=${turn.commentId} type=${turn.noticeType})`,
243
+ );
244
+ const turnResult = await core.channel.turn.run({
245
+ channel: "feishu",
246
+ accountId: route.accountId,
247
+ raw: turn,
248
+ adapter: {
249
+ ingest: () => ({
250
+ id: turn.messageId,
251
+ timestamp: parseTimestampMs(turn.timestamp),
252
+ rawText: ctxPayload.RawBody ?? "",
253
+ textForAgent: ctxPayload.BodyForAgent,
254
+ textForCommands: ctxPayload.CommandBody,
255
+ raw: turn,
256
+ }),
257
+ resolveTurn: () => ({
258
+ channel: "feishu",
259
+ accountId: route.accountId,
260
+ routeSessionKey: commentSessionKey,
261
+ storePath,
262
+ ctxPayload,
263
+ recordInboundSession: core.channel.session.recordInboundSession,
264
+ record: {
265
+ onRecordError: (err) => {
266
+ error(
267
+ `feishu[${account.accountId}]: failed to record comment inbound session ${commentSessionKey}: ${String(err)}`,
268
+ );
269
+ },
270
+ },
271
+ onPreDispatchFailure: async () => {
272
+ dispatchSettledBeforeStart = true;
273
+ await core.channel.reply.settleReplyDispatcher({
274
+ dispatcher,
275
+ onSettled: () => {
276
+ markRunComplete();
277
+ markDispatchIdle();
278
+ },
279
+ });
280
+ },
281
+ runDispatch: () =>
282
+ core.channel.reply.withReplyDispatcher({
283
+ dispatcher,
284
+ run: () =>
285
+ core.channel.reply.dispatchReplyFromConfig({
286
+ ctx: ctxPayload,
287
+ cfg: effectiveCfg,
288
+ dispatcher,
289
+ replyOptions,
290
+ }),
291
+ }),
292
+ }),
293
+ },
294
+ });
295
+ const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
296
+ const queuedFinal = dispatchResult?.queuedFinal ?? false;
297
+ const counts = dispatchResult?.counts ?? { tool: 0, block: 0, final: 0 };
298
+ log(
299
+ `feishu[${account.accountId}]: drive comment dispatch complete ` +
300
+ `(queuedFinal=${queuedFinal}, replies=${counts.final}, session=${commentSessionKey})`,
301
+ );
302
+ } finally {
303
+ if (!dispatchSettledBeforeStart) {
304
+ markRunComplete();
305
+ markDispatchIdle();
306
+ }
307
+ void cleanupTypingReaction();
308
+ }
309
+ }
@@ -0,0 +1,166 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { ClawdbotConfig } from "../runtime-api.js";
3
+ import {
4
+ cleanupAmbientCommentTypingReaction,
5
+ createCommentTypingReactionLifecycle,
6
+ } from "./comment-reaction.js";
7
+
8
+ const resolveFeishuRuntimeAccountMock = vi.hoisted(() => vi.fn());
9
+ const createFeishuClientMock = vi.hoisted(() => vi.fn());
10
+
11
+ vi.mock("./accounts.js", () => ({
12
+ resolveFeishuRuntimeAccount: resolveFeishuRuntimeAccountMock,
13
+ }));
14
+
15
+ vi.mock("./client.js", () => ({
16
+ createFeishuClient: createFeishuClientMock,
17
+ }));
18
+
19
+ describe("createCommentTypingReactionLifecycle", () => {
20
+ const request = vi.fn();
21
+
22
+ beforeEach(() => {
23
+ vi.clearAllMocks();
24
+ resolveFeishuRuntimeAccountMock.mockReturnValue({
25
+ accountId: "default",
26
+ configured: true,
27
+ config: {
28
+ typingIndicator: true,
29
+ },
30
+ });
31
+ createFeishuClientMock.mockReturnValue({
32
+ request,
33
+ });
34
+ request.mockResolvedValue({
35
+ code: 0,
36
+ data: {},
37
+ });
38
+ });
39
+
40
+ function createTypingReactionLifecycle(...args: [replyId?: string]) {
41
+ return createCommentTypingReactionLifecycle({
42
+ cfg: {} as ClawdbotConfig,
43
+ fileToken: "doc_token_1",
44
+ fileType: "docx",
45
+ replyId: args.length === 0 ? "reply_1" : args[0],
46
+ runtime: {
47
+ log: vi.fn(),
48
+ } as never,
49
+ });
50
+ }
51
+
52
+ const cleanupAmbientReply = () =>
53
+ cleanupAmbientCommentTypingReaction({
54
+ client: { request } as never,
55
+ deliveryContext: {
56
+ channel: "feishu",
57
+ to: "comment:docx:doc_token_1:comment_1",
58
+ threadId: "reply_1",
59
+ },
60
+ });
61
+
62
+ it("adds and removes a comment typing reaction using reply_id", async () => {
63
+ const lifecycle = createTypingReactionLifecycle();
64
+
65
+ await lifecycle.start();
66
+ await lifecycle.cleanup();
67
+
68
+ expect(request).toHaveBeenNthCalledWith(
69
+ 1,
70
+ expect.objectContaining({
71
+ method: "POST",
72
+ url: "/open-apis/drive/v2/files/doc_token_1/comments/reaction?file_type=docx",
73
+ data: {
74
+ action: "add",
75
+ reply_id: "reply_1",
76
+ reaction_type: "Typing",
77
+ },
78
+ }),
79
+ );
80
+ expect(request).toHaveBeenNthCalledWith(
81
+ 2,
82
+ expect.objectContaining({
83
+ method: "POST",
84
+ url: "/open-apis/drive/v2/files/doc_token_1/comments/reaction?file_type=docx",
85
+ data: {
86
+ action: "delete",
87
+ reply_id: "reply_1",
88
+ reaction_type: "Typing",
89
+ },
90
+ }),
91
+ );
92
+ });
93
+
94
+ it("skips requests when reply_id is missing", async () => {
95
+ const lifecycle = createTypingReactionLifecycle(undefined);
96
+
97
+ await lifecycle.start();
98
+ await lifecycle.cleanup();
99
+
100
+ expect(request).not.toHaveBeenCalled();
101
+ });
102
+
103
+ it("shares cleanup state so ambient cleanup and finally cleanup do not delete twice", async () => {
104
+ const lifecycle = createTypingReactionLifecycle();
105
+
106
+ await lifecycle.start();
107
+ await cleanupAmbientReply();
108
+ await lifecycle.cleanup();
109
+
110
+ expect(request).toHaveBeenCalledTimes(2);
111
+ expect(request).toHaveBeenNthCalledWith(
112
+ 2,
113
+ expect.objectContaining({
114
+ data: {
115
+ action: "delete",
116
+ reply_id: "reply_1",
117
+ reaction_type: "Typing",
118
+ },
119
+ }),
120
+ );
121
+ });
122
+
123
+ it("retries delete during later cleanup after an ambient delete failure", async () => {
124
+ request
125
+ .mockResolvedValueOnce({
126
+ code: 0,
127
+ data: {},
128
+ })
129
+ .mockResolvedValueOnce({
130
+ code: 5001,
131
+ msg: "temporary failure",
132
+ })
133
+ .mockResolvedValueOnce({
134
+ code: 0,
135
+ data: {},
136
+ });
137
+
138
+ const lifecycle = createTypingReactionLifecycle();
139
+
140
+ await lifecycle.start();
141
+ await cleanupAmbientReply();
142
+ await lifecycle.cleanup();
143
+
144
+ expect(request).toHaveBeenCalledTimes(3);
145
+ expect(request).toHaveBeenNthCalledWith(
146
+ 2,
147
+ expect.objectContaining({
148
+ data: {
149
+ action: "delete",
150
+ reply_id: "reply_1",
151
+ reaction_type: "Typing",
152
+ },
153
+ }),
154
+ );
155
+ expect(request).toHaveBeenNthCalledWith(
156
+ 3,
157
+ expect.objectContaining({
158
+ data: {
159
+ action: "delete",
160
+ reply_id: "reply_1",
161
+ reaction_type: "Typing",
162
+ },
163
+ }),
164
+ );
165
+ });
166
+ });