返回 CodeWhale
lib.mjs
1 import {
2 activeTurnBlock,
3 cleanEnvValue,
4 commandAction as coreCommandAction,
5 compactRuntimeError,
6 isPlaceholderValue,
7 latestRunningTurn,
8 parseApprovalDecisionArgs,
9 parseBool,
10 parseCommand,
11 parseEnvText,
12 parseList,
13 parseTextContent as coreParseTextContent,
14 preservedChatStateFields as corePreservedChatStateFields,
15 splitMessage,
16 stripGroupPrefix as coreStripGroupPrefix
17 } from "../../bridge-core/src/lib.mjs";
18
19 export {
20 activeTurnBlock,
21 cleanEnvValue,
22 compactRuntimeError,
23 isPlaceholderValue,
24 latestRunningTurn,
25 parseApprovalDecisionArgs,
26 parseBool,
27 parseCommand,
28 parseEnvText,
29 parseList,
30 splitMessage
31 };
32
33 export function parseTextContent(content) {
34 return coreParseTextContent(content, ["text", "content"]);
35 }
36
37 export function incomingIdentity(event) {
38 const sender = event?.sender?.sender_id || {};
39 const message = event?.message || {};
40 return {
41 chatId: message.chat_id || "",
42 messageId: message.message_id || "",
43 chatType: message.chat_type || "",
44 messageType: message.message_type || "",
45 openId: sender.open_id || "",
46 unionId: sender.union_id || "",
47 userId: sender.user_id || "",
48 // Thread/topic group context: these fields let the bridge reply
49 // inside the same topic instead of spawning a new standalone topic.
50 // / 话题群上下文:用于在同一话题内回复,而非新建独立话题。
51 parentId: message.parent_id || "",
52 rootId: message.root_id || "",
53 threadId: message.thread_id || ""
54 };
55 }
56
57 export function isAllowed(identity, allowlist, allowUnlisted = false) {
58 if (allowUnlisted) return true;
59 const allowed = new Set(allowlist);
60 return [identity.chatId, identity.openId, identity.unionId, identity.userId]
61 .filter(Boolean)
62 .some((id) => allowed.has(id));
63 }
64
65 export function pairingRefusalText(identity) {
66 return [
67 "This chat is not in DEEPSEEK_CHAT_ALLOWLIST.",
68 `chat_id=${identity.chatId}`,
69 identity.openId ? `open_id=${identity.openId}` : "",
70 identity.unionId ? `union_id=${identity.unionId}` : "",
71 identity.userId ? `user_id=${identity.userId}` : ""
72 ]
73 .filter(Boolean)
74 .join("\n");
75 }
76
77 export function stripGroupPrefix(text, { chatType, requirePrefix, prefix }) {
78 return coreStripGroupPrefix(text, {
79 chatType,
80 requirePrefix,
81 prefix: prefix || "/ds",
82 directChatTypes: ["p2p"]
83 });
84 }
85
86 export function commandAction(command) {
87 return coreCommandAction(command);
88 }
89
90 export function preservedChatStateFields(state = {}) {
91 return corePreservedChatStateFields(state, ["model", "replyToMessageId"]);
92 }
93
94 export function validateBridgeConfig(env, options = {}) {
95 const runtimeEnv = options.runtimeEnv || null;
96 const workspaceRoot = options.workspaceRoot || "";
97 const errors = [];
98 const warnings = [];
99 const info = [];
100 const add = (list, code, message) => list.push({ code, message });
101
102 for (const key of [
103 "FEISHU_APP_ID",
104 "FEISHU_APP_SECRET",
105 "DEEPSEEK_RUNTIME_URL",
106 "DEEPSEEK_RUNTIME_TOKEN",
107 "DEEPSEEK_WORKSPACE",
108 "FEISHU_THREAD_MAP_PATH"
109 ]) {
110 const value = cleanEnvValue(env[key]);
111 if (!value) {
112 add(errors, "missing_required", `${key} is required`);
113 } else if (isPlaceholderValue(value)) {
114 add(errors, "placeholder_value", `${key} still contains a placeholder value`);
115 }
116 }
117
118 const domain = cleanEnvValue(env.FEISHU_DOMAIN || "feishu").toLowerCase();
119 if (!["feishu", "lark"].includes(domain) && !/^https:\/\/open\./.test(domain)) {
120 add(errors, "invalid_domain", "FEISHU_DOMAIN must be feishu, lark, or an https://open.* URL");
121 }
122
123 const runtimeUrl = cleanEnvValue(env.DEEPSEEK_RUNTIME_URL || "http://127.0.0.1:7878");
124 try {
125 const parsed = new URL(runtimeUrl);
126 const localHosts = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
127 if (!["http:", "https:"].includes(parsed.protocol)) {
128 add(errors, "invalid_runtime_url", "DEEPSEEK_RUNTIME_URL must use http or https");
129 }
130 if (!localHosts.has(parsed.hostname)) {
131 add(errors, "remote_runtime_url", "DEEPSEEK_RUNTIME_URL must point at localhost on Lighthouse");
132 }
133 } catch {
134 add(errors, "invalid_runtime_url", "DEEPSEEK_RUNTIME_URL is not a valid URL");
135 }
136
137 const workspace = cleanEnvValue(env.DEEPSEEK_WORKSPACE);
138 if (workspace && !workspace.startsWith("/")) {
139 add(errors, "relative_workspace", "DEEPSEEK_WORKSPACE must be an absolute path");
140 }
141 if (
142 workspace &&
143 workspaceRoot &&
144 workspace !== workspaceRoot &&
145 !workspace.startsWith(`${workspaceRoot}/`)
146 ) {
147 add(warnings, "workspace_root", `DEEPSEEK_WORKSPACE is outside ${workspaceRoot}`);
148 }
149
150 const threadMapPath = cleanEnvValue(env.FEISHU_THREAD_MAP_PATH);
151 if (threadMapPath && !threadMapPath.startsWith("/")) {
152 add(errors, "relative_thread_map", "FEISHU_THREAD_MAP_PATH must be an absolute path");
153 }
154
155 const allowGroups = parseBool(env.FEISHU_ALLOW_GROUPS, false);
156 const requirePrefix = parseBool(env.FEISHU_REQUIRE_PREFIX_IN_GROUP, true);
157 const allowUnlisted = parseBool(env.DEEPSEEK_ALLOW_UNLISTED, false);
158 const allowlist = parseList(env.DEEPSEEK_CHAT_ALLOWLIST);
159
160 if (!allowlist.length && allowUnlisted) {
161 add(warnings, "pairing_mode_open", "DEEPSEEK_ALLOW_UNLISTED=true leaves first-pairing mode open");
162 } else if (!allowlist.length) {
163 add(warnings, "not_paired", "DEEPSEEK_CHAT_ALLOWLIST is empty; all chats will be refused");
164 }
165 if (allowGroups && allowUnlisted) {
166 add(errors, "open_group_control", "Group control cannot be enabled while unlisted chats are allowed");
167 }
168 if (allowGroups && !requirePrefix) {
169 add(warnings, "group_without_prefix", "Group control is enabled without requiring FEISHU_GROUP_PREFIX");
170 }
171 if (!allowGroups) {
172 add(info, "dm_only", "Direct-message control is enabled; group chats are disabled");
173 }
174
175 const maxReplyChars = Number(env.FEISHU_MAX_REPLY_CHARS || 3500);
176 if (!Number.isFinite(maxReplyChars) || maxReplyChars < 100) {
177 add(errors, "invalid_max_reply_chars", "FEISHU_MAX_REPLY_CHARS must be at least 100");
178 }
179 const turnTimeoutMs = Number(env.DEEPSEEK_TURN_TIMEOUT_MS || 900000);
180 if (!Number.isFinite(turnTimeoutMs) || turnTimeoutMs < 1000) {
181 add(errors, "invalid_turn_timeout", "DEEPSEEK_TURN_TIMEOUT_MS must be at least 1000");
182 }
183
184 if (runtimeEnv) {
185 const runtimeToken = cleanEnvValue(runtimeEnv.DEEPSEEK_RUNTIME_TOKEN);
186 const bridgeToken = cleanEnvValue(env.DEEPSEEK_RUNTIME_TOKEN);
187 if (!runtimeToken) {
188 add(errors, "missing_runtime_token", "runtime.env is missing DEEPSEEK_RUNTIME_TOKEN");
189 } else if (isPlaceholderValue(runtimeToken)) {
190 add(errors, "placeholder_runtime_token", "runtime.env DEEPSEEK_RUNTIME_TOKEN is still a placeholder");
191 } else if (bridgeToken && bridgeToken !== runtimeToken) {
192 add(errors, "token_mismatch", "Runtime and bridge DEEPSEEK_RUNTIME_TOKEN values do not match");
193 }
194
195 const apiKey = cleanEnvValue(runtimeEnv.DEEPSEEK_API_KEY);
196 if (!apiKey) {
197 add(warnings, "missing_api_key", "runtime.env is missing DEEPSEEK_API_KEY");
198 } else if (isPlaceholderValue(apiKey)) {
199 add(warnings, "placeholder_api_key", "runtime.env DEEPSEEK_API_KEY is still a placeholder");
200 }
201
202 const runtimePort = Number(runtimeEnv.DEEPSEEK_RUNTIME_PORT || 7878);
203 if (!Number.isInteger(runtimePort) || runtimePort <= 0 || runtimePort > 65535) {
204 add(errors, "invalid_runtime_port", "DEEPSEEK_RUNTIME_PORT must be a valid TCP port");
205 }
206 }
207
208 return {
209 ok: errors.length === 0,
210 errors,
211 warnings,
212 info
213 };
214 }
215
216 export function formatValidationReport(result) {
217 const lines = ["Feishu bridge config validation"];
218 for (const item of result.errors) lines.push(`[fail] ${item.message}`);
219 for (const item of result.warnings) lines.push(`[warn] ${item.message}`);
220 for (const item of result.info) lines.push(`[info] ${item.message}`);
221 if (result.ok) lines.push("[ok] No blocking config errors found");
222 return lines.join("\n");
223 }
224
225 export function helpText() {
226 return [
227 "DeepSeek phone bridge commands:",
228 "/help - show this help",
229 "/status - runtime and workspace status",
230 "/threads - recent runtime threads",
231 "/new - create a new thread for this chat",
232 "/resume <thread_id> - bind this chat to an existing thread",
233 "/model <name|default> - set or reset this chat's model",
234 "/interrupt - interrupt the active turn",
235 "/compact - compact the current thread",
236 "/allow <approval_id> [remember] - approve a pending tool call",
237 "/deny <approval_id> - deny a pending tool call",
238 "",
239 "Anything else is sent as a DeepSeek prompt."
240 ].join("\n");
241 }
242
242 lines Plain Text