| 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 |