@openclaw/feishu 2026.2.25 → 2026.3.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.
- package/index.ts +2 -0
- package/package.json +2 -1
- package/skills/feishu-doc/SKILL.md +109 -3
- package/src/accounts.test.ts +161 -0
- package/src/accounts.ts +76 -8
- package/src/async.ts +62 -0
- package/src/bitable.ts +189 -215
- package/src/bot.card-action.test.ts +63 -0
- package/src/bot.checkBotMentioned.test.ts +56 -1
- package/src/bot.test.ts +1271 -56
- package/src/bot.ts +499 -215
- package/src/card-action.ts +79 -0
- package/src/channel.ts +26 -4
- package/src/chat-schema.ts +24 -0
- package/src/chat.test.ts +89 -0
- package/src/chat.ts +130 -0
- package/src/client.test.ts +121 -0
- package/src/client.ts +13 -0
- package/src/config-schema.test.ts +101 -1
- package/src/config-schema.ts +66 -11
- package/src/dedup.ts +47 -1
- package/src/doc-schema.ts +135 -0
- package/src/docx-batch-insert.ts +190 -0
- package/src/docx-color-text.ts +149 -0
- package/src/docx-table-ops.ts +298 -0
- package/src/docx.account-selection.test.ts +70 -0
- package/src/docx.test.ts +331 -9
- package/src/docx.ts +996 -72
- package/src/drive.ts +38 -33
- package/src/media.test.ts +227 -7
- package/src/media.ts +52 -11
- package/src/mention.ts +1 -1
- package/src/monitor.account.ts +534 -0
- package/src/monitor.reaction.test.ts +578 -0
- package/src/monitor.startup.test.ts +203 -0
- package/src/monitor.startup.ts +51 -0
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +152 -0
- package/src/monitor.test-mocks.ts +12 -0
- package/src/monitor.transport.ts +163 -0
- package/src/monitor.ts +44 -346
- package/src/monitor.webhook-security.test.ts +53 -10
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.ts +144 -52
- package/src/outbound.test.ts +181 -0
- package/src/outbound.ts +94 -7
- package/src/perm.ts +37 -30
- package/src/policy.test.ts +56 -1
- package/src/policy.ts +5 -1
- package/src/post.test.ts +105 -0
- package/src/post.ts +274 -0
- package/src/probe.test.ts +271 -0
- package/src/probe.ts +131 -19
- package/src/reply-dispatcher.test.ts +300 -0
- package/src/reply-dispatcher.ts +159 -46
- package/src/secret-input.ts +19 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +6 -2
- package/src/send.reply-fallback.test.ts +105 -0
- package/src/send.test.ts +168 -0
- package/src/send.ts +143 -18
- package/src/streaming-card.ts +131 -43
- package/src/targets.test.ts +55 -1
- package/src/targets.ts +32 -7
- package/src/tool-account-routing.test.ts +129 -0
- package/src/tool-account.ts +70 -0
- package/src/tool-factory-test-harness.ts +76 -0
- package/src/tools-config.test.ts +21 -0
- package/src/tools-config.ts +2 -1
- package/src/types.ts +10 -1
- package/src/typing.test.ts +144 -0
- package/src/typing.ts +140 -10
- package/src/wiki.ts +55 -50
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
|
|
4
|
+
|
|
5
|
+
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
|
6
|
+
|
|
7
|
+
vi.mock("./probe.js", () => ({
|
|
8
|
+
probeFeishu: probeFeishuMock,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("./client.js", () => ({
|
|
12
|
+
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
|
|
13
|
+
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("./runtime.js", () => ({
|
|
17
|
+
getFeishuRuntime: () => ({
|
|
18
|
+
channel: {
|
|
19
|
+
debounce: {
|
|
20
|
+
resolveInboundDebounceMs: () => 0,
|
|
21
|
+
createInboundDebouncer: () => ({
|
|
22
|
+
enqueue: async () => {},
|
|
23
|
+
flushKey: async () => {},
|
|
24
|
+
}),
|
|
25
|
+
},
|
|
26
|
+
text: {
|
|
27
|
+
hasControlCommand: () => false,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig {
|
|
34
|
+
return {
|
|
35
|
+
channels: {
|
|
36
|
+
feishu: {
|
|
37
|
+
enabled: true,
|
|
38
|
+
accounts: Object.fromEntries(
|
|
39
|
+
accountIds.map((accountId) => [
|
|
40
|
+
accountId,
|
|
41
|
+
{
|
|
42
|
+
enabled: true,
|
|
43
|
+
appId: `cli_${accountId}`,
|
|
44
|
+
appSecret: `secret_${accountId}`,
|
|
45
|
+
connectionMode: "websocket",
|
|
46
|
+
},
|
|
47
|
+
]),
|
|
48
|
+
),
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
} as ClawdbotConfig;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
stopFeishuMonitor();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("Feishu monitor startup preflight", () => {
|
|
59
|
+
it("starts account probes sequentially to avoid startup bursts", async () => {
|
|
60
|
+
let inFlight = 0;
|
|
61
|
+
let maxInFlight = 0;
|
|
62
|
+
const started: string[] = [];
|
|
63
|
+
let releaseProbes!: () => void;
|
|
64
|
+
const probesReleased = new Promise<void>((resolve) => {
|
|
65
|
+
releaseProbes = () => resolve();
|
|
66
|
+
});
|
|
67
|
+
probeFeishuMock.mockImplementation(async (account: { accountId: string }) => {
|
|
68
|
+
started.push(account.accountId);
|
|
69
|
+
inFlight += 1;
|
|
70
|
+
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
71
|
+
await probesReleased;
|
|
72
|
+
inFlight -= 1;
|
|
73
|
+
return { ok: true, botOpenId: `bot_${account.accountId}` };
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const abortController = new AbortController();
|
|
77
|
+
const monitorPromise = monitorFeishuProvider({
|
|
78
|
+
config: buildMultiAccountWebsocketConfig(["alpha", "beta", "gamma"]),
|
|
79
|
+
abortSignal: abortController.signal,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await Promise.resolve();
|
|
84
|
+
await Promise.resolve();
|
|
85
|
+
|
|
86
|
+
expect(started).toEqual(["alpha"]);
|
|
87
|
+
expect(maxInFlight).toBe(1);
|
|
88
|
+
} finally {
|
|
89
|
+
releaseProbes();
|
|
90
|
+
abortController.abort();
|
|
91
|
+
await monitorPromise;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("does not refetch bot info after a failed sequential preflight", async () => {
|
|
96
|
+
const started: string[] = [];
|
|
97
|
+
let releaseBetaProbe!: () => void;
|
|
98
|
+
const betaProbeReleased = new Promise<void>((resolve) => {
|
|
99
|
+
releaseBetaProbe = () => resolve();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
probeFeishuMock.mockImplementation(async (account: { accountId: string }) => {
|
|
103
|
+
started.push(account.accountId);
|
|
104
|
+
if (account.accountId === "alpha") {
|
|
105
|
+
return { ok: false };
|
|
106
|
+
}
|
|
107
|
+
await betaProbeReleased;
|
|
108
|
+
return { ok: true, botOpenId: `bot_${account.accountId}` };
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const abortController = new AbortController();
|
|
112
|
+
const monitorPromise = monitorFeishuProvider({
|
|
113
|
+
config: buildMultiAccountWebsocketConfig(["alpha", "beta"]),
|
|
114
|
+
abortSignal: abortController.signal,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
for (let i = 0; i < 10 && !started.includes("beta"); i += 1) {
|
|
119
|
+
await Promise.resolve();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
expect(started).toEqual(["alpha", "beta"]);
|
|
123
|
+
expect(started.filter((accountId) => accountId === "alpha")).toHaveLength(1);
|
|
124
|
+
} finally {
|
|
125
|
+
releaseBetaProbe();
|
|
126
|
+
abortController.abort();
|
|
127
|
+
await monitorPromise;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("continues startup when probe layer reports timeout", async () => {
|
|
132
|
+
const started: string[] = [];
|
|
133
|
+
let releaseBetaProbe!: () => void;
|
|
134
|
+
const betaProbeReleased = new Promise<void>((resolve) => {
|
|
135
|
+
releaseBetaProbe = () => resolve();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
probeFeishuMock.mockImplementation((account: { accountId: string }) => {
|
|
139
|
+
started.push(account.accountId);
|
|
140
|
+
if (account.accountId === "alpha") {
|
|
141
|
+
return Promise.resolve({ ok: false, error: "probe timed out after 10000ms" });
|
|
142
|
+
}
|
|
143
|
+
return betaProbeReleased.then(() => ({ ok: true, botOpenId: `bot_${account.accountId}` }));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const abortController = new AbortController();
|
|
147
|
+
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
|
148
|
+
const monitorPromise = monitorFeishuProvider({
|
|
149
|
+
config: buildMultiAccountWebsocketConfig(["alpha", "beta"]),
|
|
150
|
+
runtime,
|
|
151
|
+
abortSignal: abortController.signal,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
for (let i = 0; i < 10 && !started.includes("beta"); i += 1) {
|
|
156
|
+
await Promise.resolve();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
expect(started).toEqual(["alpha", "beta"]);
|
|
160
|
+
expect(runtime.error).toHaveBeenCalledWith(
|
|
161
|
+
expect.stringContaining("bot info probe timed out"),
|
|
162
|
+
);
|
|
163
|
+
} finally {
|
|
164
|
+
releaseBetaProbe();
|
|
165
|
+
abortController.abort();
|
|
166
|
+
await monitorPromise;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("stops sequential preflight when aborted during probe", async () => {
|
|
171
|
+
const started: string[] = [];
|
|
172
|
+
probeFeishuMock.mockImplementation(
|
|
173
|
+
(account: { accountId: string }, options: { abortSignal?: AbortSignal }) => {
|
|
174
|
+
started.push(account.accountId);
|
|
175
|
+
return new Promise((resolve) => {
|
|
176
|
+
options.abortSignal?.addEventListener(
|
|
177
|
+
"abort",
|
|
178
|
+
() => resolve({ ok: false, error: "probe aborted" }),
|
|
179
|
+
{ once: true },
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const abortController = new AbortController();
|
|
186
|
+
const monitorPromise = monitorFeishuProvider({
|
|
187
|
+
config: buildMultiAccountWebsocketConfig(["alpha", "beta"]),
|
|
188
|
+
abortSignal: abortController.signal,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
await Promise.resolve();
|
|
193
|
+
expect(started).toEqual(["alpha"]);
|
|
194
|
+
|
|
195
|
+
abortController.abort();
|
|
196
|
+
await monitorPromise;
|
|
197
|
+
|
|
198
|
+
expect(started).toEqual(["alpha"]);
|
|
199
|
+
} finally {
|
|
200
|
+
abortController.abort();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
|
2
|
+
import { probeFeishu } from "./probe.js";
|
|
3
|
+
import type { ResolvedFeishuAccount } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export const FEISHU_STARTUP_BOT_INFO_TIMEOUT_MS = 10_000;
|
|
6
|
+
|
|
7
|
+
type FetchBotOpenIdOptions = {
|
|
8
|
+
runtime?: RuntimeEnv;
|
|
9
|
+
abortSignal?: AbortSignal;
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function isTimeoutErrorMessage(message: string | undefined): boolean {
|
|
14
|
+
return message?.toLowerCase().includes("timeout") || message?.toLowerCase().includes("timed out")
|
|
15
|
+
? true
|
|
16
|
+
: false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isAbortErrorMessage(message: string | undefined): boolean {
|
|
20
|
+
return message?.toLowerCase().includes("aborted") ?? false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function fetchBotOpenIdForMonitor(
|
|
24
|
+
account: ResolvedFeishuAccount,
|
|
25
|
+
options: FetchBotOpenIdOptions = {},
|
|
26
|
+
): Promise<string | undefined> {
|
|
27
|
+
if (options.abortSignal?.aborted) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const timeoutMs = options.timeoutMs ?? FEISHU_STARTUP_BOT_INFO_TIMEOUT_MS;
|
|
32
|
+
const result = await probeFeishu(account, {
|
|
33
|
+
timeoutMs,
|
|
34
|
+
abortSignal: options.abortSignal,
|
|
35
|
+
});
|
|
36
|
+
if (result.ok) {
|
|
37
|
+
return result.botOpenId;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (options.abortSignal?.aborted || isAbortErrorMessage(result.error)) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (isTimeoutErrorMessage(result.error)) {
|
|
45
|
+
const error = options.runtime?.error ?? console.error;
|
|
46
|
+
error(
|
|
47
|
+
`feishu[${account.accountId}]: bot info probe timed out after ${timeoutMs}ms; continuing startup`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
resolveFeishuWebhookAnomalyDefaultsForTest,
|
|
4
|
+
resolveFeishuWebhookRateLimitDefaultsForTest,
|
|
5
|
+
} from "./monitor.state.js";
|
|
6
|
+
|
|
7
|
+
describe("feishu monitor state defaults", () => {
|
|
8
|
+
it("falls back to hard defaults when sdk defaults are missing", () => {
|
|
9
|
+
expect(resolveFeishuWebhookRateLimitDefaultsForTest(undefined)).toEqual({
|
|
10
|
+
windowMs: 60_000,
|
|
11
|
+
maxRequests: 120,
|
|
12
|
+
maxTrackedKeys: 4_096,
|
|
13
|
+
});
|
|
14
|
+
expect(resolveFeishuWebhookAnomalyDefaultsForTest(undefined)).toEqual({
|
|
15
|
+
maxTrackedKeys: 4_096,
|
|
16
|
+
ttlMs: 21_600_000,
|
|
17
|
+
logEvery: 25,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("keeps valid sdk values and repairs invalid fields", () => {
|
|
22
|
+
expect(
|
|
23
|
+
resolveFeishuWebhookRateLimitDefaultsForTest({
|
|
24
|
+
windowMs: 45_000,
|
|
25
|
+
maxRequests: 0,
|
|
26
|
+
maxTrackedKeys: -1,
|
|
27
|
+
}),
|
|
28
|
+
).toEqual({
|
|
29
|
+
windowMs: 45_000,
|
|
30
|
+
maxRequests: 120,
|
|
31
|
+
maxTrackedKeys: 4_096,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(
|
|
35
|
+
resolveFeishuWebhookAnomalyDefaultsForTest({
|
|
36
|
+
maxTrackedKeys: 2048,
|
|
37
|
+
ttlMs: Number.NaN,
|
|
38
|
+
logEvery: 10,
|
|
39
|
+
}),
|
|
40
|
+
).toEqual({
|
|
41
|
+
maxTrackedKeys: 2048,
|
|
42
|
+
ttlMs: 21_600_000,
|
|
43
|
+
logEvery: 10,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import * as http from "http";
|
|
2
|
+
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
3
|
+
import {
|
|
4
|
+
createFixedWindowRateLimiter,
|
|
5
|
+
createWebhookAnomalyTracker,
|
|
6
|
+
type RuntimeEnv,
|
|
7
|
+
WEBHOOK_ANOMALY_COUNTER_DEFAULTS as WEBHOOK_ANOMALY_COUNTER_DEFAULTS_FROM_SDK,
|
|
8
|
+
WEBHOOK_RATE_LIMIT_DEFAULTS as WEBHOOK_RATE_LIMIT_DEFAULTS_FROM_SDK,
|
|
9
|
+
} from "openclaw/plugin-sdk";
|
|
10
|
+
|
|
11
|
+
export const wsClients = new Map<string, Lark.WSClient>();
|
|
12
|
+
export const httpServers = new Map<string, http.Server>();
|
|
13
|
+
export const botOpenIds = new Map<string, string>();
|
|
14
|
+
|
|
15
|
+
export const FEISHU_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
|
|
16
|
+
export const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
|
|
17
|
+
|
|
18
|
+
type WebhookRateLimitDefaults = {
|
|
19
|
+
windowMs: number;
|
|
20
|
+
maxRequests: number;
|
|
21
|
+
maxTrackedKeys: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type WebhookAnomalyDefaults = {
|
|
25
|
+
maxTrackedKeys: number;
|
|
26
|
+
ttlMs: number;
|
|
27
|
+
logEvery: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS: WebhookRateLimitDefaults = {
|
|
31
|
+
windowMs: 60_000,
|
|
32
|
+
maxRequests: 120,
|
|
33
|
+
maxTrackedKeys: 4_096,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS: WebhookAnomalyDefaults = {
|
|
37
|
+
maxTrackedKeys: 4_096,
|
|
38
|
+
ttlMs: 6 * 60 * 60_000,
|
|
39
|
+
logEvery: 25,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function coercePositiveInt(value: unknown, fallback: number): number {
|
|
43
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
44
|
+
return fallback;
|
|
45
|
+
}
|
|
46
|
+
const normalized = Math.floor(value);
|
|
47
|
+
return normalized > 0 ? normalized : fallback;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resolveFeishuWebhookRateLimitDefaultsForTest(
|
|
51
|
+
defaults: unknown,
|
|
52
|
+
): WebhookRateLimitDefaults {
|
|
53
|
+
const resolved = defaults as Partial<WebhookRateLimitDefaults> | null | undefined;
|
|
54
|
+
return {
|
|
55
|
+
windowMs: coercePositiveInt(
|
|
56
|
+
resolved?.windowMs,
|
|
57
|
+
FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS.windowMs,
|
|
58
|
+
),
|
|
59
|
+
maxRequests: coercePositiveInt(
|
|
60
|
+
resolved?.maxRequests,
|
|
61
|
+
FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS.maxRequests,
|
|
62
|
+
),
|
|
63
|
+
maxTrackedKeys: coercePositiveInt(
|
|
64
|
+
resolved?.maxTrackedKeys,
|
|
65
|
+
FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS.maxTrackedKeys,
|
|
66
|
+
),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function resolveFeishuWebhookAnomalyDefaultsForTest(
|
|
71
|
+
defaults: unknown,
|
|
72
|
+
): WebhookAnomalyDefaults {
|
|
73
|
+
const resolved = defaults as Partial<WebhookAnomalyDefaults> | null | undefined;
|
|
74
|
+
return {
|
|
75
|
+
maxTrackedKeys: coercePositiveInt(
|
|
76
|
+
resolved?.maxTrackedKeys,
|
|
77
|
+
FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS.maxTrackedKeys,
|
|
78
|
+
),
|
|
79
|
+
ttlMs: coercePositiveInt(resolved?.ttlMs, FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS.ttlMs),
|
|
80
|
+
logEvery: coercePositiveInt(
|
|
81
|
+
resolved?.logEvery,
|
|
82
|
+
FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS.logEvery,
|
|
83
|
+
),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const feishuWebhookRateLimitDefaults = resolveFeishuWebhookRateLimitDefaultsForTest(
|
|
88
|
+
WEBHOOK_RATE_LIMIT_DEFAULTS_FROM_SDK,
|
|
89
|
+
);
|
|
90
|
+
const feishuWebhookAnomalyDefaults = resolveFeishuWebhookAnomalyDefaultsForTest(
|
|
91
|
+
WEBHOOK_ANOMALY_COUNTER_DEFAULTS_FROM_SDK,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
export const feishuWebhookRateLimiter = createFixedWindowRateLimiter({
|
|
95
|
+
windowMs: feishuWebhookRateLimitDefaults.windowMs,
|
|
96
|
+
maxRequests: feishuWebhookRateLimitDefaults.maxRequests,
|
|
97
|
+
maxTrackedKeys: feishuWebhookRateLimitDefaults.maxTrackedKeys,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const feishuWebhookAnomalyTracker = createWebhookAnomalyTracker({
|
|
101
|
+
maxTrackedKeys: feishuWebhookAnomalyDefaults.maxTrackedKeys,
|
|
102
|
+
ttlMs: feishuWebhookAnomalyDefaults.ttlMs,
|
|
103
|
+
logEvery: feishuWebhookAnomalyDefaults.logEvery,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export function clearFeishuWebhookRateLimitStateForTest(): void {
|
|
107
|
+
feishuWebhookRateLimiter.clear();
|
|
108
|
+
feishuWebhookAnomalyTracker.clear();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function getFeishuWebhookRateLimitStateSizeForTest(): number {
|
|
112
|
+
return feishuWebhookRateLimiter.size();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function isWebhookRateLimitedForTest(key: string, nowMs: number): boolean {
|
|
116
|
+
return feishuWebhookRateLimiter.isRateLimited(key, nowMs);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function recordWebhookStatus(
|
|
120
|
+
runtime: RuntimeEnv | undefined,
|
|
121
|
+
accountId: string,
|
|
122
|
+
path: string,
|
|
123
|
+
statusCode: number,
|
|
124
|
+
): void {
|
|
125
|
+
feishuWebhookAnomalyTracker.record({
|
|
126
|
+
key: `${accountId}:${path}:${statusCode}`,
|
|
127
|
+
statusCode,
|
|
128
|
+
log: runtime?.log ?? console.log,
|
|
129
|
+
message: (count) =>
|
|
130
|
+
`feishu[${accountId}]: webhook anomaly path=${path} status=${statusCode} count=${count}`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function stopFeishuMonitorState(accountId?: string): void {
|
|
135
|
+
if (accountId) {
|
|
136
|
+
wsClients.delete(accountId);
|
|
137
|
+
const server = httpServers.get(accountId);
|
|
138
|
+
if (server) {
|
|
139
|
+
server.close();
|
|
140
|
+
httpServers.delete(accountId);
|
|
141
|
+
}
|
|
142
|
+
botOpenIds.delete(accountId);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
wsClients.clear();
|
|
147
|
+
for (const server of httpServers.values()) {
|
|
148
|
+
server.close();
|
|
149
|
+
}
|
|
150
|
+
httpServers.clear();
|
|
151
|
+
botOpenIds.clear();
|
|
152
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
export const probeFeishuMock: ReturnType<typeof vi.fn> = vi.fn();
|
|
4
|
+
|
|
5
|
+
vi.mock("./probe.js", () => ({
|
|
6
|
+
probeFeishu: probeFeishuMock,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("./client.js", () => ({
|
|
10
|
+
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
|
|
11
|
+
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
|
|
12
|
+
}));
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import * as http from "http";
|
|
2
|
+
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
3
|
+
import {
|
|
4
|
+
applyBasicWebhookRequestGuards,
|
|
5
|
+
type RuntimeEnv,
|
|
6
|
+
installRequestBodyLimitGuard,
|
|
7
|
+
} from "openclaw/plugin-sdk";
|
|
8
|
+
import { createFeishuWSClient } from "./client.js";
|
|
9
|
+
import {
|
|
10
|
+
botOpenIds,
|
|
11
|
+
FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
|
|
12
|
+
FEISHU_WEBHOOK_MAX_BODY_BYTES,
|
|
13
|
+
feishuWebhookRateLimiter,
|
|
14
|
+
httpServers,
|
|
15
|
+
recordWebhookStatus,
|
|
16
|
+
wsClients,
|
|
17
|
+
} from "./monitor.state.js";
|
|
18
|
+
import type { ResolvedFeishuAccount } from "./types.js";
|
|
19
|
+
|
|
20
|
+
export type MonitorTransportParams = {
|
|
21
|
+
account: ResolvedFeishuAccount;
|
|
22
|
+
accountId: string;
|
|
23
|
+
runtime?: RuntimeEnv;
|
|
24
|
+
abortSignal?: AbortSignal;
|
|
25
|
+
eventDispatcher: Lark.EventDispatcher;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export async function monitorWebSocket({
|
|
29
|
+
account,
|
|
30
|
+
accountId,
|
|
31
|
+
runtime,
|
|
32
|
+
abortSignal,
|
|
33
|
+
eventDispatcher,
|
|
34
|
+
}: MonitorTransportParams): Promise<void> {
|
|
35
|
+
const log = runtime?.log ?? console.log;
|
|
36
|
+
log(`feishu[${accountId}]: starting WebSocket connection...`);
|
|
37
|
+
|
|
38
|
+
const wsClient = createFeishuWSClient(account);
|
|
39
|
+
wsClients.set(accountId, wsClient);
|
|
40
|
+
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const cleanup = () => {
|
|
43
|
+
wsClients.delete(accountId);
|
|
44
|
+
botOpenIds.delete(accountId);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const handleAbort = () => {
|
|
48
|
+
log(`feishu[${accountId}]: abort signal received, stopping`);
|
|
49
|
+
cleanup();
|
|
50
|
+
resolve();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (abortSignal?.aborted) {
|
|
54
|
+
cleanup();
|
|
55
|
+
resolve();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
abortSignal?.addEventListener("abort", handleAbort, { once: true });
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
wsClient.start({ eventDispatcher });
|
|
63
|
+
log(`feishu[${accountId}]: WebSocket client started`);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
cleanup();
|
|
66
|
+
abortSignal?.removeEventListener("abort", handleAbort);
|
|
67
|
+
reject(err);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function monitorWebhook({
|
|
73
|
+
account,
|
|
74
|
+
accountId,
|
|
75
|
+
runtime,
|
|
76
|
+
abortSignal,
|
|
77
|
+
eventDispatcher,
|
|
78
|
+
}: MonitorTransportParams): Promise<void> {
|
|
79
|
+
const log = runtime?.log ?? console.log;
|
|
80
|
+
const error = runtime?.error ?? console.error;
|
|
81
|
+
|
|
82
|
+
const port = account.config.webhookPort ?? 3000;
|
|
83
|
+
const path = account.config.webhookPath ?? "/feishu/events";
|
|
84
|
+
const host = account.config.webhookHost ?? "127.0.0.1";
|
|
85
|
+
|
|
86
|
+
log(`feishu[${accountId}]: starting Webhook server on ${host}:${port}, path ${path}...`);
|
|
87
|
+
|
|
88
|
+
const server = http.createServer();
|
|
89
|
+
const webhookHandler = Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true });
|
|
90
|
+
|
|
91
|
+
server.on("request", (req, res) => {
|
|
92
|
+
res.on("finish", () => {
|
|
93
|
+
recordWebhookStatus(runtime, accountId, path, res.statusCode);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const rateLimitKey = `${accountId}:${path}:${req.socket.remoteAddress ?? "unknown"}`;
|
|
97
|
+
if (
|
|
98
|
+
!applyBasicWebhookRequestGuards({
|
|
99
|
+
req,
|
|
100
|
+
res,
|
|
101
|
+
rateLimiter: feishuWebhookRateLimiter,
|
|
102
|
+
rateLimitKey,
|
|
103
|
+
nowMs: Date.now(),
|
|
104
|
+
requireJsonContentType: true,
|
|
105
|
+
})
|
|
106
|
+
) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const guard = installRequestBodyLimitGuard(req, res, {
|
|
111
|
+
maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES,
|
|
112
|
+
timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
|
|
113
|
+
responseFormat: "text",
|
|
114
|
+
});
|
|
115
|
+
if (guard.isTripped()) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
void Promise.resolve(webhookHandler(req, res))
|
|
120
|
+
.catch((err) => {
|
|
121
|
+
if (!guard.isTripped()) {
|
|
122
|
+
error(`feishu[${accountId}]: webhook handler error: ${String(err)}`);
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
.finally(() => {
|
|
126
|
+
guard.dispose();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
httpServers.set(accountId, server);
|
|
131
|
+
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const cleanup = () => {
|
|
134
|
+
server.close();
|
|
135
|
+
httpServers.delete(accountId);
|
|
136
|
+
botOpenIds.delete(accountId);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const handleAbort = () => {
|
|
140
|
+
log(`feishu[${accountId}]: abort signal received, stopping Webhook server`);
|
|
141
|
+
cleanup();
|
|
142
|
+
resolve();
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
if (abortSignal?.aborted) {
|
|
146
|
+
cleanup();
|
|
147
|
+
resolve();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
abortSignal?.addEventListener("abort", handleAbort, { once: true });
|
|
152
|
+
|
|
153
|
+
server.listen(port, host, () => {
|
|
154
|
+
log(`feishu[${accountId}]: Webhook server listening on ${host}:${port}`);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
server.on("error", (err) => {
|
|
158
|
+
error(`feishu[${accountId}]: Webhook server error: ${err}`);
|
|
159
|
+
abortSignal?.removeEventListener("abort", handleAbort);
|
|
160
|
+
reject(err);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|