@openclaw/feishu 2026.3.12 → 2026.5.1-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.
- package/api.ts +31 -0
- package/channel-entry.ts +20 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +16 -0
- package/index.ts +70 -53
- package/openclaw.plugin.json +1653 -4
- package/package.json +32 -7
- package/runtime-api.ts +55 -0
- package/secret-contract-api.ts +5 -0
- package/security-contract-api.ts +1 -0
- package/session-key-api.ts +1 -0
- package/setup-api.ts +3 -0
- package/setup-entry.test.ts +14 -0
- package/setup-entry.ts +13 -0
- package/src/accounts.test.ts +115 -22
- package/src/accounts.ts +199 -117
- package/src/app-registration.ts +331 -0
- package/src/approval-auth.test.ts +24 -0
- package/src/approval-auth.ts +25 -0
- package/src/async.test.ts +35 -0
- package/src/async.ts +43 -1
- package/src/audio-preflight.runtime.ts +9 -0
- package/src/bitable.test.ts +131 -0
- package/src/bitable.ts +59 -22
- package/src/bot-content.ts +474 -0
- package/src/bot-group-name.test.ts +108 -0
- package/src/bot-runtime-api.ts +12 -0
- package/src/bot-sender-name.ts +125 -0
- package/src/bot.broadcast.test.ts +463 -0
- package/src/bot.card-action.test.ts +519 -5
- package/src/bot.checkBotMentioned.test.ts +92 -20
- package/src/bot.helpers.test.ts +118 -0
- package/src/bot.stripBotMention.test.ts +13 -21
- package/src/bot.test.ts +1334 -401
- package/src/bot.ts +798 -786
- package/src/card-action.ts +408 -40
- package/src/card-interaction.test.ts +129 -0
- package/src/card-interaction.ts +159 -0
- package/src/card-test-helpers.ts +47 -0
- package/src/card-ux-approval.ts +65 -0
- package/src/card-ux-launcher.test.ts +99 -0
- package/src/card-ux-launcher.ts +121 -0
- package/src/card-ux-shared.ts +33 -0
- package/src/channel-runtime-api.ts +16 -0
- package/src/channel.runtime.ts +47 -0
- package/src/channel.test.ts +914 -3
- package/src/channel.ts +1252 -309
- package/src/chat-schema.ts +5 -4
- package/src/chat.test.ts +84 -28
- package/src/chat.ts +68 -10
- package/src/client.test.ts +212 -103
- package/src/client.ts +115 -21
- package/src/comment-dispatcher-runtime-api.ts +6 -0
- package/src/comment-dispatcher.test.ts +169 -0
- package/src/comment-dispatcher.ts +107 -0
- package/src/comment-handler-runtime-api.ts +3 -0
- package/src/comment-handler.test.ts +486 -0
- package/src/comment-handler.ts +309 -0
- package/src/comment-reaction.test.ts +166 -0
- package/src/comment-reaction.ts +259 -0
- package/src/comment-shared.test.ts +182 -0
- package/src/comment-shared.ts +365 -0
- package/src/comment-target.ts +44 -0
- package/src/config-schema.test.ts +77 -25
- package/src/config-schema.ts +31 -4
- package/src/conversation-id.test.ts +18 -0
- package/src/conversation-id.ts +199 -0
- package/src/dedup-runtime-api.ts +1 -0
- package/src/dedup.ts +76 -35
- package/src/directory.static.ts +61 -0
- package/src/directory.test.ts +119 -20
- package/src/directory.ts +61 -91
- package/src/doc-schema.ts +1 -1
- package/src/docx-batch-insert.test.ts +39 -38
- package/src/docx-batch-insert.ts +55 -19
- package/src/docx-color-text.ts +9 -4
- package/src/docx-table-ops.test.ts +53 -0
- package/src/docx-table-ops.ts +52 -34
- package/src/docx-types.ts +38 -0
- package/src/docx.account-selection.test.ts +12 -3
- package/src/docx.test.ts +314 -74
- package/src/docx.ts +278 -122
- package/src/drive-schema.ts +47 -1
- package/src/drive.test.ts +1219 -0
- package/src/drive.ts +614 -13
- package/src/dynamic-agent.ts +10 -4
- package/src/event-types.ts +45 -0
- package/src/external-keys.ts +1 -1
- package/src/lifecycle.test-support.ts +220 -0
- package/src/media.test.ts +413 -87
- package/src/media.ts +488 -154
- package/src/mention-target.types.ts +5 -0
- package/src/mention.ts +32 -51
- package/src/message-action-contract.ts +13 -0
- package/src/monitor-state-runtime-api.ts +7 -0
- package/src/monitor-transport-runtime-api.ts +7 -0
- package/src/monitor.account.ts +220 -313
- package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
- package/src/monitor.bot-identity.ts +86 -0
- package/src/monitor.bot-menu-handler.ts +165 -0
- package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
- package/src/monitor.bot-menu.test.ts +178 -0
- package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
- package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
- package/src/monitor.cleanup.test.ts +376 -0
- package/src/monitor.comment-notice-handler.ts +105 -0
- package/src/monitor.comment.test.ts +937 -0
- package/src/monitor.comment.ts +1386 -0
- package/src/monitor.lifecycle.test.ts +4 -0
- package/src/monitor.message-handler.ts +339 -0
- package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
- package/src/monitor.reaction.test.ts +194 -92
- package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
- package/src/monitor.startup.test.ts +24 -36
- package/src/monitor.startup.ts +26 -16
- package/src/monitor.state.ts +20 -5
- package/src/monitor.synthetic-error.ts +18 -0
- package/src/monitor.test-mocks.ts +2 -2
- package/src/monitor.transport.ts +297 -39
- package/src/monitor.ts +15 -10
- package/src/monitor.webhook-e2e.test.ts +272 -0
- package/src/monitor.webhook-security.test.ts +125 -91
- package/src/monitor.webhook.test-helpers.ts +116 -0
- package/src/outbound-runtime-api.ts +1 -0
- package/src/outbound.test.ts +627 -53
- package/src/outbound.ts +623 -81
- package/src/perm-schema.ts +1 -1
- package/src/perm.ts +1 -7
- package/src/pins.ts +108 -0
- package/src/policy.test.ts +297 -117
- package/src/policy.ts +142 -29
- package/src/post.ts +7 -6
- package/src/probe.test.ts +122 -118
- package/src/probe.ts +26 -16
- package/src/processing-claims.ts +59 -0
- package/src/qr-terminal.ts +1 -0
- package/src/reactions.ts +23 -60
- package/src/reasoning-preview.test.ts +59 -0
- package/src/reasoning-preview.ts +20 -0
- package/src/reply-dispatcher-runtime-api.ts +7 -0
- package/src/reply-dispatcher.test.ts +721 -168
- package/src/reply-dispatcher.ts +422 -172
- package/src/runtime.ts +6 -3
- package/src/secret-contract.ts +145 -0
- package/src/secret-input.ts +1 -13
- package/src/security-audit-shared.ts +69 -0
- package/src/security-audit.test.ts +61 -0
- package/src/security-audit.ts +1 -0
- package/src/send-result.ts +1 -1
- package/src/send-target.test.ts +9 -3
- package/src/send-target.ts +10 -4
- package/src/send.reply-fallback.test.ts +127 -42
- package/src/send.test.ts +386 -4
- package/src/send.ts +486 -164
- package/src/sequential-key.test.ts +72 -0
- package/src/sequential-key.ts +28 -0
- package/src/sequential-queue.test.ts +92 -0
- package/src/sequential-queue.ts +16 -0
- package/src/session-conversation.ts +42 -0
- package/src/session-route.ts +48 -0
- package/src/setup-core.ts +51 -0
- package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
- package/src/setup-surface.ts +581 -0
- package/src/streaming-card.test.ts +138 -2
- package/src/streaming-card.ts +134 -18
- package/src/subagent-hooks.test.ts +603 -0
- package/src/subagent-hooks.ts +397 -0
- package/src/targets.ts +3 -13
- package/src/test-support/lifecycle-test-support.ts +479 -0
- package/src/thread-bindings.test.ts +143 -0
- package/src/thread-bindings.ts +330 -0
- package/src/tool-account-routing.test.ts +66 -8
- package/src/tool-account.test.ts +44 -0
- package/src/tool-account.ts +40 -17
- package/src/tool-factory-test-harness.ts +11 -8
- package/src/tool-result.ts +3 -1
- package/src/tools-config.ts +1 -1
- package/src/types.ts +16 -15
- package/src/typing.ts +10 -6
- package/src/wiki-schema.ts +1 -1
- package/src/wiki.ts +1 -7
- package/subagent-hooks-api.ts +31 -0
- package/tsconfig.json +16 -0
- package/src/feishu-command-handler.ts +0 -59
- package/src/onboarding.status.test.ts +0 -25
- package/src/onboarding.ts +0 -489
- package/src/send-message.ts +0 -71
- package/src/targets.test.ts +0 -70
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { botNames, botOpenIds, stopFeishuMonitorState, wsClients } from "./monitor.state.js";
|
|
3
|
+
import type { ResolvedFeishuAccount } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const createFeishuWSClientMock = vi.hoisted(() => vi.fn());
|
|
6
|
+
|
|
7
|
+
vi.mock("./client.js", () => ({
|
|
8
|
+
createFeishuWSClient: createFeishuWSClientMock,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import { monitorWebSocket } from "./monitor.transport.js";
|
|
12
|
+
|
|
13
|
+
type MockWsClient = {
|
|
14
|
+
start: ReturnType<typeof vi.fn>;
|
|
15
|
+
close: ReturnType<typeof vi.fn>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function createAccount(accountId: string): ResolvedFeishuAccount {
|
|
19
|
+
return {
|
|
20
|
+
accountId,
|
|
21
|
+
enabled: true,
|
|
22
|
+
configured: true,
|
|
23
|
+
appId: `cli_${accountId}`,
|
|
24
|
+
appSecret: `secret_${accountId}`, // pragma: allowlist secret
|
|
25
|
+
domain: "feishu",
|
|
26
|
+
config: {
|
|
27
|
+
enabled: true,
|
|
28
|
+
connectionMode: "websocket",
|
|
29
|
+
},
|
|
30
|
+
} as ResolvedFeishuAccount;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createWsClient(): MockWsClient {
|
|
34
|
+
return {
|
|
35
|
+
start: vi.fn(),
|
|
36
|
+
close: vi.fn(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
vi.useRealTimers();
|
|
42
|
+
stopFeishuMonitorState();
|
|
43
|
+
vi.clearAllMocks();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("feishu websocket cleanup", () => {
|
|
47
|
+
it("closes the websocket client when the monitor aborts", async () => {
|
|
48
|
+
const wsClient = createWsClient();
|
|
49
|
+
createFeishuWSClientMock.mockReturnValue(wsClient);
|
|
50
|
+
|
|
51
|
+
const abortController = new AbortController();
|
|
52
|
+
const accountId = "alpha";
|
|
53
|
+
|
|
54
|
+
botOpenIds.set(accountId, "ou_alpha");
|
|
55
|
+
botNames.set(accountId, "Alpha");
|
|
56
|
+
|
|
57
|
+
const monitorPromise = monitorWebSocket({
|
|
58
|
+
account: createAccount(accountId),
|
|
59
|
+
accountId,
|
|
60
|
+
runtime: {
|
|
61
|
+
log: vi.fn(),
|
|
62
|
+
error: vi.fn(),
|
|
63
|
+
exit: vi.fn(),
|
|
64
|
+
},
|
|
65
|
+
abortSignal: abortController.signal,
|
|
66
|
+
eventDispatcher: {} as never,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await vi.waitFor(() => {
|
|
70
|
+
expect(wsClient.start).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(wsClients.get(accountId)).toBe(wsClient);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
abortController.abort();
|
|
75
|
+
await monitorPromise;
|
|
76
|
+
|
|
77
|
+
expect(wsClient.close).toHaveBeenCalledTimes(1);
|
|
78
|
+
expect(wsClients.has(accountId)).toBe(false);
|
|
79
|
+
expect(botOpenIds.has(accountId)).toBe(false);
|
|
80
|
+
expect(botNames.has(accountId)).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("retries with backoff after websocket start rejects", async () => {
|
|
84
|
+
vi.useFakeTimers();
|
|
85
|
+
const failedClient = createWsClient();
|
|
86
|
+
failedClient.start.mockRejectedValueOnce(
|
|
87
|
+
new Error("connect failed\nAuthorization: Bearer token_abc appSecret=secret_abc"),
|
|
88
|
+
);
|
|
89
|
+
const recoveredClient = createWsClient();
|
|
90
|
+
createFeishuWSClientMock
|
|
91
|
+
.mockResolvedValueOnce(failedClient)
|
|
92
|
+
.mockResolvedValueOnce(recoveredClient);
|
|
93
|
+
|
|
94
|
+
const abortController = new AbortController();
|
|
95
|
+
const runtime = {
|
|
96
|
+
log: vi.fn(),
|
|
97
|
+
error: vi.fn(),
|
|
98
|
+
exit: vi.fn(),
|
|
99
|
+
};
|
|
100
|
+
const accountId = "retry";
|
|
101
|
+
|
|
102
|
+
const monitorPromise = monitorWebSocket({
|
|
103
|
+
account: createAccount(accountId),
|
|
104
|
+
accountId,
|
|
105
|
+
runtime,
|
|
106
|
+
abortSignal: abortController.signal,
|
|
107
|
+
eventDispatcher: {} as never,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await vi.waitFor(() => {
|
|
111
|
+
expect(failedClient.start).toHaveBeenCalledTimes(1);
|
|
112
|
+
expect(failedClient.close).toHaveBeenCalledTimes(1);
|
|
113
|
+
expect(wsClients.has(accountId)).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
117
|
+
|
|
118
|
+
await vi.waitFor(() => {
|
|
119
|
+
expect(recoveredClient.start).toHaveBeenCalledTimes(1);
|
|
120
|
+
expect(wsClients.get(accountId)).toBe(recoveredClient);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
abortController.abort();
|
|
124
|
+
await monitorPromise;
|
|
125
|
+
|
|
126
|
+
expect(createFeishuWSClientMock).toHaveBeenCalledTimes(2);
|
|
127
|
+
expect(recoveredClient.close).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(runtime.error).toHaveBeenCalledWith(
|
|
129
|
+
expect.stringContaining("WebSocket start failed, retrying in 1000ms"),
|
|
130
|
+
);
|
|
131
|
+
const errorMessage = String(runtime.error.mock.calls[0]?.[0] ?? "");
|
|
132
|
+
expect(errorMessage).not.toContain("\n");
|
|
133
|
+
expect(errorMessage).not.toContain("token_abc");
|
|
134
|
+
expect(errorMessage).not.toContain("secret_abc");
|
|
135
|
+
expect(errorMessage).toContain("Authorization: Bearer [redacted]");
|
|
136
|
+
expect(errorMessage).toContain("appSecret=[redacted]");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("recreates the websocket client after sdk reconnect exhaustion", async () => {
|
|
140
|
+
vi.useFakeTimers();
|
|
141
|
+
const exhaustedClient = createWsClient();
|
|
142
|
+
const recoveredClient = createWsClient();
|
|
143
|
+
createFeishuWSClientMock
|
|
144
|
+
.mockResolvedValueOnce(exhaustedClient)
|
|
145
|
+
.mockResolvedValueOnce(recoveredClient);
|
|
146
|
+
|
|
147
|
+
const abortController = new AbortController();
|
|
148
|
+
const runtime = {
|
|
149
|
+
log: vi.fn(),
|
|
150
|
+
error: vi.fn(),
|
|
151
|
+
exit: vi.fn(),
|
|
152
|
+
};
|
|
153
|
+
const accountId = "exhausted";
|
|
154
|
+
botOpenIds.set(accountId, "ou_exhausted");
|
|
155
|
+
botNames.set(accountId, "Exhausted");
|
|
156
|
+
|
|
157
|
+
const monitorPromise = monitorWebSocket({
|
|
158
|
+
account: createAccount(accountId),
|
|
159
|
+
accountId,
|
|
160
|
+
runtime,
|
|
161
|
+
abortSignal: abortController.signal,
|
|
162
|
+
eventDispatcher: {} as never,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await vi.waitFor(() => {
|
|
166
|
+
expect(exhaustedClient.start).toHaveBeenCalledTimes(1);
|
|
167
|
+
expect(wsClients.get(accountId)).toBe(exhaustedClient);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const callbacks = createFeishuWSClientMock.mock.calls[0]?.[1] as
|
|
171
|
+
| { onError?: (err: Error) => void }
|
|
172
|
+
| undefined;
|
|
173
|
+
callbacks?.onError?.(
|
|
174
|
+
new Error("WebSocket reconnect exhausted after 3 attempts\nBearer token_abc"),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
await vi.waitFor(() => {
|
|
178
|
+
expect(exhaustedClient.close).toHaveBeenCalledTimes(1);
|
|
179
|
+
expect(wsClients.has(accountId)).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
expect(botOpenIds.get(accountId)).toBe("ou_exhausted");
|
|
182
|
+
expect(botNames.get(accountId)).toBe("Exhausted");
|
|
183
|
+
|
|
184
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
185
|
+
|
|
186
|
+
await vi.waitFor(() => {
|
|
187
|
+
expect(recoveredClient.start).toHaveBeenCalledTimes(1);
|
|
188
|
+
expect(wsClients.get(accountId)).toBe(recoveredClient);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
abortController.abort();
|
|
192
|
+
await monitorPromise;
|
|
193
|
+
|
|
194
|
+
expect(createFeishuWSClientMock).toHaveBeenCalledTimes(2);
|
|
195
|
+
expect(recoveredClient.close).toHaveBeenCalledTimes(1);
|
|
196
|
+
expect(botOpenIds.has(accountId)).toBe(false);
|
|
197
|
+
expect(botNames.has(accountId)).toBe(false);
|
|
198
|
+
const errorMessage = String(runtime.error.mock.calls[0]?.[0] ?? "");
|
|
199
|
+
expect(errorMessage).toContain("WebSocket connection ended, recreating client in 1000ms");
|
|
200
|
+
expect(errorMessage).toContain("Bearer [redacted]");
|
|
201
|
+
expect(errorMessage).not.toContain("\n");
|
|
202
|
+
expect(errorMessage).not.toContain("token_abc");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("keeps the websocket client alive after recoverable sdk callback errors", async () => {
|
|
206
|
+
vi.useFakeTimers();
|
|
207
|
+
const wsClient = createWsClient();
|
|
208
|
+
createFeishuWSClientMock.mockResolvedValueOnce(wsClient);
|
|
209
|
+
|
|
210
|
+
const abortController = new AbortController();
|
|
211
|
+
const runtime = {
|
|
212
|
+
log: vi.fn(),
|
|
213
|
+
error: vi.fn(),
|
|
214
|
+
exit: vi.fn(),
|
|
215
|
+
};
|
|
216
|
+
const accountId = "recoverable-callback";
|
|
217
|
+
|
|
218
|
+
const monitorPromise = monitorWebSocket({
|
|
219
|
+
account: createAccount(accountId),
|
|
220
|
+
accountId,
|
|
221
|
+
runtime,
|
|
222
|
+
abortSignal: abortController.signal,
|
|
223
|
+
eventDispatcher: {} as never,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await vi.waitFor(() => {
|
|
227
|
+
expect(wsClient.start).toHaveBeenCalledTimes(1);
|
|
228
|
+
expect(wsClients.get(accountId)).toBe(wsClient);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const callbacks = createFeishuWSClientMock.mock.calls[0]?.[1] as
|
|
232
|
+
| { onError?: (err: Error) => void }
|
|
233
|
+
| undefined;
|
|
234
|
+
callbacks?.onError?.(new Error("temporary callback failure\nBearer token_abc"));
|
|
235
|
+
|
|
236
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
237
|
+
|
|
238
|
+
expect(createFeishuWSClientMock).toHaveBeenCalledTimes(1);
|
|
239
|
+
expect(wsClient.close).not.toHaveBeenCalled();
|
|
240
|
+
expect(wsClients.get(accountId)).toBe(wsClient);
|
|
241
|
+
const errorMessage = String(runtime.error.mock.calls[0]?.[0] ?? "");
|
|
242
|
+
expect(errorMessage).toContain("WebSocket SDK reported recoverable error");
|
|
243
|
+
expect(errorMessage).toContain("Bearer [redacted]");
|
|
244
|
+
expect(errorMessage).not.toContain("\n");
|
|
245
|
+
expect(errorMessage).not.toContain("token_abc");
|
|
246
|
+
|
|
247
|
+
abortController.abort();
|
|
248
|
+
await monitorPromise;
|
|
249
|
+
|
|
250
|
+
expect(createFeishuWSClientMock).toHaveBeenCalledTimes(1);
|
|
251
|
+
expect(wsClient.close).toHaveBeenCalledTimes(1);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("clears identity without recreating a websocket when aborted during reconnect backoff", async () => {
|
|
255
|
+
vi.useFakeTimers();
|
|
256
|
+
const exhaustedClient = createWsClient();
|
|
257
|
+
createFeishuWSClientMock.mockResolvedValueOnce(exhaustedClient);
|
|
258
|
+
|
|
259
|
+
const abortController = new AbortController();
|
|
260
|
+
const accountId = "abort-backoff";
|
|
261
|
+
botOpenIds.set(accountId, "ou_abort");
|
|
262
|
+
botNames.set(accountId, "Abort");
|
|
263
|
+
|
|
264
|
+
const monitorPromise = monitorWebSocket({
|
|
265
|
+
account: createAccount(accountId),
|
|
266
|
+
accountId,
|
|
267
|
+
runtime: {
|
|
268
|
+
log: vi.fn(),
|
|
269
|
+
error: vi.fn(),
|
|
270
|
+
exit: vi.fn(),
|
|
271
|
+
},
|
|
272
|
+
abortSignal: abortController.signal,
|
|
273
|
+
eventDispatcher: {} as never,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await vi.waitFor(() => {
|
|
277
|
+
expect(exhaustedClient.start).toHaveBeenCalledTimes(1);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const callbacks = createFeishuWSClientMock.mock.calls[0]?.[1] as
|
|
281
|
+
| { onError?: (err: Error) => void }
|
|
282
|
+
| undefined;
|
|
283
|
+
callbacks?.onError?.(new Error("WebSocket reconnect exhausted after 3 attempts"));
|
|
284
|
+
|
|
285
|
+
await vi.waitFor(() => {
|
|
286
|
+
expect(exhaustedClient.close).toHaveBeenCalledTimes(1);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
abortController.abort();
|
|
290
|
+
await monitorPromise;
|
|
291
|
+
|
|
292
|
+
expect(createFeishuWSClientMock).toHaveBeenCalledTimes(1);
|
|
293
|
+
expect(wsClients.has(accountId)).toBe(false);
|
|
294
|
+
expect(botOpenIds.has(accountId)).toBe(false);
|
|
295
|
+
expect(botNames.has(accountId)).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("redacts websocket close errors during abort cleanup", async () => {
|
|
299
|
+
const wsClient = createWsClient();
|
|
300
|
+
wsClient.close.mockImplementationOnce(() => {
|
|
301
|
+
throw new Error("close failed\naccess_token=secret_token");
|
|
302
|
+
});
|
|
303
|
+
createFeishuWSClientMock.mockReturnValue(wsClient);
|
|
304
|
+
|
|
305
|
+
const abortController = new AbortController();
|
|
306
|
+
const runtime = {
|
|
307
|
+
log: vi.fn(),
|
|
308
|
+
error: vi.fn(),
|
|
309
|
+
exit: vi.fn(),
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const monitorPromise = monitorWebSocket({
|
|
313
|
+
account: createAccount("close-error"),
|
|
314
|
+
accountId: "close-error",
|
|
315
|
+
runtime,
|
|
316
|
+
abortSignal: abortController.signal,
|
|
317
|
+
eventDispatcher: {} as never,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
await vi.waitFor(() => {
|
|
321
|
+
expect(wsClient.start).toHaveBeenCalledTimes(1);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
abortController.abort();
|
|
325
|
+
await monitorPromise;
|
|
326
|
+
|
|
327
|
+
const errorMessage = String(runtime.error.mock.calls[0]?.[0] ?? "");
|
|
328
|
+
expect(errorMessage).toContain("error closing WebSocket client");
|
|
329
|
+
expect(errorMessage).toContain("access_token=[redacted]");
|
|
330
|
+
expect(errorMessage).not.toContain("\n");
|
|
331
|
+
expect(errorMessage).not.toContain("secret_token");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("closes targeted websocket clients during stop cleanup", () => {
|
|
335
|
+
const alphaClient = createWsClient();
|
|
336
|
+
const betaClient = createWsClient();
|
|
337
|
+
|
|
338
|
+
wsClients.set("alpha", alphaClient as never);
|
|
339
|
+
wsClients.set("beta", betaClient as never);
|
|
340
|
+
botOpenIds.set("alpha", "ou_alpha");
|
|
341
|
+
botOpenIds.set("beta", "ou_beta");
|
|
342
|
+
botNames.set("alpha", "Alpha");
|
|
343
|
+
botNames.set("beta", "Beta");
|
|
344
|
+
|
|
345
|
+
stopFeishuMonitorState("alpha");
|
|
346
|
+
|
|
347
|
+
expect(alphaClient.close).toHaveBeenCalledTimes(1);
|
|
348
|
+
expect(betaClient.close).not.toHaveBeenCalled();
|
|
349
|
+
expect(wsClients.has("alpha")).toBe(false);
|
|
350
|
+
expect(wsClients.has("beta")).toBe(true);
|
|
351
|
+
expect(botOpenIds.has("alpha")).toBe(false);
|
|
352
|
+
expect(botOpenIds.has("beta")).toBe(true);
|
|
353
|
+
expect(botNames.has("alpha")).toBe(false);
|
|
354
|
+
expect(botNames.has("beta")).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("closes all websocket clients during global stop cleanup", () => {
|
|
358
|
+
const alphaClient = createWsClient();
|
|
359
|
+
const betaClient = createWsClient();
|
|
360
|
+
|
|
361
|
+
wsClients.set("alpha", alphaClient as never);
|
|
362
|
+
wsClients.set("beta", betaClient as never);
|
|
363
|
+
botOpenIds.set("alpha", "ou_alpha");
|
|
364
|
+
botOpenIds.set("beta", "ou_beta");
|
|
365
|
+
botNames.set("alpha", "Alpha");
|
|
366
|
+
botNames.set("beta", "Beta");
|
|
367
|
+
|
|
368
|
+
stopFeishuMonitorState();
|
|
369
|
+
|
|
370
|
+
expect(alphaClient.close).toHaveBeenCalledTimes(1);
|
|
371
|
+
expect(betaClient.close).toHaveBeenCalledTimes(1);
|
|
372
|
+
expect(wsClients.size).toBe(0);
|
|
373
|
+
expect(botOpenIds.size).toBe(0);
|
|
374
|
+
expect(botNames.size).toBe(0);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
|
|
2
|
+
import { handleFeishuCommentEvent } from "./comment-handler.js";
|
|
3
|
+
import {
|
|
4
|
+
claimUnprocessedFeishuMessage,
|
|
5
|
+
recordProcessedFeishuMessage,
|
|
6
|
+
releaseFeishuMessageProcessing,
|
|
7
|
+
} from "./dedup.js";
|
|
8
|
+
import { parseFeishuDriveCommentNoticeEventPayload } from "./monitor.comment.js";
|
|
9
|
+
import { botOpenIds } from "./monitor.state.js";
|
|
10
|
+
import { isFeishuRetryableSyntheticEventError } from "./monitor.synthetic-error.js";
|
|
11
|
+
import { createSequentialQueue } from "./sequential-queue.js";
|
|
12
|
+
|
|
13
|
+
function buildCommentNoticeQueueKey(event: {
|
|
14
|
+
notice_meta?: {
|
|
15
|
+
file_type?: string;
|
|
16
|
+
file_token?: string;
|
|
17
|
+
};
|
|
18
|
+
}): string {
|
|
19
|
+
const fileType = event.notice_meta?.file_type?.trim() || "unknown";
|
|
20
|
+
const fileToken = event.notice_meta?.file_token?.trim() || "unknown";
|
|
21
|
+
return `comment-doc:${fileType}:${fileToken}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createFeishuDriveCommentNoticeHandler(params: {
|
|
25
|
+
cfg: ClawdbotConfig;
|
|
26
|
+
accountId: string;
|
|
27
|
+
runtime?: RuntimeEnv;
|
|
28
|
+
fireAndForget?: boolean;
|
|
29
|
+
getBotOpenId?: (accountId: string) => string | undefined;
|
|
30
|
+
}): (data: unknown) => Promise<void> {
|
|
31
|
+
const { cfg, accountId, runtime, fireAndForget } = params;
|
|
32
|
+
const log = runtime?.log ?? console.log;
|
|
33
|
+
const error = runtime?.error ?? console.error;
|
|
34
|
+
const enqueue = createSequentialQueue();
|
|
35
|
+
const getBotOpenId = params.getBotOpenId ?? ((id) => botOpenIds.get(id));
|
|
36
|
+
|
|
37
|
+
const runFeishuHandler = async (task: () => Promise<void>) => {
|
|
38
|
+
const promise = task().catch((err) => {
|
|
39
|
+
error(`feishu[${accountId}]: error handling drive comment notice: ${String(err)}`);
|
|
40
|
+
});
|
|
41
|
+
if (!fireAndForget) {
|
|
42
|
+
await promise;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return async (data: unknown) => {
|
|
47
|
+
await runFeishuHandler(async () => {
|
|
48
|
+
const event = parseFeishuDriveCommentNoticeEventPayload(data);
|
|
49
|
+
if (!event) {
|
|
50
|
+
error(`feishu[${accountId}]: ignoring malformed drive comment notice payload`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const eventId = event.event_id?.trim();
|
|
54
|
+
const syntheticMessageId = eventId ? `drive-comment:${eventId}` : undefined;
|
|
55
|
+
if (syntheticMessageId) {
|
|
56
|
+
const claim = await claimUnprocessedFeishuMessage({
|
|
57
|
+
messageId: syntheticMessageId,
|
|
58
|
+
namespace: accountId,
|
|
59
|
+
log,
|
|
60
|
+
});
|
|
61
|
+
if (claim === "duplicate") {
|
|
62
|
+
log(`feishu[${accountId}]: dropping duplicate comment event ${syntheticMessageId}`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (claim === "inflight") {
|
|
66
|
+
log(`feishu[${accountId}]: dropping in-flight comment event ${syntheticMessageId}`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
log(
|
|
71
|
+
`feishu[${accountId}]: received drive comment notice ` +
|
|
72
|
+
`event=${event.event_id ?? "unknown"} ` +
|
|
73
|
+
`type=${event.notice_meta?.notice_type ?? "unknown"} ` +
|
|
74
|
+
`file=${event.notice_meta?.file_type ?? "unknown"}:${event.notice_meta?.file_token ?? "unknown"} ` +
|
|
75
|
+
`comment=${event.comment_id ?? "unknown"} ` +
|
|
76
|
+
`reply=${event.reply_id ?? "none"} ` +
|
|
77
|
+
`from=${event.notice_meta?.from_user_id?.open_id ?? "unknown"} ` +
|
|
78
|
+
`mentioned=${event.is_mentioned === true ? "yes" : "no"}`,
|
|
79
|
+
);
|
|
80
|
+
try {
|
|
81
|
+
await enqueue(buildCommentNoticeQueueKey(event), async () => {
|
|
82
|
+
await handleFeishuCommentEvent({
|
|
83
|
+
cfg,
|
|
84
|
+
accountId,
|
|
85
|
+
event,
|
|
86
|
+
botOpenId: getBotOpenId(accountId),
|
|
87
|
+
runtime,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
if (syntheticMessageId) {
|
|
91
|
+
await recordProcessedFeishuMessage(syntheticMessageId, accountId, log);
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (syntheticMessageId && !isFeishuRetryableSyntheticEventError(err)) {
|
|
95
|
+
await recordProcessedFeishuMessage(syntheticMessageId, accountId, log);
|
|
96
|
+
}
|
|
97
|
+
throw err;
|
|
98
|
+
} finally {
|
|
99
|
+
if (syntheticMessageId) {
|
|
100
|
+
releaseFeishuMessageProcessing(syntheticMessageId, accountId);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
}
|