@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,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
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { SecretRef, SecretRefSource } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_SECRET_PROVIDER_ALIAS = "default";
|
|
5
|
+
export const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/;
|
|
6
|
+
export const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
7
|
+
const ENV_SECRET_TEMPLATE_RE = /^\$\{([A-Z][A-Z0-9_]{0,127})\}$/;
|
|
8
|
+
const FILE_SECRET_REF_SEGMENT_PATTERN = /^(?:[^~]|~0|~1)*$/;
|
|
9
|
+
const EXEC_SECRET_REF_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/;
|
|
10
|
+
|
|
11
|
+
type SecretDefaults = {
|
|
12
|
+
env?: string;
|
|
13
|
+
file?: string;
|
|
14
|
+
exec?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
18
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isSecretRef(value: unknown): value is SecretRef {
|
|
22
|
+
return (
|
|
23
|
+
isRecord(value) &&
|
|
24
|
+
(value.source === "env" || value.source === "file" || value.source === "exec") &&
|
|
25
|
+
typeof value.provider === "string" &&
|
|
26
|
+
value.provider.trim().length > 0 &&
|
|
27
|
+
typeof value.id === "string" &&
|
|
28
|
+
value.id.trim().length > 0
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function coerceSecretRef(value: unknown, defaults?: SecretDefaults): SecretRef | null {
|
|
33
|
+
if (isSecretRef(value)) {
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
if (typeof value === "string") {
|
|
37
|
+
const match = ENV_SECRET_TEMPLATE_RE.exec(value.trim());
|
|
38
|
+
if (match) {
|
|
39
|
+
return {
|
|
40
|
+
source: "env",
|
|
41
|
+
provider: defaults?.env ?? DEFAULT_SECRET_PROVIDER_ALIAS,
|
|
42
|
+
id: match[1],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!isRecord(value)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (
|
|
50
|
+
(value.source === "env" || value.source === "file" || value.source === "exec") &&
|
|
51
|
+
typeof value.id === "string" &&
|
|
52
|
+
value.id.trim().length > 0 &&
|
|
53
|
+
value.provider === undefined
|
|
54
|
+
) {
|
|
55
|
+
const source = value.source as SecretRefSource;
|
|
56
|
+
return {
|
|
57
|
+
source,
|
|
58
|
+
provider:
|
|
59
|
+
source === "env"
|
|
60
|
+
? (defaults?.env ?? DEFAULT_SECRET_PROVIDER_ALIAS)
|
|
61
|
+
: source === "file"
|
|
62
|
+
? (defaults?.file ?? DEFAULT_SECRET_PROVIDER_ALIAS)
|
|
63
|
+
: (defaults?.exec ?? DEFAULT_SECRET_PROVIDER_ALIAS),
|
|
64
|
+
id: value.id,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveSecretInputRef(params: {
|
|
71
|
+
value: unknown;
|
|
72
|
+
refValue?: unknown;
|
|
73
|
+
defaults?: SecretDefaults;
|
|
74
|
+
}): SecretRef | null {
|
|
75
|
+
return coerceSecretRef(params.refValue, params.defaults) ?? coerceSecretRef(params.value, params.defaults);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function normalizeSecretInputString(value: unknown): string | undefined {
|
|
79
|
+
if (typeof value !== "string") {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const trimmed = value.trim();
|
|
83
|
+
return trimmed || undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function hasConfiguredSecretInput(value: unknown, defaults?: SecretDefaults): boolean {
|
|
87
|
+
return Boolean(normalizeSecretInputString(value) || coerceSecretRef(value, defaults));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function normalizeResolvedSecretInputString(params: {
|
|
91
|
+
value: unknown;
|
|
92
|
+
refValue?: unknown;
|
|
93
|
+
defaults?: SecretDefaults;
|
|
94
|
+
path: string;
|
|
95
|
+
}): string | undefined {
|
|
96
|
+
const normalized = normalizeSecretInputString(params.value);
|
|
97
|
+
if (normalized) {
|
|
98
|
+
return normalized;
|
|
99
|
+
}
|
|
100
|
+
const ref = resolveSecretInputRef(params);
|
|
101
|
+
if (ref) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`${params.path}: unresolved SecretRef "${ref.source}:${ref.provider}:${ref.id}".`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function isValidFileSecretRefId(value: string): boolean {
|
|
110
|
+
if (value === "value") {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
if (!value.startsWith("/")) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
return value
|
|
117
|
+
.slice(1)
|
|
118
|
+
.split("/")
|
|
119
|
+
.every((segment) => FILE_SECRET_REF_SEGMENT_PATTERN.test(segment));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function isValidExecSecretRefId(value: string): boolean {
|
|
123
|
+
if (!EXEC_SECRET_REF_ID_PATTERN.test(value)) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
return value.split("/").every((segment) => segment !== "." && segment !== "..");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function formatExecSecretRefIdValidationMessage(): string {
|
|
130
|
+
return [
|
|
131
|
+
"Exec secret reference id must match /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/",
|
|
132
|
+
'and must not include "." or ".." path segments',
|
|
133
|
+
'(example: "vault/openai/api-key").',
|
|
134
|
+
].join(" ");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function buildSecretInputSchema() {
|
|
138
|
+
const providerSchema = z
|
|
139
|
+
.string()
|
|
140
|
+
.regex(
|
|
141
|
+
SECRET_PROVIDER_ALIAS_PATTERN,
|
|
142
|
+
'Secret reference provider must match /^[a-z][a-z0-9_-]{0,63}$/ (example: "default").',
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
return z.union([
|
|
146
|
+
z.string(),
|
|
147
|
+
z.discriminatedUnion("source", [
|
|
148
|
+
z.object({
|
|
149
|
+
source: z.literal("env"),
|
|
150
|
+
provider: providerSchema,
|
|
151
|
+
id: z
|
|
152
|
+
.string()
|
|
153
|
+
.regex(
|
|
154
|
+
ENV_SECRET_REF_ID_RE,
|
|
155
|
+
'Env secret reference id must match /^[A-Z][A-Z0-9_]{0,127}$/ (example: "OPENAI_API_KEY").',
|
|
156
|
+
),
|
|
157
|
+
}),
|
|
158
|
+
z.object({
|
|
159
|
+
source: z.literal("file"),
|
|
160
|
+
provider: providerSchema,
|
|
161
|
+
id: z.string().refine(isValidFileSecretRefId, "Invalid file secret reference id."),
|
|
162
|
+
}),
|
|
163
|
+
z.object({
|
|
164
|
+
source: z.literal("exec"),
|
|
165
|
+
provider: providerSchema,
|
|
166
|
+
id: z.string().refine(isValidExecSecretRefId, formatExecSecretRefIdValidationMessage()),
|
|
167
|
+
}),
|
|
168
|
+
]),
|
|
169
|
+
]);
|
|
170
|
+
}
|