@nextclaw/channel-plugin-feishu 0.2.14 → 0.2.16
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 -2
- package/package.json +1 -2
- package/src/accounts.test.ts +10 -0
- package/src/accounts.ts +12 -12
- package/src/bitable.ts +1 -1
- package/src/bot.test.ts +1 -1
- package/src/bot.ts +2 -2
- package/src/card-action.ts +1 -1
- package/src/channel.test.ts +1 -1
- package/src/channel.ts +3 -3
- package/src/chat.ts +1 -1
- package/src/config-schema.ts +1 -1
- package/src/dedup.ts +1 -1
- package/src/directory.test.ts +1 -1
- package/src/directory.ts +2 -2
- package/src/docx.account-selection.test.ts +1 -1
- package/src/docx.ts +1 -1
- package/src/drive.ts +1 -1
- package/src/dynamic-agent.ts +1 -1
- package/src/media.ts +1 -1
- package/src/monitor.account.ts +1 -1
- package/src/monitor.reaction.test.ts +1 -1
- package/src/monitor.startup.test.ts +1 -1
- package/src/monitor.startup.ts +1 -1
- package/src/monitor.state.ts +1 -1
- package/src/monitor.transport.ts +1 -1
- package/src/monitor.ts +1 -1
- package/src/monitor.webhook.test-helpers.ts +1 -1
- 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 +1 -1
- package/src/onboarding.ts +2 -2
- package/src/outbound.ts +1 -1
- package/src/perm.ts +1 -1
- package/src/policy.ts +2 -2
- package/src/reactions.ts +1 -1
- package/src/reply-dispatcher.ts +1 -1
- package/src/runtime.ts +2 -2
- package/src/secret-input.ts +1 -1
- package/src/send-target.test.ts +1 -1
- package/src/send-target.ts +1 -1
- package/src/send.test.ts +1 -1
- package/src/send.ts +1 -1
- package/src/streaming-card.ts +1 -1
- package/src/tool-account-routing.test.ts +1 -1
- package/src/tool-account.ts +1 -1
- package/src/tool-factory-test-harness.ts +1 -1
- package/src/types.ts +1 -1
- package/src/typing.ts +1 -1
- package/src/wiki.ts +1 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
function pruneMapToMaxSize<K, V>(map: Map<K, V>, maxSize: number): void {
|
|
5
|
+
while (map.size > maxSize) {
|
|
6
|
+
const firstKey = map.keys().next().value;
|
|
7
|
+
if (firstKey === undefined) {
|
|
8
|
+
break;
|
|
9
|
+
}
|
|
10
|
+
map.delete(firstKey);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createDedupeCache(options: {
|
|
15
|
+
ttlMs: number;
|
|
16
|
+
maxSize: number;
|
|
17
|
+
}): {
|
|
18
|
+
check: (key: string | undefined | null, now?: number) => boolean;
|
|
19
|
+
peek: (key: string | undefined | null, now?: number) => boolean;
|
|
20
|
+
delete: (key: string | undefined | null) => void;
|
|
21
|
+
clear: () => void;
|
|
22
|
+
size: () => number;
|
|
23
|
+
} {
|
|
24
|
+
const ttlMs = Math.max(0, options.ttlMs);
|
|
25
|
+
const maxSize = Math.max(0, Math.floor(options.maxSize));
|
|
26
|
+
const cache = new Map<string, number>();
|
|
27
|
+
|
|
28
|
+
const touch = (key: string, now: number) => {
|
|
29
|
+
cache.delete(key);
|
|
30
|
+
cache.set(key, now);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const prune = (now: number) => {
|
|
34
|
+
const cutoff = ttlMs > 0 ? now - ttlMs : undefined;
|
|
35
|
+
if (cutoff !== undefined) {
|
|
36
|
+
for (const [key, seenAt] of cache) {
|
|
37
|
+
if (seenAt < cutoff) {
|
|
38
|
+
cache.delete(key);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (maxSize <= 0) {
|
|
43
|
+
cache.clear();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
pruneMapToMaxSize(cache, maxSize);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const hasUnexpired = (key: string, now: number, touchOnRead: boolean): boolean => {
|
|
50
|
+
const seenAt = cache.get(key);
|
|
51
|
+
if (seenAt === undefined) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (ttlMs > 0 && now - seenAt >= ttlMs) {
|
|
55
|
+
cache.delete(key);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (touchOnRead) {
|
|
59
|
+
touch(key, now);
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
check: (key, now = Date.now()) => {
|
|
66
|
+
if (!key) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
if (hasUnexpired(key, now, true)) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
touch(key, now);
|
|
73
|
+
prune(now);
|
|
74
|
+
return false;
|
|
75
|
+
},
|
|
76
|
+
peek: (key, now = Date.now()) => {
|
|
77
|
+
if (!key) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
return hasUnexpired(key, now, false);
|
|
81
|
+
},
|
|
82
|
+
delete: (key) => {
|
|
83
|
+
if (key) {
|
|
84
|
+
cache.delete(key);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
clear: () => cache.clear(),
|
|
88
|
+
size: () => cache.size,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function readJsonFileWithFallback<T>(
|
|
93
|
+
filePath: string,
|
|
94
|
+
fallback: T,
|
|
95
|
+
): Promise<{ value: T; exists: boolean }> {
|
|
96
|
+
try {
|
|
97
|
+
const raw = await fs.promises.readFile(filePath, "utf-8");
|
|
98
|
+
return { value: JSON.parse(raw) as T, exists: true };
|
|
99
|
+
} catch (error) {
|
|
100
|
+
const code = (error as { code?: string }).code;
|
|
101
|
+
if (code === "ENOENT") {
|
|
102
|
+
return { value: fallback, exists: false };
|
|
103
|
+
}
|
|
104
|
+
return { value: fallback, exists: false };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function writeJsonFileAtomically(filePath: string, value: unknown): Promise<void> {
|
|
109
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
110
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
111
|
+
await fs.promises.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
112
|
+
await fs.promises.rename(tempPath, filePath);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function createPersistentDedupe(options: {
|
|
116
|
+
ttlMs: number;
|
|
117
|
+
memoryMaxSize: number;
|
|
118
|
+
fileMaxEntries: number;
|
|
119
|
+
resolveFilePath: (namespace: string) => string;
|
|
120
|
+
onDiskError?: (error: unknown) => void;
|
|
121
|
+
}) {
|
|
122
|
+
const ttlMs = Math.max(0, Math.floor(options.ttlMs));
|
|
123
|
+
const fileMaxEntries = Math.max(1, Math.floor(options.fileMaxEntries));
|
|
124
|
+
const memory = createDedupeCache({
|
|
125
|
+
ttlMs,
|
|
126
|
+
maxSize: Math.max(0, Math.floor(options.memoryMaxSize)),
|
|
127
|
+
});
|
|
128
|
+
const inflight = new Map<string, Promise<boolean>>();
|
|
129
|
+
|
|
130
|
+
const sanitize = (value: unknown): Record<string, number> => {
|
|
131
|
+
if (!value || typeof value !== "object") {
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
const out: Record<string, number> = {};
|
|
135
|
+
for (const [key, timestamp] of Object.entries(value as Record<string, unknown>)) {
|
|
136
|
+
if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {
|
|
137
|
+
out[key] = timestamp;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const pruneData = (data: Record<string, number>, now: number) => {
|
|
144
|
+
if (ttlMs > 0) {
|
|
145
|
+
for (const [key, timestamp] of Object.entries(data)) {
|
|
146
|
+
if (now - timestamp >= ttlMs) {
|
|
147
|
+
delete data[key];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const keys = Object.keys(data);
|
|
152
|
+
if (keys.length > fileMaxEntries) {
|
|
153
|
+
keys
|
|
154
|
+
.toSorted((left, right) => data[left] - data[right])
|
|
155
|
+
.slice(0, keys.length - fileMaxEntries)
|
|
156
|
+
.forEach((key) => {
|
|
157
|
+
delete data[key];
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const checkAndRecordInner = async (
|
|
163
|
+
key: string,
|
|
164
|
+
namespace: string,
|
|
165
|
+
scopedKey: string,
|
|
166
|
+
now: number,
|
|
167
|
+
onDiskError?: (error: unknown) => void,
|
|
168
|
+
): Promise<boolean> => {
|
|
169
|
+
if (memory.check(scopedKey, now)) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const filePath = options.resolveFilePath(namespace);
|
|
174
|
+
try {
|
|
175
|
+
const { value } = await readJsonFileWithFallback<Record<string, number>>(filePath, {});
|
|
176
|
+
const data = sanitize(value);
|
|
177
|
+
const seenAt = data[key];
|
|
178
|
+
if (seenAt != null && (ttlMs <= 0 || now - seenAt < ttlMs)) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
data[key] = now;
|
|
182
|
+
pruneData(data, now);
|
|
183
|
+
await writeJsonFileAtomically(filePath, data);
|
|
184
|
+
return true;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
onDiskError?.(error);
|
|
187
|
+
memory.check(scopedKey, now);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
async checkAndRecord(
|
|
194
|
+
key: string,
|
|
195
|
+
dedupeOptions?: {
|
|
196
|
+
namespace?: string;
|
|
197
|
+
now?: number;
|
|
198
|
+
onDiskError?: (error: unknown) => void;
|
|
199
|
+
},
|
|
200
|
+
): Promise<boolean> {
|
|
201
|
+
const trimmed = key.trim();
|
|
202
|
+
if (!trimmed) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
const namespace = dedupeOptions?.namespace?.trim() || "global";
|
|
206
|
+
const scopedKey = `${namespace}:${trimmed}`;
|
|
207
|
+
if (inflight.has(scopedKey)) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
const work = checkAndRecordInner(
|
|
211
|
+
trimmed,
|
|
212
|
+
namespace,
|
|
213
|
+
scopedKey,
|
|
214
|
+
dedupeOptions?.now ?? Date.now(),
|
|
215
|
+
dedupeOptions?.onDiskError ?? options.onDiskError,
|
|
216
|
+
);
|
|
217
|
+
inflight.set(scopedKey, work);
|
|
218
|
+
try {
|
|
219
|
+
return await work;
|
|
220
|
+
} finally {
|
|
221
|
+
inflight.delete(scopedKey);
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
async warmup(namespace = "global", onError?: (error: unknown) => void): Promise<number> {
|
|
225
|
+
const filePath = options.resolveFilePath(namespace);
|
|
226
|
+
try {
|
|
227
|
+
const { value } = await readJsonFileWithFallback<Record<string, number>>(filePath, {});
|
|
228
|
+
const now = Date.now();
|
|
229
|
+
let loaded = 0;
|
|
230
|
+
for (const [key, timestamp] of Object.entries(sanitize(value))) {
|
|
231
|
+
if (ttlMs > 0 && now - timestamp >= ttlMs) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
memory.check(`${namespace}:${key}`, timestamp);
|
|
235
|
+
loaded += 1;
|
|
236
|
+
}
|
|
237
|
+
return loaded;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
onError?.(error);
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
clearMemory: () => memory.clear(),
|
|
244
|
+
memorySize: () => memory.size(),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
AllowlistMatch,
|
|
3
|
+
AnyAgentTool,
|
|
4
|
+
BaseProbeResult,
|
|
5
|
+
ChannelGroupContext,
|
|
6
|
+
ChannelMeta,
|
|
7
|
+
ChannelOnboardingAdapter,
|
|
8
|
+
ChannelOnboardingDmPolicy,
|
|
9
|
+
ChannelOutboundAdapter,
|
|
10
|
+
ChannelPlugin,
|
|
11
|
+
ClawdbotConfig,
|
|
12
|
+
DmPolicy,
|
|
13
|
+
GroupToolPolicyConfig,
|
|
14
|
+
HistoryEntry,
|
|
15
|
+
OpenClawConfig,
|
|
16
|
+
OpenClawPluginApi,
|
|
17
|
+
PluginRuntime,
|
|
18
|
+
ReplyPayload,
|
|
19
|
+
RuntimeEnv,
|
|
20
|
+
SecretInput,
|
|
21
|
+
WizardPrompter,
|
|
22
|
+
} from "./types.js";
|
|
23
|
+
export {
|
|
24
|
+
buildAgentMediaPayload,
|
|
25
|
+
buildProbeChannelStatusSummary,
|
|
26
|
+
buildRuntimeAccountStatusSnapshot,
|
|
27
|
+
collectAllowlistProviderRestrictSendersWarnings,
|
|
28
|
+
createDefaultChannelRuntimeState,
|
|
29
|
+
createReplyPrefixContext,
|
|
30
|
+
createScopedPairingAccess,
|
|
31
|
+
createTypingCallbacks,
|
|
32
|
+
emptyPluginConfigSchema,
|
|
33
|
+
evaluateSenderGroupAccessForPolicy,
|
|
34
|
+
formatAllowFromLowercase,
|
|
35
|
+
formatDocsLink,
|
|
36
|
+
issuePairingChallenge,
|
|
37
|
+
listDirectoryGroupEntriesFromMapKeysAndAllowFrom,
|
|
38
|
+
listDirectoryUserEntriesFromAllowFromAndMapKeys,
|
|
39
|
+
logTypingFailure,
|
|
40
|
+
mapAllowFromEntries,
|
|
41
|
+
PAIRING_APPROVED_MESSAGE,
|
|
42
|
+
resolveDefaultGroupPolicy,
|
|
43
|
+
resolveOpenProviderRuntimeGroupPolicy,
|
|
44
|
+
warnMissingProviderGroupPolicyFallbackOnce,
|
|
45
|
+
} from "./core.js";
|
|
46
|
+
export { DEFAULT_ACCOUNT_ID, normalizeAgentId } from "./account-id.js";
|
|
47
|
+
export { createDedupeCache, createPersistentDedupe, readJsonFileWithFallback } from "./dedupe.js";
|
|
48
|
+
export {
|
|
49
|
+
buildPendingHistoryContextFromMap,
|
|
50
|
+
clearHistoryEntriesIfEnabled,
|
|
51
|
+
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
52
|
+
recordPendingHistoryEntryIfEnabled,
|
|
53
|
+
} from "./history.js";
|
|
54
|
+
export {
|
|
55
|
+
applyBasicWebhookRequestGuards,
|
|
56
|
+
createFixedWindowRateLimiter,
|
|
57
|
+
createWebhookAnomalyTracker,
|
|
58
|
+
fetchWithSsrFGuard,
|
|
59
|
+
installRequestBodyLimitGuard,
|
|
60
|
+
readJsonBodyWithLimit,
|
|
61
|
+
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
|
|
62
|
+
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
|
63
|
+
withTempDownloadPath,
|
|
64
|
+
} from "./network.js";
|
|
65
|
+
export {
|
|
66
|
+
buildSecretInputSchema,
|
|
67
|
+
buildSingleChannelSecretPromptState,
|
|
68
|
+
hasConfiguredSecretInput,
|
|
69
|
+
mergeAllowFromEntries,
|
|
70
|
+
normalizeResolvedSecretInputString,
|
|
71
|
+
normalizeSecretInputString,
|
|
72
|
+
promptSingleChannelSecretInput,
|
|
73
|
+
setTopLevelChannelAllowFrom,
|
|
74
|
+
setTopLevelChannelDmPolicyWithAllowFrom,
|
|
75
|
+
setTopLevelChannelGroupPolicy,
|
|
76
|
+
splitOnboardingEntries,
|
|
77
|
+
} from "./secrets.js";
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { HistoryEntry } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export const HISTORY_CONTEXT_MARKER = "[Chat messages since your last reply - for context]";
|
|
4
|
+
export const CURRENT_MESSAGE_MARKER = "[Current message]";
|
|
5
|
+
export const DEFAULT_GROUP_HISTORY_LIMIT = 50;
|
|
6
|
+
const MAX_HISTORY_KEYS = 1_000;
|
|
7
|
+
|
|
8
|
+
function evictOldHistoryKeys<T>(historyMap: Map<string, T[]>, maxKeys = MAX_HISTORY_KEYS): void {
|
|
9
|
+
if (historyMap.size <= maxKeys) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const keysToDelete = historyMap.size - maxKeys;
|
|
13
|
+
const iterator = historyMap.keys();
|
|
14
|
+
for (let index = 0; index < keysToDelete; index += 1) {
|
|
15
|
+
const key = iterator.next().value;
|
|
16
|
+
if (key !== undefined) {
|
|
17
|
+
historyMap.delete(key);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildHistoryContext(params: {
|
|
23
|
+
historyText: string;
|
|
24
|
+
currentMessage: string;
|
|
25
|
+
lineBreak?: string;
|
|
26
|
+
}): string {
|
|
27
|
+
const lineBreak = params.lineBreak ?? "\n";
|
|
28
|
+
if (!params.historyText.trim()) {
|
|
29
|
+
return params.currentMessage;
|
|
30
|
+
}
|
|
31
|
+
return [
|
|
32
|
+
HISTORY_CONTEXT_MARKER,
|
|
33
|
+
params.historyText,
|
|
34
|
+
"",
|
|
35
|
+
CURRENT_MESSAGE_MARKER,
|
|
36
|
+
params.currentMessage,
|
|
37
|
+
].join(lineBreak);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function appendHistoryEntry<T extends HistoryEntry>(params: {
|
|
41
|
+
historyMap: Map<string, T[]>;
|
|
42
|
+
historyKey: string;
|
|
43
|
+
entry: T;
|
|
44
|
+
limit: number;
|
|
45
|
+
}): T[] {
|
|
46
|
+
if (params.limit <= 0) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
const history = params.historyMap.get(params.historyKey) ?? [];
|
|
50
|
+
history.push(params.entry);
|
|
51
|
+
while (history.length > params.limit) {
|
|
52
|
+
history.shift();
|
|
53
|
+
}
|
|
54
|
+
if (params.historyMap.has(params.historyKey)) {
|
|
55
|
+
params.historyMap.delete(params.historyKey);
|
|
56
|
+
}
|
|
57
|
+
params.historyMap.set(params.historyKey, history);
|
|
58
|
+
evictOldHistoryKeys(params.historyMap);
|
|
59
|
+
return history;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildHistoryContextFromEntries(params: {
|
|
63
|
+
entries: HistoryEntry[];
|
|
64
|
+
currentMessage: string;
|
|
65
|
+
formatEntry: (entry: HistoryEntry) => string;
|
|
66
|
+
lineBreak?: string;
|
|
67
|
+
excludeLast?: boolean;
|
|
68
|
+
}): string {
|
|
69
|
+
const lineBreak = params.lineBreak ?? "\n";
|
|
70
|
+
const entries = params.excludeLast === false ? params.entries : params.entries.slice(0, -1);
|
|
71
|
+
if (entries.length === 0) {
|
|
72
|
+
return params.currentMessage;
|
|
73
|
+
}
|
|
74
|
+
return buildHistoryContext({
|
|
75
|
+
historyText: entries.map(params.formatEntry).join(lineBreak),
|
|
76
|
+
currentMessage: params.currentMessage,
|
|
77
|
+
lineBreak,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function recordPendingHistoryEntryIfEnabled<T extends HistoryEntry>(params: {
|
|
82
|
+
historyMap: Map<string, T[]>;
|
|
83
|
+
historyKey: string;
|
|
84
|
+
entry?: T | null;
|
|
85
|
+
limit: number;
|
|
86
|
+
}): T[] {
|
|
87
|
+
if (!params.entry || params.limit <= 0) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
return appendHistoryEntry({
|
|
91
|
+
historyMap: params.historyMap,
|
|
92
|
+
historyKey: params.historyKey,
|
|
93
|
+
entry: params.entry,
|
|
94
|
+
limit: params.limit,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function buildPendingHistoryContextFromMap(params: {
|
|
99
|
+
historyMap: Map<string, HistoryEntry[]>;
|
|
100
|
+
historyKey: string;
|
|
101
|
+
limit: number;
|
|
102
|
+
currentMessage: string;
|
|
103
|
+
formatEntry: (entry: HistoryEntry) => string;
|
|
104
|
+
lineBreak?: string;
|
|
105
|
+
}): string {
|
|
106
|
+
if (params.limit <= 0) {
|
|
107
|
+
return params.currentMessage;
|
|
108
|
+
}
|
|
109
|
+
return buildHistoryContextFromEntries({
|
|
110
|
+
entries: params.historyMap.get(params.historyKey) ?? [],
|
|
111
|
+
currentMessage: params.currentMessage,
|
|
112
|
+
formatEntry: params.formatEntry,
|
|
113
|
+
lineBreak: params.lineBreak,
|
|
114
|
+
excludeLast: false,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function clearHistoryEntriesIfEnabled(params: {
|
|
119
|
+
historyMap: Map<string, HistoryEntry[]>;
|
|
120
|
+
historyKey: string;
|
|
121
|
+
limit: number;
|
|
122
|
+
}): void {
|
|
123
|
+
if (params.limit <= 0) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
params.historyMap.set(params.historyKey, []);
|
|
127
|
+
}
|
|
@@ -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
|
+
}
|