@dcrays/dcgchat 0.4.29 → 0.5.0

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.
@@ -1,160 +0,0 @@
1
- import type WebSocket from 'ws'
2
- import fs from 'node:fs'
3
- import os from 'node:os'
4
- import path from 'node:path'
5
- import { createPluginRuntimeStore, type OpenClawConfig, type PluginRuntime } from 'openclaw/plugin-sdk'
6
- import { channelInfo, ENV } from './constant.js'
7
- import { dcgLogger } from './log.js'
8
-
9
- /** socket connection */
10
- let ws: WebSocket | null = null
11
-
12
- export function setWsConnection(next: WebSocket | null) {
13
- ws = next
14
- }
15
-
16
- export function getWsConnection(): WebSocket | null {
17
- return ws
18
- }
19
-
20
- let config: OpenClawConfig | null = null
21
-
22
- export function setOpenClawConfig(next: OpenClawConfig | null) {
23
- config = next
24
- }
25
-
26
- export function getOpenClawConfig(): OpenClawConfig | null {
27
- return config
28
- }
29
-
30
- function getWorkspacePath(): string | null {
31
- const workspacePath = path.join(
32
- os.homedir(),
33
- config?.channels?.["dcgchat"]?.appId == 110 ? '.mobook' : '.openclaw',
34
- 'workspace'
35
- )
36
- if (fs.existsSync(workspacePath)) {
37
- return workspacePath
38
- }
39
- return null
40
- }
41
-
42
- let workspaceDir: string = getWorkspacePath() ?? ''
43
-
44
- export function setWorkspaceDir(dir?: string) {
45
- if (dir) {
46
- workspaceDir = dir
47
- }
48
- }
49
-
50
- export function getWorkspaceDir(): string {
51
- if (!workspaceDir) {
52
- dcgLogger?.('Workspace directory not initialized', 'error')
53
- }
54
- return workspaceDir
55
- }
56
-
57
- const { setRuntime: setDcgchatRuntime, getRuntime: getDcgchatRuntime } = createPluginRuntimeStore<PluginRuntime>(
58
- `${"dcgchat"} runtime not initialized`
59
- )
60
- export { setDcgchatRuntime, getDcgchatRuntime }
61
-
62
- export type MsgSessionStatus = 'running' | 'finished' | ''
63
-
64
- const msgStatusBySessionKey = new Map<string, MsgSessionStatus>()
65
-
66
- export function setMsgStatus(sessionKey: string, status: MsgSessionStatus) {
67
- if (!sessionKey?.trim()) return
68
- if (status === '') {
69
- msgStatusBySessionKey.delete(sessionKey)
70
- } else {
71
- msgStatusBySessionKey.set(sessionKey, status)
72
- }
73
- }
74
-
75
- export function getMsgStatus(sessionKey: string): MsgSessionStatus {
76
- if (!sessionKey?.trim()) return ''
77
- return msgStatusBySessionKey.get(sessionKey) ?? ''
78
- }
79
-
80
- const getMediaKey = (url: string) => url.split(/[\\/]/).pop() ?? url
81
-
82
- /** 已发送媒体去重:外层 messageId → 内层该会话下已发送的媒体 key(文件名) */
83
- const sentMediaKeysBySession = new Map<string, Set<string>>()
84
-
85
- function getSessionMediaSet(sessionId: string): Set<string> {
86
- let set = sentMediaKeysBySession.get(sessionId)
87
- if (!set) {
88
- set = new Set<string>()
89
- sentMediaKeysBySession.set(sessionId, set)
90
- }
91
- return set
92
- }
93
-
94
- export function addSentMediaKey(messageId: string, url: string) {
95
- getSessionMediaSet(messageId).add(getMediaKey(url))
96
- }
97
-
98
- export function hasSentMediaKey(sessionId: string, url: string): boolean {
99
- return sentMediaKeysBySession.get(sessionId)?.has(getMediaKey(url)) ?? false
100
- }
101
-
102
- /** 不传 messageId 时清空全部会话;传入则只清空该会话 */
103
- export function clearSentMediaKeys(sessionId?: string) {
104
- if (sessionId) {
105
- sentMediaKeysBySession.delete(sessionId)
106
- } else {
107
- sentMediaKeysBySession.clear()
108
- }
109
- }
110
-
111
- /** 每个 sessionKey 下多个定时任务 messageId,入队在尾、出队在头(FIFO) */
112
- const cronMessageIdMap = new Map<string, string[]>()
113
-
114
- export function setCronMessageId(sk: string, messageId: string | number | null | undefined) {
115
- const mid = messageId != null && messageId !== '' ? String(messageId).trim() : ''
116
- if (!sk?.trim() || !mid) return
117
- let q = cronMessageIdMap.get(sk)
118
- if (!q) {
119
- q = []
120
- cronMessageIdMap.set(sk, q)
121
- }
122
- q.push(mid)
123
- }
124
-
125
- /** 窥视队首 messageId(不移除),供发送中与 finished 配对使用 */
126
- export function getCronMessageId(sk: string): string {
127
- if (!sk?.trim()) return ''
128
- return cronMessageIdMap.get(sk)?.[0] ?? ''
129
- }
130
-
131
- /** 弹出队首一条,与一次 finished 对应;队列为空时移除 key */
132
- export function removeCronMessageId(sk: string) {
133
- if (!sk?.trim()) return
134
- const q = cronMessageIdMap.get(sk)
135
- if (!q?.length) return
136
- q.shift()
137
- if (q.length === 0) {
138
- cronMessageIdMap.delete(sk)
139
- }
140
- }
141
-
142
- export const getSessionKey = (content: any, accountId: string) => {
143
- const { real_mobook, agent_id, agent_clone_code, session_id } = content
144
- const core = getDcgchatRuntime()
145
-
146
- const agentCode = agent_clone_code || 'main'
147
-
148
- const route = core.channel.routing.resolveAgentRoute({
149
- cfg: getOpenClawConfig() as OpenClawConfig,
150
- channel: "dcgchat",
151
- accountId: accountId || 'default',
152
- peer: { kind: 'direct', id: session_id }
153
- })
154
- return real_mobook == '1' ? route.sessionKey : `agent:${agentCode}:mobook:direct:${agent_id}:${session_id}`.toLowerCase()
155
- }
156
-
157
- export function getInfoBySessionKey(sk: string): { sessionId: string; agentId: string } {
158
- const sessionInfo = sk.split(':')
159
- return { sessionId: sessionInfo.at(-1) ?? '', agentId: sessionInfo.at(-2) ?? '' }
160
- }
package/src/utils/log.ts DELETED
@@ -1,15 +0,0 @@
1
- import { RuntimeEnv } from 'openclaw/plugin-sdk'
2
-
3
- let logger: RuntimeEnv | null = null
4
-
5
- export function setLogger(next: RuntimeEnv | null) {
6
- logger = next
7
- }
8
-
9
- export function dcgLogger(message: string, type: 'log' | 'error' = 'log'): void {
10
- if (logger) {
11
- logger[type](`书灵墨宝🚀 ~ [${new Date().toISOString()}] ${message}`)
12
- } else {
13
- console[type](`书灵墨宝🚀 ~ ${new Date().toISOString()} [${"dcgchat"}]: ${message}`)
14
- }
15
- }
@@ -1,88 +0,0 @@
1
- import { channelInfo, ENV } from './constant.js'
2
- import { getOpenClawConfig } from './global.js'
3
- import type { DcgchatConfig, IMsgParams } from '../types.js'
4
-
5
- /**
6
- * map key 是 session_key,value 为该会话下已 merge 后的消息参数。
7
- */
8
- const paramsMessageMap = new Map<string, IMsgParams>()
9
-
10
- /** 从 OpenClaw 配置读取当前 channel 的基础参数(唯一来源,供 transport / resolve 等复用) */
11
- export function getParamsDefaults(): IMsgParams {
12
- const ch = (getOpenClawConfig()?.channels?.["dcgchat"] as DcgchatConfig | undefined) ?? {}
13
- return {
14
- userId: Number(ch.userId ?? 0),
15
- botToken: ch.botToken ?? '',
16
- sessionId: '',
17
- messageId: '',
18
- domainId: String(ch.domainId ?? '1000'),
19
- appId: String(ch.appId ?? '100'),
20
- botId: '',
21
- agentId: '',
22
- sessionKey: ''
23
- }
24
- }
25
-
26
- export function resolveParamsMessage(params: Partial<IMsgParams>): IMsgParams {
27
- const defaults = getParamsDefaults()
28
- return {
29
- userId: Number(params.userId ?? defaults.userId),
30
- botToken: params.botToken ?? defaults.botToken,
31
- sessionId: params.sessionId ?? defaults.sessionId,
32
- messageId: params.messageId ?? defaults.messageId,
33
- domainId: String(params.domainId ?? defaults.domainId),
34
- appId: String(params.appId ?? defaults.appId),
35
- botId: params.botId ?? defaults.botId,
36
- agentId: params.agentId ?? defaults.agentId,
37
- sessionKey: params.sessionKey ?? defaults.sessionKey
38
- }
39
- }
40
-
41
- /**
42
- * 统一取值入口:显式 sessionKey,或回落到当前会话;再与配置缺省 merge,保证字段完整。
43
- */
44
- export function getEffectiveMsgParams(sessionKey?: string): IMsgParams {
45
- const stored = sessionKey ? paramsMessageMap.get(sessionKey) : undefined
46
- return stored ? resolveParamsMessage(stored) : getParamsDefaults()
47
- }
48
-
49
- /**
50
- * Agent `dcgchat_message` / 出站 `target` 应为 `dcgSessionKey`(如 `agent:main:mobook:direct:...`)。
51
- * `setParamsMessage` 的 key 与此一致;查不到 map 时回落到配置缺省(无会话级 messageId/sessionId)。
52
- */
53
- export function getOutboundMsgParams(preferredKey: string): IMsgParams {
54
- const k = preferredKey?.trim()
55
- if (k && paramsMessageMap.has(k)) {
56
- return getEffectiveMsgParams(k)
57
- }
58
- return getEffectiveMsgParams()
59
- }
60
-
61
- export function setParamsMessage(sessionKey: string, params: Partial<IMsgParams>) {
62
- if (!sessionKey) return
63
- const previous = paramsMessageMap.get(sessionKey)
64
- const base = previous ? resolveParamsMessage(previous) : getParamsDefaults()
65
- paramsMessageMap.set(sessionKey, resolveParamsMessage({ ...base, ...params, sessionKey }))
66
- }
67
-
68
- export function getParamsMessage(sessionKey: string): IMsgParams | undefined {
69
- return paramsMessageMap.get(sessionKey)
70
- }
71
-
72
- export function clearParamsMessage(sessionKey: string): void {
73
- const k = sessionKey?.trim()
74
- if (!k) return
75
- paramsMessageMap.delete(k)
76
- }
77
-
78
- // sessionKey 对应的 子agent的runId
79
- const subagentRunIdMap = new Map<string, string>()
80
- export function getSessionKeyBySubAgentRunId(runId: string): string | undefined {
81
- return subagentRunIdMap.get(runId)
82
- }
83
- export function setSessionKeyBySubAgentRunId(runId: string, sessionKey: string) {
84
- subagentRunIdMap.set(runId, sessionKey)
85
- }
86
- export function deleteSessionKeyBySubAgentRunId(runId: string) {
87
- subagentRunIdMap.delete(runId)
88
- }
@@ -1,228 +0,0 @@
1
- import { dcgLogger } from './log.js'
2
-
3
- /**
4
- * 从文本中提取 /mobook 目录下的文件
5
- * @param {string} text
6
- * @returns {string[]}
7
- */
8
- const EXT_LIST = [
9
- // 文档类
10
- 'doc',
11
- 'docx',
12
- 'xls',
13
- 'xlsx',
14
- 'ppt',
15
- 'pptx',
16
- 'pdf',
17
- 'txt',
18
- 'rtf',
19
- 'odt',
20
-
21
- // 数据/开发
22
- 'json',
23
- 'xml',
24
- 'csv',
25
- 'yaml',
26
- 'yml',
27
-
28
- // 前端/文本
29
- 'html',
30
- 'htm',
31
- 'md',
32
- 'markdown',
33
- 'css',
34
- 'js',
35
- 'ts',
36
-
37
- // 图片
38
- 'png',
39
- 'jpg',
40
- 'jpeg',
41
- 'gif',
42
- 'bmp',
43
- 'webp',
44
- 'svg',
45
- 'ico',
46
- 'tiff',
47
-
48
- // 音频
49
- 'mp3',
50
- 'wav',
51
- 'ogg',
52
- 'aac',
53
- 'flac',
54
- 'm4a',
55
-
56
- // 视频
57
- 'mp4',
58
- 'avi',
59
- 'mov',
60
- 'wmv',
61
- 'flv',
62
- 'mkv',
63
- 'webm',
64
-
65
- // 压缩包
66
- 'zip',
67
- 'rar',
68
- '7z',
69
- 'tar',
70
- 'gz',
71
- 'bz2',
72
- 'xz',
73
-
74
- // 可执行/程序
75
- 'exe',
76
- 'dmg',
77
- 'pkg',
78
- 'apk',
79
- 'ipa',
80
-
81
- // 其他常见
82
- 'log',
83
- 'dat',
84
- 'bin'
85
- ]
86
- /**
87
- * 扩展名按长度降序,用于正则交替,避免 xls 抢先匹配 xlsx、htm 抢先匹配 html 等
88
- */
89
- const EXT_SORTED_FOR_REGEX = [...EXT_LIST].sort((a, b) => b.length - a.length)
90
-
91
- /** 去除控制符、零宽字符等常见脏值 */
92
- function stripMobookNoise(s: string) {
93
- return s.replace(/[\u0000-\u001F\u007F\u200B-\u200D\u200E\u200F\uFEFF]/g, '')
94
- }
95
-
96
- /**
97
- * 从文本中扫描 `.../mobook/...` 或 `...\mobook\...` 片段,按最长后缀匹配合法扩展名(兜底)
98
- */
99
- function collectMobookPathsAfterNeedle(text: string, lower: string, needle: string, result: Set<string>): void {
100
- let from = 0
101
- while (from < text.length) {
102
- const i = lower.indexOf(needle, from)
103
- if (i < 0) break
104
- const start = i + needle.length
105
- const tail = text.slice(start)
106
- const seg = tail.match(/^([^\s\]\)'"}\u3002,,]+)/)
107
- if (!seg) {
108
- from = start + 1
109
- continue
110
- }
111
- let raw = stripMobookNoise(seg[1]).trim()
112
- if (!raw || raw.includes('\uFFFD')) {
113
- from = start + 1
114
- continue
115
- }
116
- const low = raw.toLowerCase()
117
- let matchedExt: string | undefined
118
- for (const ext of EXT_SORTED_FOR_REGEX) {
119
- if (low.endsWith(`.${ext}`)) {
120
- matchedExt = ext
121
- break
122
- }
123
- }
124
- if (!matchedExt) {
125
- from = start + 1
126
- continue
127
- }
128
- const base = raw.slice(0, -(matchedExt.length + 1))
129
- const fileName = `${base}.${matchedExt}`
130
- if (isValidFileName(fileName)) {
131
- result.add(normalizePath(`/mobook/${fileName}`))
132
- }
133
- from = start + 1
134
- }
135
- }
136
-
137
- function collectMobookPathsByScan(text: string, result: Set<string>): void {
138
- const lower = text.toLowerCase()
139
- collectMobookPathsAfterNeedle(text, lower, '/mobook/', result)
140
- collectMobookPathsAfterNeedle(text, lower, '\\mobook\\', result)
141
- }
142
-
143
- export function extractMobookFiles(text = '') {
144
- if (typeof text !== 'string' || !text.trim()) return []
145
- // 全角冒号(中文输入常见)→ 半角,便于匹配 c:\mobook\
146
- text = text.replace(/\uFF1A/g, ':')
147
- const result = new Set<string>()
148
- // ✅ 扩展名(必须长扩展名优先,见 EXT_SORTED_FOR_REGEX)
149
- const EXT = `(${EXT_SORTED_FOR_REGEX.join('|')})`
150
- // ✅ 文件名字符(增强:支持中文、符号)
151
- const FILE_NAME = `[\\w\\u4e00-\\u9fa5::《》()()\\-\\s]+?`
152
- try {
153
- // 1️⃣ `xxx.xxx`
154
- const backtickReg = new RegExp(`\`([^\\\`]+?\\.${EXT})\``, 'gi')
155
- ;(text.match(backtickReg) || []).forEach((item) => {
156
- const name = item.replace(/`/g, '').trim()
157
- if (isValidFileName(name)) {
158
- result.add(`/mobook/${name}`)
159
- }
160
- })
161
- // 2️⃣ /mobook/xxx.xxx
162
- const fullPathReg = new RegExp(`/mobook/${FILE_NAME}\\.${EXT}`, 'gi')
163
- ;(text.match(fullPathReg) || []).forEach((p) => {
164
- result.add(normalizePath(p))
165
- })
166
- // 2️⃣b Windows 实际保存路径:C:\mobook\xxx、c:/mobook/xxx、\mobook\xxx(模型常写反斜杠,原先无法识别)
167
- const winMobookReg = new RegExp(`(?:[a-zA-Z]:)?[/\\\\]mobook[/\\\\]${FILE_NAME}\\.${EXT}`, 'gi')
168
- ;(text.match(winMobookReg) || []).forEach((full) => {
169
- const name = full.replace(/^(?:[a-zA-Z]:)?[/\\\\]mobook[/\\\\]/i, '').trim()
170
- if (isValidFileName(name)) {
171
- result.add(normalizePath(`/mobook/${name}`))
172
- }
173
- })
174
- // 3️⃣ mobook下的 xxx.xxx
175
- const inlineReg = new RegExp(`mobook下的\\s*(${FILE_NAME}\\.${EXT})`, 'gi')
176
- ;(text.match(inlineReg) || []).forEach((item) => {
177
- const match = item.match(new RegExp(`${FILE_NAME}\\.${EXT}`, 'i'))
178
- if (match && isValidFileName(match[0])) {
179
- result.add(`/mobook/${match[0].trim()}`)
180
- }
181
- })
182
- // 🆕 4️⃣ **xxx.xxx**
183
- const boldReg = new RegExp(`\\*\\*(${FILE_NAME}\\.${EXT})\\*\\*`, 'gi')
184
- ;(text.match(boldReg) || []).forEach((item) => {
185
- const name = item.replace(/\*\*/g, '').trim()
186
- if (isValidFileName(name)) {
187
- result.add(`/mobook/${name}`)
188
- }
189
- })
190
- // 🆕 5️⃣ xxx.xxx (123字节)
191
- const looseReg = new RegExp(`(${FILE_NAME}\\.${EXT})\\s*\\(`, 'gi')
192
- ;(text.match(looseReg) || []).forEach((item) => {
193
- const name = item.replace(/\s*\(.+$/, '').trim()
194
- if (isValidFileName(name)) {
195
- result.add(`/mobook/${name}`)
196
- }
197
- })
198
- // 6️⃣ 兜底:绝对路径等 `.../mobook/<文件名>.<扩展名>` + 最长后缀匹配 + 去脏字符
199
- collectMobookPathsByScan(text, result)
200
- } catch (e) {
201
- dcgLogger(`extractMobookFiles error:${e}`)
202
- }
203
- return [...result]
204
- }
205
-
206
- /**
207
- * 校验文件名是否合法(避免脏数据)
208
- */
209
- function isValidFileName(name: string) {
210
- if (!name) return false
211
- const cleaned = stripMobookNoise(name).trim()
212
- if (!cleaned) return false
213
- if (cleaned.includes('\uFFFD')) return false
214
- // 过滤异常字符
215
- if (/[\/\\<>:"|?*]/.test(cleaned)) return false
216
- // 长度限制(防止异常长字符串)
217
- if (cleaned.length > 200) return false
218
- return true
219
- }
220
-
221
- /**
222
- * 规范路径(去重用)
223
- */
224
- function normalizePath(path: string) {
225
- return path
226
- .replace(/\/+/g, '/') // 多斜杠 → 单斜杠
227
- .replace(/\/$/, '') // 去掉结尾 /
228
- }
@@ -1,64 +0,0 @@
1
- import { handleDcgchatMessage } from '../bot.js'
2
- import { setMsgStatus, getSessionKey } from './global.js'
3
- import type { InboundMessage } from '../types.js'
4
- import { installSkill, uninstallSkill } from '../skill.js'
5
- import { dcgLogger } from './log.js'
6
- import { onDisabledCronJob, onEnabledCronJob, onRemoveCronJob, onRunCronJob } from '../cron.js'
7
- import { ignoreToolCommand } from './constant.js'
8
- import { createAgent } from '../agent.js'
9
-
10
- export type ParsedWsPayload = {
11
- messageType?: string
12
- content: any
13
- }
14
-
15
- /**
16
- * 处理 WebSocket 已解析 JSON 且 content 已二次 parse 后的业务消息(openclaw_bot_chat / openclaw_bot_event)。
17
- */
18
- export async function handleParsedWsMessage(parsed: ParsedWsPayload, rawPayload: string, accountId: string): Promise<void> {
19
- if (parsed.messageType == 'openclaw_bot_chat') {
20
- const msg = parsed as unknown as InboundMessage
21
- // 与 monitor 原逻辑一致:工具类指令不进入 running,避免误触工具链监控
22
- const effectiveSessionKey = getSessionKey(msg.content, accountId)
23
- if (!ignoreToolCommand.includes(msg.content.text?.trim() ?? '')) {
24
- setMsgStatus(effectiveSessionKey, 'running')
25
- } else {
26
- setMsgStatus(effectiveSessionKey, 'finished')
27
- }
28
- await handleDcgchatMessage(msg, accountId)
29
- } else if (parsed.messageType == 'openclaw_bot_event') {
30
- const { event_type, operation_type } = parsed.content ? parsed.content : ({} as Record<string, any>)
31
- if (event_type === 'skill') {
32
- const { skill_url, skill_code, skill_id, bot_token, websocket_trace_id } = parsed.content
33
- const content = { event_type, operation_type, skill_url, skill_code, skill_id, bot_token, websocket_trace_id }
34
- if (operation_type === 'install' || operation_type === 'enable' || operation_type === 'update') {
35
- installSkill({ path: skill_url, code: skill_code }, content)
36
- } else if (operation_type === 'remove' || operation_type === 'disable') {
37
- uninstallSkill({ code: skill_code }, content)
38
- } else {
39
- dcgLogger(`openclaw_bot_event unknown event_type: ${event_type}, ${rawPayload}`)
40
- }
41
- } else if (event_type === 'agent') {
42
- if (operation_type === 'create') {
43
- await createAgent(parsed.content)
44
- } else {
45
- dcgLogger(`openclaw_bot_event unknown event_type: ${event_type}, ${rawPayload}`)
46
- }
47
- } else if (event_type === 'cron') {
48
- const { job_id, message_id } = parsed.content
49
- if (operation_type === 'remove') {
50
- await onRemoveCronJob(job_id)
51
- } else if (operation_type === 'enable') {
52
- await onEnabledCronJob(job_id)
53
- } else if (operation_type === 'disable') {
54
- await onDisabledCronJob(job_id)
55
- } else if (operation_type === 'run') {
56
- await onRunCronJob(job_id, message_id)
57
- }
58
- } else {
59
- dcgLogger(`openclaw_bot_event unknown operation_type: ${operation_type}, ${rawPayload}`)
60
- }
61
- } else {
62
- dcgLogger(`ignoring unknown messageType: ${parsed.messageType}`, 'error')
63
- }
64
- }
@@ -1,97 +0,0 @@
1
- import path from 'path'
2
- import fs from 'fs'
3
- /** @ts-ignore */
4
- import unzipper from 'unzipper'
5
- import { pipeline } from 'stream/promises'
6
- import { decodeZipEntryPath } from './zipPath.js'
7
-
8
- /**
9
- * 若且唯若所有条目都在同一顶层目录下(如 GitHub 下载的 repo-name/...),返回该目录名;否则返回 null。
10
- * 不能再用「第一个多段路径的第一段」推断,否则 ZIP 条目顺序变化时会误判(例如先出现 .github/ 或 src/)。
11
- */
12
- export function computeSharedZipRootPrefix(decodedPaths: string[]): string | null {
13
- const normalized = decodedPaths
14
- .map((p) => p.replace(/\\/g, '/').replace(/\/+$/, ''))
15
- .filter((p) => p.length > 0)
16
-
17
- if (normalized.length === 0) return null
18
-
19
- const firstSegs = new Set<string>()
20
- for (const p of normalized) {
21
- const seg = p.split('/').filter(Boolean)[0]
22
- if (seg) firstSegs.add(seg)
23
- }
24
- if (firstSegs.size !== 1) return null
25
-
26
- const root = [...firstSegs][0]!
27
- const prefix = `${root}/`
28
- for (const p of normalized) {
29
- if (p !== root && !p.startsWith(prefix)) return null
30
- }
31
- return root
32
- }
33
-
34
- function assertSafeZipTarget(destDir: string, relativePath: string): string {
35
- const resolvedPath = path.resolve(destDir, relativePath)
36
- const resolvedDest = path.resolve(destDir)
37
- const rel = path.relative(resolvedDest, resolvedPath)
38
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
39
- throw new Error(`zip 路径越界: ${relativePath}`)
40
- }
41
- return resolvedPath
42
- }
43
-
44
- type ZipEntry = {
45
- path: string
46
- pathBuffer: Buffer
47
- flags: number
48
- type: string
49
- stream: (password?: string) => NodeJS.ReadableStream
50
- }
51
-
52
- /**
53
- * 将已下载的 zip 解压到 destDir;顶层单根目录(若存在)会被剥掉,与原先流式 Parse 行为一致,但根目录由全量路径计算,与条目顺序无关。
54
- */
55
- export async function extractZipBufferToDirectory(buf: Buffer, destDir: string): Promise<void> {
56
- const directory = await unzipper.Open.buffer(buf)
57
- const files = (await directory.files) as ZipEntry[]
58
-
59
- const decodedPaths = files.map((entry) =>
60
- decodeZipEntryPath(entry.pathBuffer, entry.flags ?? 0, entry.path)
61
- )
62
- const rootDir = computeSharedZipRootPrefix(decodedPaths)
63
-
64
- // 与 unzipper 默认 extract 一致:串行读各 entry,避免同一 buffer 上多路解压竞争
65
- for (let i = 0; i < files.length; i++) {
66
- const entry = files[i]!
67
- const entryPath = decodedPaths[i]!
68
- let newPath = entryPath.replace(/\\/g, '/')
69
- if (rootDir) {
70
- if (newPath === rootDir || newPath === `${rootDir}/`) {
71
- continue
72
- }
73
- if (newPath.startsWith(`${rootDir}/`)) {
74
- newPath = newPath.slice(rootDir.length + 1)
75
- }
76
- }
77
- newPath = newPath.replace(/\/+$/, '')
78
- if (!newPath) continue
79
-
80
- const targetPath = assertSafeZipTarget(destDir, newPath)
81
-
82
- if (entry.type === 'Directory') {
83
- fs.mkdirSync(targetPath, { recursive: true })
84
- continue
85
- }
86
-
87
- const parentDir = path.dirname(targetPath)
88
- fs.mkdirSync(parentDir, { recursive: true })
89
- const writeStream = fs.createWriteStream(targetPath)
90
- try {
91
- await pipeline(entry.stream(), writeStream)
92
- } catch (err) {
93
- const message = err instanceof Error ? err.message : String(err)
94
- throw new Error(`解压文件失败 ${entryPath}: ${message}`)
95
- }
96
- }
97
- }
@@ -1,24 +0,0 @@
1
- /**
2
- * ZIP 文件名编码:规范要求 UTF-8 时设置 0x800;很多工具未设标志但仍写 UTF-8 字节。
3
- * 无标志时若一律按 GBK 解码,会得到「鍥句功…」类乱码。先严格 UTF-8,失败再 GBK(兼容 Windows 中文 ZIP)。
4
- */
5
- export function decodeZipEntryPath(
6
- pathBuffer: Buffer | Uint8Array | undefined,
7
- flags: number,
8
- fallbackPath: string
9
- ): string {
10
- if ((flags & 0x800) !== 0) {
11
- if (pathBuffer) {
12
- return new TextDecoder('utf-8').decode(pathBuffer)
13
- }
14
- return fallbackPath
15
- }
16
- if (pathBuffer && pathBuffer.length > 0) {
17
- try {
18
- return new TextDecoder('utf-8', { fatal: true }).decode(pathBuffer)
19
- } catch {
20
- return new TextDecoder('gbk').decode(pathBuffer)
21
- }
22
- }
23
- return fallbackPath
24
- }