@nextclaw/channel-plugin-feishu 0.2.14 → 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.
Files changed (65) hide show
  1. package/index.ts +2 -2
  2. package/package.json +1 -2
  3. package/src/accounts.ts +2 -2
  4. package/src/bitable.ts +1 -1
  5. package/src/bot.test.ts +1 -1
  6. package/src/bot.ts +2 -2
  7. package/src/card-action.ts +1 -1
  8. package/src/channel.test.ts +1 -1
  9. package/src/channel.ts +3 -3
  10. package/src/chat.ts +1 -1
  11. package/src/config-schema.ts +1 -1
  12. package/src/dedup.ts +1 -1
  13. package/src/directory.test.ts +1 -1
  14. package/src/directory.ts +2 -2
  15. package/src/docx.account-selection.test.ts +1 -1
  16. package/src/docx.ts +1 -1
  17. package/src/drive.ts +1 -1
  18. package/src/dynamic-agent.ts +1 -1
  19. package/src/media.ts +1 -1
  20. package/src/monitor.account.ts +1 -1
  21. package/src/monitor.reaction.test.ts +1 -1
  22. package/src/monitor.startup.test.ts +1 -1
  23. package/src/monitor.startup.ts +1 -1
  24. package/src/monitor.state.ts +1 -1
  25. package/src/monitor.transport.ts +1 -1
  26. package/src/monitor.ts +1 -1
  27. package/src/monitor.webhook.test-helpers.ts +1 -1
  28. package/src/nextclaw-sdk/account-id.ts +31 -0
  29. package/src/nextclaw-sdk/compat.ts +8 -0
  30. package/src/nextclaw-sdk/core-channel.ts +296 -0
  31. package/src/nextclaw-sdk/core-pairing.ts +224 -0
  32. package/src/nextclaw-sdk/core.ts +26 -0
  33. package/src/nextclaw-sdk/dedupe.ts +246 -0
  34. package/src/nextclaw-sdk/feishu.ts +77 -0
  35. package/src/nextclaw-sdk/history.ts +127 -0
  36. package/src/nextclaw-sdk/network-body.ts +245 -0
  37. package/src/nextclaw-sdk/network-fetch.ts +129 -0
  38. package/src/nextclaw-sdk/network-webhook.ts +182 -0
  39. package/src/nextclaw-sdk/network.ts +13 -0
  40. package/src/nextclaw-sdk/runtime-store.ts +26 -0
  41. package/src/nextclaw-sdk/secrets-config.ts +109 -0
  42. package/src/nextclaw-sdk/secrets-core.ts +170 -0
  43. package/src/nextclaw-sdk/secrets-prompt.ts +305 -0
  44. package/src/nextclaw-sdk/secrets.ts +18 -0
  45. package/src/nextclaw-sdk/types.ts +300 -0
  46. package/src/onboarding.status.test.ts +1 -1
  47. package/src/onboarding.ts +2 -2
  48. package/src/outbound.ts +1 -1
  49. package/src/perm.ts +1 -1
  50. package/src/policy.ts +2 -2
  51. package/src/reactions.ts +1 -1
  52. package/src/reply-dispatcher.ts +1 -1
  53. package/src/runtime.ts +2 -2
  54. package/src/secret-input.ts +1 -1
  55. package/src/send-target.test.ts +1 -1
  56. package/src/send-target.ts +1 -1
  57. package/src/send.test.ts +1 -1
  58. package/src/send.ts +1 -1
  59. package/src/streaming-card.ts +1 -1
  60. package/src/tool-account-routing.test.ts +1 -1
  61. package/src/tool-account.ts +1 -1
  62. package/src/tool-factory-test-harness.ts +1 -1
  63. package/src/types.ts +1 -1
  64. package/src/typing.ts +1 -1
  65. package/src/wiki.ts +1 -1
@@ -0,0 +1,224 @@
1
+ import { normalizeAccountId } from "./account-id.js";
2
+ import type { LogFn, PluginRuntime } from "./types.js";
3
+
4
+ export const PAIRING_APPROVED_MESSAGE =
5
+ "NextClaw access approved. Send a message to start chatting.";
6
+
7
+ const NEXTCLAW_DOCS_ROOT = "https://docs.nextclaw.io";
8
+
9
+ export function buildAgentMediaPayload(
10
+ mediaList: Array<{ path: string; contentType?: string | null }>,
11
+ ): {
12
+ MediaPath?: string;
13
+ MediaType?: string;
14
+ MediaUrl?: string;
15
+ MediaPaths?: string[];
16
+ MediaUrls?: string[];
17
+ MediaTypes?: string[];
18
+ } {
19
+ const first = mediaList[0];
20
+ const mediaPaths = mediaList.map((media) => media.path);
21
+ const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
22
+ return {
23
+ MediaPath: first?.path,
24
+ MediaType: first?.contentType ?? undefined,
25
+ MediaUrl: first?.path,
26
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
27
+ MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
28
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
29
+ };
30
+ }
31
+
32
+ export function formatDocsLink(path: string, label?: string): string {
33
+ const trimmed = path.trim();
34
+ const url = trimmed.startsWith("http")
35
+ ? trimmed
36
+ : `${NEXTCLAW_DOCS_ROOT}${trimmed.startsWith("/") ? trimmed : `/${trimmed}`}`;
37
+ return label ? `${label} (${url})` : url;
38
+ }
39
+
40
+ function buildPairingReply(params: {
41
+ channel: string;
42
+ idLine: string;
43
+ code: string;
44
+ }): string {
45
+ return [
46
+ "NextClaw: access not configured.",
47
+ "",
48
+ params.idLine,
49
+ "",
50
+ `Pairing code: ${params.code}`,
51
+ "",
52
+ "Ask the bot owner to approve with:",
53
+ `nextclaw pairing approve ${params.channel} ${params.code}`,
54
+ ].join("\n");
55
+ }
56
+
57
+ export async function issuePairingChallenge(params: {
58
+ channel: string;
59
+ senderId: string;
60
+ senderIdLine: string;
61
+ meta?: Record<string, string | undefined>;
62
+ upsertPairingRequest: (params: {
63
+ id: string;
64
+ meta?: Record<string, string | undefined>;
65
+ }) => Promise<{ code: string; created: boolean }>;
66
+ sendPairingReply: (text: string) => Promise<void>;
67
+ buildReplyText?: (params: { code: string; senderIdLine: string }) => string;
68
+ onCreated?: (params: { code: string }) => void;
69
+ onReplyError?: (err: unknown) => void;
70
+ }): Promise<{ created: boolean; code?: string }> {
71
+ const { code, created } = await params.upsertPairingRequest({
72
+ id: params.senderId,
73
+ meta: params.meta,
74
+ });
75
+ if (!created) {
76
+ return { created: false };
77
+ }
78
+ params.onCreated?.({ code });
79
+ const replyText =
80
+ params.buildReplyText?.({ code, senderIdLine: params.senderIdLine }) ??
81
+ buildPairingReply({
82
+ channel: params.channel,
83
+ idLine: params.senderIdLine,
84
+ code,
85
+ });
86
+ try {
87
+ await params.sendPairingReply(replyText);
88
+ } catch (error) {
89
+ params.onReplyError?.(error);
90
+ }
91
+ return { created: true, code };
92
+ }
93
+
94
+ export function createScopedPairingAccess(params: {
95
+ core: PluginRuntime;
96
+ channel: string;
97
+ accountId: string;
98
+ }) {
99
+ const accountId = normalizeAccountId(params.accountId);
100
+ return {
101
+ accountId,
102
+ readAllowFromStore: () =>
103
+ params.core.channel.pairing.readAllowFromStore({
104
+ channel: params.channel,
105
+ accountId,
106
+ }),
107
+ readStoreForDmPolicy: (provider: string, providerAccountId: string) =>
108
+ params.core.channel.pairing.readAllowFromStore({
109
+ channel: provider,
110
+ accountId: normalizeAccountId(providerAccountId),
111
+ }),
112
+ upsertPairingRequest: (input: {
113
+ id: string;
114
+ meta?: Record<string, string | undefined>;
115
+ }) =>
116
+ params.core.channel.pairing.upsertPairingRequest({
117
+ channel: params.channel,
118
+ accountId,
119
+ ...input,
120
+ }),
121
+ };
122
+ }
123
+
124
+ export function createReplyPrefixContext() {
125
+ const prefixContext: Record<string, unknown> = {};
126
+ return {
127
+ prefixContext,
128
+ responsePrefix: undefined as string | undefined,
129
+ enableSlackInteractiveReplies: undefined as boolean | undefined,
130
+ responsePrefixContextProvider: () => prefixContext,
131
+ onModelSelected: (_ctx: Record<string, unknown>) => {},
132
+ };
133
+ }
134
+
135
+ export function logTypingFailure(params: {
136
+ log: LogFn;
137
+ channel: string;
138
+ target?: string;
139
+ action?: "start" | "stop";
140
+ error: unknown;
141
+ }): void {
142
+ const target = params.target ? ` target=${params.target}` : "";
143
+ const action = params.action ? ` action=${params.action}` : "";
144
+ params.log(`${params.channel} typing${action} failed${target}: ${String(params.error)}`);
145
+ }
146
+
147
+ export function createTypingCallbacks(params: {
148
+ start: () => Promise<void>;
149
+ stop?: () => Promise<void>;
150
+ onStartError: (err: unknown) => void;
151
+ onStopError?: (err: unknown) => void;
152
+ keepaliveIntervalMs?: number;
153
+ maxConsecutiveFailures?: number;
154
+ maxDurationMs?: number;
155
+ }) {
156
+ const keepaliveIntervalMs = params.keepaliveIntervalMs ?? 3_000;
157
+ const maxConsecutiveFailures = Math.max(1, params.maxConsecutiveFailures ?? 2);
158
+ const maxDurationMs = params.maxDurationMs ?? 60_000;
159
+ let interval: ReturnType<typeof setInterval> | undefined;
160
+ let timeout: ReturnType<typeof setTimeout> | undefined;
161
+ let closed = false;
162
+ let stopSent = false;
163
+ let consecutiveFailures = 0;
164
+
165
+ const cleanupTimers = () => {
166
+ if (interval) {
167
+ clearInterval(interval);
168
+ interval = undefined;
169
+ }
170
+ if (timeout) {
171
+ clearTimeout(timeout);
172
+ timeout = undefined;
173
+ }
174
+ };
175
+
176
+ const fireStop = () => {
177
+ cleanupTimers();
178
+ closed = true;
179
+ if (!params.stop || stopSent) {
180
+ return;
181
+ }
182
+ stopSent = true;
183
+ void params.stop().catch((error) => (params.onStopError ?? params.onStartError)(error));
184
+ };
185
+
186
+ const fireStart = async () => {
187
+ if (closed) {
188
+ return;
189
+ }
190
+ try {
191
+ await params.start();
192
+ consecutiveFailures = 0;
193
+ } catch (error) {
194
+ consecutiveFailures += 1;
195
+ params.onStartError(error);
196
+ if (consecutiveFailures >= maxConsecutiveFailures) {
197
+ fireStop();
198
+ }
199
+ }
200
+ };
201
+
202
+ return {
203
+ onReplyStart: async () => {
204
+ closed = false;
205
+ stopSent = false;
206
+ consecutiveFailures = 0;
207
+ cleanupTimers();
208
+ await fireStart();
209
+ if (closed) {
210
+ return;
211
+ }
212
+ interval = setInterval(() => {
213
+ void fireStart();
214
+ }, keepaliveIntervalMs);
215
+ if (maxDurationMs > 0) {
216
+ timeout = setTimeout(() => {
217
+ fireStop();
218
+ }, maxDurationMs);
219
+ }
220
+ },
221
+ onIdle: fireStop,
222
+ onCleanup: fireStop,
223
+ };
224
+ }
@@ -0,0 +1,26 @@
1
+ export { DEFAULT_ACCOUNT_ID, normalizeAccountId, normalizeAgentId, normalizeOptionalAccountId } from "./account-id.js";
2
+ export {
3
+ collectAllowlistProviderRestrictSendersWarnings,
4
+ createDefaultChannelRuntimeState,
5
+ emptyPluginConfigSchema,
6
+ evaluateSenderGroupAccessForPolicy,
7
+ formatAllowFromLowercase,
8
+ listDirectoryGroupEntriesFromMapKeysAndAllowFrom,
9
+ listDirectoryUserEntriesFromAllowFromAndMapKeys,
10
+ mapAllowFromEntries,
11
+ resolveDefaultGroupPolicy,
12
+ resolveOpenProviderRuntimeGroupPolicy,
13
+ warnMissingProviderGroupPolicyFallbackOnce,
14
+ buildProbeChannelStatusSummary,
15
+ buildRuntimeAccountStatusSnapshot,
16
+ } from "./core-channel.js";
17
+ export {
18
+ buildAgentMediaPayload,
19
+ createReplyPrefixContext,
20
+ createScopedPairingAccess,
21
+ createTypingCallbacks,
22
+ formatDocsLink,
23
+ issuePairingChallenge,
24
+ logTypingFailure,
25
+ PAIRING_APPROVED_MESSAGE,
26
+ } from "./core-pairing.js";
@@ -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
+ }