@nextclaw/channel-plugin-feishu 0.2.13 → 0.2.15
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/README.md +3 -1
- package/index.ts +65 -0
- package/openclaw.plugin.json +3 -7
- package/package.json +32 -9
- package/skills/feishu-doc/SKILL.md +211 -0
- package/skills/feishu-doc/references/block-types.md +103 -0
- package/skills/feishu-drive/SKILL.md +97 -0
- package/skills/feishu-perm/SKILL.md +119 -0
- package/skills/feishu-wiki/SKILL.md +111 -0
- package/src/accounts.test.ts +371 -0
- package/src/accounts.ts +244 -0
- package/src/async.ts +62 -0
- package/src/bitable.ts +725 -0
- package/src/bot.card-action.test.ts +63 -0
- package/src/bot.checkBotMentioned.test.ts +193 -0
- package/src/bot.stripBotMention.test.ts +134 -0
- package/src/bot.test.ts +2107 -0
- package/src/bot.ts +1556 -0
- package/src/card-action.ts +79 -0
- package/src/channel.test.ts +48 -0
- package/src/channel.ts +369 -0
- 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 +324 -0
- package/src/client.ts +196 -0
- package/src/config-schema.test.ts +247 -0
- package/src/config-schema.ts +306 -0
- package/src/dedup.ts +203 -0
- package/src/directory.test.ts +40 -0
- package/src/directory.ts +156 -0
- package/src/doc-schema.ts +182 -0
- package/src/docx-batch-insert.test.ts +90 -0
- package/src/docx-batch-insert.ts +187 -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 +445 -0
- package/src/docx.ts +1460 -0
- package/src/drive-schema.ts +46 -0
- package/src/drive.ts +228 -0
- package/src/dynamic-agent.ts +131 -0
- package/src/external-keys.test.ts +20 -0
- package/src/external-keys.ts +19 -0
- package/src/feishu-command-handler.ts +59 -0
- package/src/media.test.ts +523 -0
- package/src/media.ts +484 -0
- package/src/mention.ts +133 -0
- package/src/monitor.account.ts +562 -0
- package/src/monitor.reaction.test.ts +653 -0
- package/src/monitor.startup.test.ts +190 -0
- package/src/monitor.startup.ts +64 -0
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +155 -0
- package/src/monitor.test-mocks.ts +45 -0
- package/src/monitor.transport.ts +264 -0
- package/src/monitor.ts +95 -0
- package/src/monitor.webhook-e2e.test.ts +214 -0
- package/src/monitor.webhook-security.test.ts +142 -0
- package/src/monitor.webhook.test-helpers.ts +98 -0
- package/src/nextclaw-sdk/account-id.ts +31 -0
- package/src/nextclaw-sdk/compat.ts +8 -0
- package/src/nextclaw-sdk/core-channel.ts +296 -0
- package/src/nextclaw-sdk/core-pairing.ts +224 -0
- package/src/nextclaw-sdk/core.ts +26 -0
- package/src/nextclaw-sdk/dedupe.ts +246 -0
- package/src/nextclaw-sdk/feishu.ts +77 -0
- package/src/nextclaw-sdk/history.ts +127 -0
- package/src/nextclaw-sdk/network-body.ts +245 -0
- package/src/nextclaw-sdk/network-fetch.ts +129 -0
- package/src/nextclaw-sdk/network-webhook.ts +182 -0
- package/src/nextclaw-sdk/network.ts +13 -0
- package/src/nextclaw-sdk/runtime-store.ts +26 -0
- package/src/nextclaw-sdk/secrets-config.ts +109 -0
- package/src/nextclaw-sdk/secrets-core.ts +170 -0
- package/src/nextclaw-sdk/secrets-prompt.ts +305 -0
- package/src/nextclaw-sdk/secrets.ts +18 -0
- package/src/nextclaw-sdk/types.ts +300 -0
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.test.ts +143 -0
- package/src/onboarding.ts +489 -0
- package/src/outbound.test.ts +356 -0
- package/src/outbound.ts +176 -0
- package/src/perm-schema.ts +52 -0
- package/src/perm.ts +176 -0
- package/src/policy.test.ts +154 -0
- package/src/policy.ts +123 -0
- package/src/post.test.ts +105 -0
- package/src/post.ts +274 -0
- package/src/probe.test.ts +270 -0
- package/src/probe.ts +156 -0
- package/src/reactions.ts +153 -0
- package/src/reply-dispatcher.test.ts +513 -0
- package/src/reply-dispatcher.ts +397 -0
- package/src/runtime.ts +6 -0
- package/src/secret-input.ts +13 -0
- package/src/send-message.ts +71 -0
- package/src/send-result.ts +29 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +29 -0
- package/src/send.reply-fallback.test.ts +189 -0
- package/src/send.test.ts +168 -0
- package/src/send.ts +481 -0
- package/src/streaming-card.test.ts +54 -0
- package/src/streaming-card.ts +374 -0
- package/src/targets.test.ts +70 -0
- package/src/targets.ts +107 -0
- 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/tool-result.test.ts +32 -0
- package/src/tool-result.ts +14 -0
- package/src/tools-config.test.ts +21 -0
- package/src/tools-config.ts +22 -0
- package/src/types.ts +103 -0
- package/src/typing.test.ts +144 -0
- package/src/typing.ts +210 -0
- package/src/wiki-schema.ts +55 -0
- package/src/wiki.ts +233 -0
- package/index.js +0 -27
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
|
|
3
|
+
export type RequestBodyLimitErrorCode =
|
|
4
|
+
| "PAYLOAD_TOO_LARGE"
|
|
5
|
+
| "REQUEST_BODY_TIMEOUT"
|
|
6
|
+
| "CONNECTION_CLOSED";
|
|
7
|
+
|
|
8
|
+
class RequestBodyLimitError extends Error {
|
|
9
|
+
readonly code: RequestBodyLimitErrorCode;
|
|
10
|
+
readonly statusCode: number;
|
|
11
|
+
|
|
12
|
+
constructor(code: RequestBodyLimitErrorCode) {
|
|
13
|
+
super(code);
|
|
14
|
+
this.name = "RequestBodyLimitError";
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.statusCode = code === "PAYLOAD_TOO_LARGE" ? 413 : code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function requestBodyErrorToText(code: RequestBodyLimitErrorCode): string {
|
|
21
|
+
switch (code) {
|
|
22
|
+
case "PAYLOAD_TOO_LARGE":
|
|
23
|
+
return "Payload too large";
|
|
24
|
+
case "REQUEST_BODY_TIMEOUT":
|
|
25
|
+
return "Request body timeout";
|
|
26
|
+
default:
|
|
27
|
+
return "Connection closed";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseContentLengthHeader(req: IncomingMessage): number | null {
|
|
32
|
+
const header = req.headers["content-length"];
|
|
33
|
+
const raw = Array.isArray(header) ? header[0] : header;
|
|
34
|
+
if (typeof raw !== "string") {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const parsed = Number.parseInt(raw, 10);
|
|
38
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readRequestBodyWithLimit(
|
|
42
|
+
req: IncomingMessage,
|
|
43
|
+
options: { maxBytes: number; timeoutMs?: number; encoding?: BufferEncoding },
|
|
44
|
+
): Promise<string> {
|
|
45
|
+
const maxBytes = Math.max(1, Math.floor(options.maxBytes));
|
|
46
|
+
const timeoutMs =
|
|
47
|
+
typeof options.timeoutMs === "number" && options.timeoutMs > 0
|
|
48
|
+
? Math.floor(options.timeoutMs)
|
|
49
|
+
: 30_000;
|
|
50
|
+
const encoding = options.encoding ?? "utf-8";
|
|
51
|
+
|
|
52
|
+
const declaredLength = parseContentLengthHeader(req);
|
|
53
|
+
if (declaredLength !== null && declaredLength > maxBytes) {
|
|
54
|
+
throw new RequestBodyLimitError("PAYLOAD_TOO_LARGE");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return await new Promise((resolve, reject) => {
|
|
58
|
+
let done = false;
|
|
59
|
+
let ended = false;
|
|
60
|
+
let totalBytes = 0;
|
|
61
|
+
const chunks: Buffer[] = [];
|
|
62
|
+
|
|
63
|
+
const finish = (cb: () => void) => {
|
|
64
|
+
if (done) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
done = true;
|
|
68
|
+
req.removeListener("data", onData);
|
|
69
|
+
req.removeListener("end", onEnd);
|
|
70
|
+
req.removeListener("error", onError);
|
|
71
|
+
req.removeListener("close", onClose);
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
cb();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const fail = (error: Error) => finish(() => reject(error));
|
|
77
|
+
|
|
78
|
+
const onData = (chunk: Buffer | string) => {
|
|
79
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
80
|
+
totalBytes += buffer.length;
|
|
81
|
+
if (totalBytes > maxBytes) {
|
|
82
|
+
if (!req.destroyed) {
|
|
83
|
+
req.destroy();
|
|
84
|
+
}
|
|
85
|
+
fail(new RequestBodyLimitError("PAYLOAD_TOO_LARGE"));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
chunks.push(buffer);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const onEnd = () => {
|
|
92
|
+
ended = true;
|
|
93
|
+
finish(() => resolve(Buffer.concat(chunks).toString(encoding)));
|
|
94
|
+
};
|
|
95
|
+
const onError = (error: Error) => fail(error);
|
|
96
|
+
const onClose = () => {
|
|
97
|
+
if (!done && !ended) {
|
|
98
|
+
fail(new RequestBodyLimitError("CONNECTION_CLOSED"));
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
if (!req.destroyed) {
|
|
104
|
+
req.destroy();
|
|
105
|
+
}
|
|
106
|
+
fail(new RequestBodyLimitError("REQUEST_BODY_TIMEOUT"));
|
|
107
|
+
}, timeoutMs);
|
|
108
|
+
|
|
109
|
+
req.on("data", onData);
|
|
110
|
+
req.on("end", onEnd);
|
|
111
|
+
req.on("error", onError);
|
|
112
|
+
req.on("close", onClose);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function readJsonBodyWithLimit(
|
|
117
|
+
req: IncomingMessage,
|
|
118
|
+
options: { maxBytes: number; timeoutMs?: number; emptyObjectOnEmpty?: boolean },
|
|
119
|
+
): Promise<
|
|
120
|
+
| { ok: true; value: unknown }
|
|
121
|
+
| { ok: false; error: string; code: RequestBodyLimitErrorCode | "INVALID_JSON" }
|
|
122
|
+
> {
|
|
123
|
+
try {
|
|
124
|
+
const raw = await readRequestBodyWithLimit(req, options);
|
|
125
|
+
const trimmed = raw.trim();
|
|
126
|
+
if (!trimmed) {
|
|
127
|
+
if (options.emptyObjectOnEmpty === false) {
|
|
128
|
+
return { ok: false, code: "INVALID_JSON", error: "empty payload" };
|
|
129
|
+
}
|
|
130
|
+
return { ok: true, value: {} };
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
return { ok: true, value: JSON.parse(trimmed) as unknown };
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
code: "INVALID_JSON",
|
|
138
|
+
error: error instanceof Error ? error.message : String(error),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error instanceof RequestBodyLimitError) {
|
|
143
|
+
return { ok: false, code: error.code, error: requestBodyErrorToText(error.code) };
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
code: "INVALID_JSON",
|
|
148
|
+
error: error instanceof Error ? error.message : String(error),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function installRequestBodyLimitGuard(
|
|
154
|
+
req: IncomingMessage,
|
|
155
|
+
res: ServerResponse,
|
|
156
|
+
options: { maxBytes: number; timeoutMs?: number; responseFormat?: "json" | "text" },
|
|
157
|
+
) {
|
|
158
|
+
const maxBytes = Math.max(1, Math.floor(options.maxBytes));
|
|
159
|
+
const timeoutMs =
|
|
160
|
+
typeof options.timeoutMs === "number" && options.timeoutMs > 0
|
|
161
|
+
? Math.floor(options.timeoutMs)
|
|
162
|
+
: 30_000;
|
|
163
|
+
const responseFormat = options.responseFormat ?? "json";
|
|
164
|
+
|
|
165
|
+
let tripped = false;
|
|
166
|
+
let code: RequestBodyLimitErrorCode | null = null;
|
|
167
|
+
let done = false;
|
|
168
|
+
let ended = false;
|
|
169
|
+
let totalBytes = 0;
|
|
170
|
+
|
|
171
|
+
const finish = () => {
|
|
172
|
+
if (done) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
done = true;
|
|
176
|
+
req.removeListener("data", onData);
|
|
177
|
+
req.removeListener("end", onEnd);
|
|
178
|
+
req.removeListener("close", onClose);
|
|
179
|
+
req.removeListener("error", onError);
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const respond = (error: RequestBodyLimitError) => {
|
|
184
|
+
if (res.headersSent) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const text = requestBodyErrorToText(error.code);
|
|
188
|
+
res.statusCode = error.statusCode;
|
|
189
|
+
if (responseFormat === "text") {
|
|
190
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
191
|
+
res.end(text);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
195
|
+
res.end(JSON.stringify({ error: text }));
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const trip = (error: RequestBodyLimitError) => {
|
|
199
|
+
if (tripped) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
tripped = true;
|
|
203
|
+
code = error.code;
|
|
204
|
+
finish();
|
|
205
|
+
respond(error);
|
|
206
|
+
if (!req.destroyed) {
|
|
207
|
+
req.destroy();
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const onData = (chunk: Buffer | string) => {
|
|
212
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
213
|
+
totalBytes += buffer.length;
|
|
214
|
+
if (totalBytes > maxBytes) {
|
|
215
|
+
trip(new RequestBodyLimitError("PAYLOAD_TOO_LARGE"));
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
const onEnd = () => {
|
|
219
|
+
ended = true;
|
|
220
|
+
finish();
|
|
221
|
+
};
|
|
222
|
+
const onClose = () => {
|
|
223
|
+
if (!ended) {
|
|
224
|
+
finish();
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
const onError = () => finish();
|
|
228
|
+
const timer = setTimeout(() => trip(new RequestBodyLimitError("REQUEST_BODY_TIMEOUT")), timeoutMs);
|
|
229
|
+
|
|
230
|
+
req.on("data", onData);
|
|
231
|
+
req.on("end", onEnd);
|
|
232
|
+
req.on("close", onClose);
|
|
233
|
+
req.on("error", onError);
|
|
234
|
+
|
|
235
|
+
const declaredLength = parseContentLengthHeader(req);
|
|
236
|
+
if (declaredLength !== null && declaredLength > maxBytes) {
|
|
237
|
+
trip(new RequestBodyLimitError("PAYLOAD_TOO_LARGE"));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
dispose: finish,
|
|
242
|
+
isTripped: () => tripped,
|
|
243
|
+
code: () => code,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
function sanitizePrefix(prefix: string): string {
|
|
7
|
+
const normalized = prefix.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
8
|
+
return normalized || "tmp";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function sanitizeFileName(fileName: string): string {
|
|
12
|
+
const normalized = path.basename(fileName).replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
13
|
+
return normalized.replace(/^-+|-+$/g, "") || "download.bin";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function resolveTempRoot(tmpDir?: string): string {
|
|
17
|
+
return tmpDir ?? process.env.NEXTCLAW_TMP_DIR?.trim() ?? os.tmpdir();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function withTempDownloadPath<T>(
|
|
21
|
+
params: {
|
|
22
|
+
prefix: string;
|
|
23
|
+
fileName?: string;
|
|
24
|
+
tmpDir?: string;
|
|
25
|
+
},
|
|
26
|
+
fn: (tmpPath: string) => Promise<T>,
|
|
27
|
+
): Promise<T> {
|
|
28
|
+
const root = resolveTempRoot(params.tmpDir);
|
|
29
|
+
const dir = await mkdtemp(path.join(root, `${sanitizePrefix(params.prefix)}-`));
|
|
30
|
+
const tempPath = path.join(dir, sanitizeFileName(params.fileName ?? "download.bin"));
|
|
31
|
+
try {
|
|
32
|
+
return await fn(tempPath);
|
|
33
|
+
} finally {
|
|
34
|
+
try {
|
|
35
|
+
await rm(dir, { recursive: true, force: true });
|
|
36
|
+
} catch {}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function fetchWithSsrFGuard(params: {
|
|
41
|
+
url: string;
|
|
42
|
+
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
43
|
+
init?: RequestInit;
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
policy?: {
|
|
47
|
+
allowedHostnames?: string[];
|
|
48
|
+
};
|
|
49
|
+
auditContext?: string;
|
|
50
|
+
}): Promise<{
|
|
51
|
+
response: Response;
|
|
52
|
+
finalUrl: string;
|
|
53
|
+
release: () => Promise<void>;
|
|
54
|
+
}> {
|
|
55
|
+
const fetcher = params.fetchImpl ?? globalThis.fetch;
|
|
56
|
+
if (!fetcher) {
|
|
57
|
+
throw new Error("fetch is not available");
|
|
58
|
+
}
|
|
59
|
+
const parsed = new URL(params.url);
|
|
60
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
61
|
+
throw new Error("Invalid URL: must be http or https");
|
|
62
|
+
}
|
|
63
|
+
const allowedHostnames = params.policy?.allowedHostnames?.map((entry) => entry.trim()).filter(Boolean) ?? [];
|
|
64
|
+
if (allowedHostnames.length > 0 && !allowedHostnames.includes(parsed.hostname)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`${params.auditContext ?? "guarded-fetch"} blocked hostname "${parsed.hostname}"`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
const timeoutId =
|
|
72
|
+
params.timeoutMs && params.timeoutMs > 0
|
|
73
|
+
? setTimeout(() => controller.abort(), params.timeoutMs)
|
|
74
|
+
: undefined;
|
|
75
|
+
const relay = () => controller.abort();
|
|
76
|
+
if (params.signal) {
|
|
77
|
+
if (params.signal.aborted) {
|
|
78
|
+
controller.abort();
|
|
79
|
+
} else {
|
|
80
|
+
params.signal.addEventListener("abort", relay, { once: true });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const response = await fetcher(parsed.toString(), {
|
|
85
|
+
...(params.init ?? {}),
|
|
86
|
+
signal: controller.signal,
|
|
87
|
+
});
|
|
88
|
+
const finalUrl = response.url || parsed.toString();
|
|
89
|
+
const finalHostname = new URL(finalUrl).hostname;
|
|
90
|
+
if (allowedHostnames.length > 0 && !allowedHostnames.includes(finalHostname)) {
|
|
91
|
+
clearTimeout(timeoutId);
|
|
92
|
+
if (params.signal) {
|
|
93
|
+
params.signal.removeEventListener("abort", relay);
|
|
94
|
+
}
|
|
95
|
+
throw new Error(
|
|
96
|
+
`${params.auditContext ?? "guarded-fetch"} blocked redirected hostname "${finalHostname}"`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
response,
|
|
102
|
+
finalUrl,
|
|
103
|
+
release: async () => {
|
|
104
|
+
clearTimeout(timeoutId);
|
|
105
|
+
if (params.signal) {
|
|
106
|
+
params.signal.removeEventListener("abort", relay);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function buildRandomTempFilePath(params: {
|
|
113
|
+
prefix: string;
|
|
114
|
+
extension?: string;
|
|
115
|
+
tmpDir?: string;
|
|
116
|
+
now?: number;
|
|
117
|
+
uuid?: string;
|
|
118
|
+
}): string {
|
|
119
|
+
const prefix = sanitizePrefix(params.prefix);
|
|
120
|
+
const extension = params.extension
|
|
121
|
+
? `.${params.extension.replace(/^\.+/, "").replace(/[^a-zA-Z0-9._-]+/g, "")}`
|
|
122
|
+
: "";
|
|
123
|
+
const now =
|
|
124
|
+
typeof params.now === "number" && Number.isFinite(params.now)
|
|
125
|
+
? Math.trunc(params.now)
|
|
126
|
+
: Date.now();
|
|
127
|
+
const uuid = params.uuid?.trim() || crypto.randomUUID();
|
|
128
|
+
return path.join(resolveTempRoot(params.tmpDir), `${prefix}-${now}-${uuid}${extension}`);
|
|
129
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
|
|
3
|
+
function pruneMapToMaxSize<K, V>(map: Map<K, V>, maxSize: number): void {
|
|
4
|
+
while (map.size > maxSize) {
|
|
5
|
+
const firstKey = map.keys().next().value;
|
|
6
|
+
if (firstKey === undefined) {
|
|
7
|
+
break;
|
|
8
|
+
}
|
|
9
|
+
map.delete(firstKey);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const WEBHOOK_RATE_LIMIT_DEFAULTS = Object.freeze({
|
|
14
|
+
windowMs: 60_000,
|
|
15
|
+
maxRequests: 120,
|
|
16
|
+
maxTrackedKeys: 4_096,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const WEBHOOK_ANOMALY_COUNTER_DEFAULTS = Object.freeze({
|
|
20
|
+
maxTrackedKeys: 4_096,
|
|
21
|
+
ttlMs: 6 * 60 * 60_000,
|
|
22
|
+
logEvery: 25,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export function createFixedWindowRateLimiter(options: {
|
|
26
|
+
windowMs: number;
|
|
27
|
+
maxRequests: number;
|
|
28
|
+
maxTrackedKeys: number;
|
|
29
|
+
pruneIntervalMs?: number;
|
|
30
|
+
}) {
|
|
31
|
+
const state = new Map<string, { count: number; windowStartMs: number }>();
|
|
32
|
+
const windowMs = Math.max(1, Math.floor(options.windowMs));
|
|
33
|
+
const maxRequests = Math.max(1, Math.floor(options.maxRequests));
|
|
34
|
+
const maxTrackedKeys = Math.max(1, Math.floor(options.maxTrackedKeys));
|
|
35
|
+
const pruneIntervalMs = Math.max(1, Math.floor(options.pruneIntervalMs ?? windowMs));
|
|
36
|
+
let lastPruneMs = 0;
|
|
37
|
+
|
|
38
|
+
const touch = (key: string, value: { count: number; windowStartMs: number }) => {
|
|
39
|
+
state.delete(key);
|
|
40
|
+
state.set(key, value);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const prune = (nowMs: number) => {
|
|
44
|
+
for (const [key, entry] of state) {
|
|
45
|
+
if (nowMs - entry.windowStartMs >= windowMs) {
|
|
46
|
+
state.delete(key);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
isRateLimited(key: string, nowMs = Date.now()): boolean {
|
|
53
|
+
if (!key) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (nowMs - lastPruneMs >= pruneIntervalMs) {
|
|
57
|
+
prune(nowMs);
|
|
58
|
+
lastPruneMs = nowMs;
|
|
59
|
+
}
|
|
60
|
+
const existing = state.get(key);
|
|
61
|
+
if (!existing || nowMs - existing.windowStartMs >= windowMs) {
|
|
62
|
+
touch(key, { count: 1, windowStartMs: nowMs });
|
|
63
|
+
pruneMapToMaxSize(state, maxTrackedKeys);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const nextCount = existing.count + 1;
|
|
67
|
+
touch(key, { count: nextCount, windowStartMs: existing.windowStartMs });
|
|
68
|
+
pruneMapToMaxSize(state, maxTrackedKeys);
|
|
69
|
+
return nextCount > maxRequests;
|
|
70
|
+
},
|
|
71
|
+
size: () => state.size,
|
|
72
|
+
clear: () => {
|
|
73
|
+
state.clear();
|
|
74
|
+
lastPruneMs = 0;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createWebhookAnomalyTracker(options?: {
|
|
80
|
+
maxTrackedKeys?: number;
|
|
81
|
+
ttlMs?: number;
|
|
82
|
+
logEvery?: number;
|
|
83
|
+
trackedStatusCodes?: readonly number[];
|
|
84
|
+
}) {
|
|
85
|
+
const trackedStatusCodes = new Set(options?.trackedStatusCodes ?? [400, 401, 408, 413, 415, 429]);
|
|
86
|
+
const counters = new Map<string, { count: number; updatedAtMs: number }>();
|
|
87
|
+
const maxTrackedKeys = Math.max(
|
|
88
|
+
1,
|
|
89
|
+
Math.floor(options?.maxTrackedKeys ?? WEBHOOK_ANOMALY_COUNTER_DEFAULTS.maxTrackedKeys),
|
|
90
|
+
);
|
|
91
|
+
const ttlMs = Math.max(
|
|
92
|
+
0,
|
|
93
|
+
Math.floor(options?.ttlMs ?? WEBHOOK_ANOMALY_COUNTER_DEFAULTS.ttlMs),
|
|
94
|
+
);
|
|
95
|
+
const logEvery = Math.max(
|
|
96
|
+
1,
|
|
97
|
+
Math.floor(options?.logEvery ?? WEBHOOK_ANOMALY_COUNTER_DEFAULTS.logEvery),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const prune = (nowMs: number) => {
|
|
101
|
+
if (ttlMs <= 0) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
for (const [key, entry] of counters) {
|
|
105
|
+
if (nowMs - entry.updatedAtMs >= ttlMs) {
|
|
106
|
+
counters.delete(key);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
record(params: {
|
|
113
|
+
key: string;
|
|
114
|
+
statusCode: number;
|
|
115
|
+
message: (count: number) => string;
|
|
116
|
+
log?: (message: string) => void;
|
|
117
|
+
nowMs?: number;
|
|
118
|
+
}): number {
|
|
119
|
+
if (!trackedStatusCodes.has(params.statusCode)) {
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
const nowMs = params.nowMs ?? Date.now();
|
|
123
|
+
prune(nowMs);
|
|
124
|
+
const existing = counters.get(params.key);
|
|
125
|
+
const nextCount = (existing?.count ?? 0) + 1;
|
|
126
|
+
counters.set(params.key, { count: nextCount, updatedAtMs: nowMs });
|
|
127
|
+
pruneMapToMaxSize(counters, maxTrackedKeys);
|
|
128
|
+
if (params.log && (nextCount === 1 || nextCount % logEvery === 0)) {
|
|
129
|
+
params.log(params.message(nextCount));
|
|
130
|
+
}
|
|
131
|
+
return nextCount;
|
|
132
|
+
},
|
|
133
|
+
size: () => counters.size,
|
|
134
|
+
clear: () => counters.clear(),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isJsonContentType(value: string | string[] | undefined): boolean {
|
|
139
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
140
|
+
if (!first) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
const mediaType = first.split(";", 1)[0]?.trim().toLowerCase();
|
|
144
|
+
return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json"));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function applyBasicWebhookRequestGuards(params: {
|
|
148
|
+
req: IncomingMessage;
|
|
149
|
+
res: ServerResponse;
|
|
150
|
+
allowMethods?: readonly string[];
|
|
151
|
+
rateLimiter?: { isRateLimited: (key: string, nowMs?: number) => boolean };
|
|
152
|
+
rateLimitKey?: string;
|
|
153
|
+
nowMs?: number;
|
|
154
|
+
requireJsonContentType?: boolean;
|
|
155
|
+
}): boolean {
|
|
156
|
+
const allowMethods = params.allowMethods?.length ? params.allowMethods : null;
|
|
157
|
+
if (allowMethods && !allowMethods.includes(params.req.method ?? "")) {
|
|
158
|
+
params.res.statusCode = 405;
|
|
159
|
+
params.res.setHeader("Allow", allowMethods.join(", "));
|
|
160
|
+
params.res.end("Method Not Allowed");
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
if (
|
|
164
|
+
params.rateLimiter &&
|
|
165
|
+
params.rateLimitKey &&
|
|
166
|
+
params.rateLimiter.isRateLimited(params.rateLimitKey, params.nowMs ?? Date.now())
|
|
167
|
+
) {
|
|
168
|
+
params.res.statusCode = 429;
|
|
169
|
+
params.res.end("Too Many Requests");
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
if (
|
|
173
|
+
params.requireJsonContentType &&
|
|
174
|
+
params.req.method === "POST" &&
|
|
175
|
+
!isJsonContentType(params.req.headers["content-type"])
|
|
176
|
+
) {
|
|
177
|
+
params.res.statusCode = 415;
|
|
178
|
+
params.res.end("Unsupported Media Type");
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export {
|
|
2
|
+
buildRandomTempFilePath,
|
|
3
|
+
fetchWithSsrFGuard,
|
|
4
|
+
withTempDownloadPath,
|
|
5
|
+
} from "./network-fetch.js";
|
|
6
|
+
export {
|
|
7
|
+
applyBasicWebhookRequestGuards,
|
|
8
|
+
createFixedWindowRateLimiter,
|
|
9
|
+
createWebhookAnomalyTracker,
|
|
10
|
+
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
|
|
11
|
+
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
|
12
|
+
} from "./network-webhook.js";
|
|
13
|
+
export { installRequestBodyLimitGuard, readJsonBodyWithLimit } from "./network-body.js";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function createPluginRuntimeStore<T>(errorMessage: string): {
|
|
2
|
+
setRuntime: (next: T) => void;
|
|
3
|
+
clearRuntime: () => void;
|
|
4
|
+
tryGetRuntime: () => T | null;
|
|
5
|
+
getRuntime: () => T;
|
|
6
|
+
} {
|
|
7
|
+
let runtime: T | null = null;
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
setRuntime(next: T) {
|
|
11
|
+
runtime = next;
|
|
12
|
+
},
|
|
13
|
+
clearRuntime() {
|
|
14
|
+
runtime = null;
|
|
15
|
+
},
|
|
16
|
+
tryGetRuntime() {
|
|
17
|
+
return runtime;
|
|
18
|
+
},
|
|
19
|
+
getRuntime() {
|
|
20
|
+
if (!runtime) {
|
|
21
|
+
throw new Error(errorMessage);
|
|
22
|
+
}
|
|
23
|
+
return runtime;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ClawdbotConfig, DmPolicy, GroupPolicy } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function mergeAllowFromEntries(
|
|
4
|
+
current: Array<string | number> | null | undefined,
|
|
5
|
+
additions: Array<string | number>,
|
|
6
|
+
): string[] {
|
|
7
|
+
const merged = [...(current ?? []), ...additions].map((entry) => String(entry).trim()).filter(Boolean);
|
|
8
|
+
return [...new Set(merged)];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function splitOnboardingEntries(raw: string): string[] {
|
|
12
|
+
return raw
|
|
13
|
+
.split(/[\n,;]+/g)
|
|
14
|
+
.map((entry) => entry.trim())
|
|
15
|
+
.filter(Boolean);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function patchTopLevelChannelConfig(params: {
|
|
19
|
+
cfg: ClawdbotConfig;
|
|
20
|
+
channel: string;
|
|
21
|
+
enabled?: boolean;
|
|
22
|
+
patch: Record<string, unknown>;
|
|
23
|
+
}): ClawdbotConfig {
|
|
24
|
+
const channelConfig =
|
|
25
|
+
(params.cfg.channels?.[params.channel] as Record<string, unknown> | undefined) ?? {};
|
|
26
|
+
return {
|
|
27
|
+
...params.cfg,
|
|
28
|
+
channels: {
|
|
29
|
+
...params.cfg.channels,
|
|
30
|
+
[params.channel]: {
|
|
31
|
+
...channelConfig,
|
|
32
|
+
...(params.enabled ? { enabled: true } : {}),
|
|
33
|
+
...params.patch,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function addWildcardAllowFrom(allowFrom?: Array<string | number> | null): string[] {
|
|
40
|
+
const next = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
|
41
|
+
if (!next.includes("*")) {
|
|
42
|
+
next.push("*");
|
|
43
|
+
}
|
|
44
|
+
return next;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function setTopLevelChannelAllowFrom(params: {
|
|
48
|
+
cfg: ClawdbotConfig;
|
|
49
|
+
channel: string;
|
|
50
|
+
allowFrom: string[];
|
|
51
|
+
enabled?: boolean;
|
|
52
|
+
}): ClawdbotConfig {
|
|
53
|
+
return patchTopLevelChannelConfig({
|
|
54
|
+
cfg: params.cfg,
|
|
55
|
+
channel: params.channel,
|
|
56
|
+
enabled: params.enabled,
|
|
57
|
+
patch: { allowFrom: params.allowFrom },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function setTopLevelChannelDmPolicyWithAllowFrom(params: {
|
|
62
|
+
cfg: ClawdbotConfig;
|
|
63
|
+
channel: string;
|
|
64
|
+
dmPolicy: DmPolicy;
|
|
65
|
+
getAllowFrom?: (cfg: ClawdbotConfig) => Array<string | number> | undefined;
|
|
66
|
+
}): ClawdbotConfig {
|
|
67
|
+
const channelConfig =
|
|
68
|
+
(params.cfg.channels?.[params.channel] as Record<string, unknown> | undefined) ?? {};
|
|
69
|
+
const existingAllowFrom =
|
|
70
|
+
params.getAllowFrom?.(params.cfg) ??
|
|
71
|
+
(channelConfig.allowFrom as Array<string | number> | undefined) ??
|
|
72
|
+
undefined;
|
|
73
|
+
const allowFrom = params.dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined;
|
|
74
|
+
return patchTopLevelChannelConfig({
|
|
75
|
+
cfg: params.cfg,
|
|
76
|
+
channel: params.channel,
|
|
77
|
+
patch: {
|
|
78
|
+
dmPolicy: params.dmPolicy,
|
|
79
|
+
...(allowFrom ? { allowFrom } : {}),
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function setTopLevelChannelGroupPolicy(params: {
|
|
85
|
+
cfg: ClawdbotConfig;
|
|
86
|
+
channel: string;
|
|
87
|
+
groupPolicy: GroupPolicy;
|
|
88
|
+
enabled?: boolean;
|
|
89
|
+
}): ClawdbotConfig {
|
|
90
|
+
return patchTopLevelChannelConfig({
|
|
91
|
+
cfg: params.cfg,
|
|
92
|
+
channel: params.channel,
|
|
93
|
+
enabled: params.enabled,
|
|
94
|
+
patch: { groupPolicy: params.groupPolicy },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function buildSingleChannelSecretPromptState(params: {
|
|
99
|
+
accountConfigured: boolean;
|
|
100
|
+
hasConfigToken: boolean;
|
|
101
|
+
allowEnv: boolean;
|
|
102
|
+
envValue?: string;
|
|
103
|
+
}) {
|
|
104
|
+
return {
|
|
105
|
+
accountConfigured: params.accountConfigured,
|
|
106
|
+
hasConfigToken: params.hasConfigToken,
|
|
107
|
+
canUseEnv: params.allowEnv && Boolean(params.envValue?.trim()) && !params.hasConfigToken,
|
|
108
|
+
};
|
|
109
|
+
}
|