@dcrays/dcgchat-test 0.6.1 → 0.6.2

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 (59) hide show
  1. package/README.md +167 -0
  2. package/index.ts +31 -0
  3. package/openclaw.plugin.json +12 -41
  4. package/package.json +38 -11
  5. package/src/agent.ts +592 -0
  6. package/src/bot.ts +676 -0
  7. package/src/channel/index.ts +329 -0
  8. package/src/channel/outboundMedia.ts +144 -0
  9. package/src/channel/outboundTarget.ts +24 -0
  10. package/src/channel/uploadMediaUrl.ts +76 -0
  11. package/src/cron/message.ts +109 -0
  12. package/src/cron/params.ts +166 -0
  13. package/src/cron/toolGuard.ts +285 -0
  14. package/src/cron/types.ts +15 -0
  15. package/src/gateway/index.ts +430 -0
  16. package/src/gateway/security.ts +95 -0
  17. package/src/gateway/socket.ts +256 -0
  18. package/src/libs/ali-oss-6.23.0.tgz +0 -0
  19. package/src/libs/axios-1.13.6.tgz +0 -0
  20. package/src/libs/md5-2.3.0.tgz +0 -0
  21. package/src/libs/mime-types-3.0.2.tgz +0 -0
  22. package/src/libs/unzipper-0.12.3.tgz +0 -0
  23. package/src/libs/ws-8.19.0.tgz +0 -0
  24. package/src/monitor.ts +269 -0
  25. package/src/request/api.ts +80 -0
  26. package/src/request/oss.ts +200 -0
  27. package/src/request/request.ts +191 -0
  28. package/src/request/userInfo.ts +44 -0
  29. package/src/session.ts +28 -0
  30. package/src/skill.ts +114 -0
  31. package/src/tool.ts +625 -0
  32. package/src/tools/messageTool.ts +334 -0
  33. package/src/tools/toolCallGuard.ts +323 -0
  34. package/src/transport.ts +217 -0
  35. package/src/types.ts +139 -0
  36. package/src/utils/agentErrors.ts +23 -0
  37. package/src/utils/constant.ts +60 -0
  38. package/src/utils/env-config.ts +19 -0
  39. package/src/utils/formatLlmInputEvent.ts +48 -0
  40. package/src/utils/gatewayMsgHandler.ts +89 -0
  41. package/src/utils/global.ts +306 -0
  42. package/src/utils/inboundTurnState.ts +66 -0
  43. package/src/utils/log.ts +77 -0
  44. package/src/utils/mediaAttached.ts +244 -0
  45. package/src/utils/mediaEmitter.ts +54 -0
  46. package/src/utils/outboundAssistantText.ts +110 -0
  47. package/src/utils/params.ts +104 -0
  48. package/src/utils/passTxt.ts +4 -0
  49. package/src/utils/resolveRegisterConfig.ts +33 -0
  50. package/src/utils/searchFile.ts +228 -0
  51. package/src/utils/sessionState.ts +137 -0
  52. package/src/utils/sessionTermination.ts +228 -0
  53. package/src/utils/streamMerge.ts +150 -0
  54. package/src/utils/subagentRunMap.ts +71 -0
  55. package/src/utils/workspaceFilePaths.ts +18 -0
  56. package/src/utils/wsMessageHandler.ts +124 -0
  57. package/src/utils/zipExtract.ts +97 -0
  58. package/src/utils/zipPath.ts +24 -0
  59. package/index.js +0 -304
@@ -0,0 +1,306 @@
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 type { OpenClawConfig, PluginRuntime } from 'openclaw/plugin-sdk'
6
+ import { createPluginRuntimeStore } from 'openclaw/plugin-sdk/runtime-store'
7
+ import { CHANNEL_ID } from './constant.js'
8
+ import { dcgLogger } from './log.js'
9
+
10
+ /** 配置中 `channels.*.appId` 为该值时,默认工作区落在 `~/.mobook/workspace`(否则 `~/.openclaw/workspace`)。 */
11
+ export const MOBOOK_WORKSPACE_APP_ID = 110
12
+
13
+ /** socket connection */
14
+ let ws: WebSocket | null = null
15
+
16
+ export function setWsConnection(next: WebSocket | null) {
17
+ ws = next
18
+ }
19
+
20
+ export function getWsConnection(): WebSocket | null {
21
+ return ws
22
+ }
23
+
24
+ let config: OpenClawConfig | null = null
25
+
26
+ export function setOpenClawConfig(next: OpenClawConfig | null) {
27
+ config = next
28
+ }
29
+
30
+ export function getOpenClawConfig(): OpenClawConfig | null {
31
+ return config
32
+ }
33
+
34
+ function getWorkspacePath(): string | null {
35
+ const chAppId = config?.channels?.[CHANNEL_ID]?.appId
36
+ const workspacePath = path.join(os.homedir(), Number(chAppId) === MOBOOK_WORKSPACE_APP_ID ? '.mobook' : '.openclaw', 'workspace')
37
+ if (fs.existsSync(workspacePath)) {
38
+ return workspacePath
39
+ }
40
+ return null
41
+ }
42
+
43
+ let workspaceDir: string = getWorkspacePath() ?? ''
44
+
45
+ export function setWorkspaceDir(dir?: string) {
46
+ if (dir && dir !== workspaceDir) {
47
+ workspaceDir = dir
48
+ dcgLogger(`setWorkspaceDir ==> ${dir}`)
49
+ }
50
+ }
51
+
52
+ export function getWorkspaceDir(): string {
53
+ if (!workspaceDir) {
54
+ dcgLogger?.('Workspace directory not initialized', 'error')
55
+ }
56
+ return workspaceDir
57
+ }
58
+
59
+ const { setRuntime: setDcgchatRuntime, getRuntime: getDcgchatRuntime } = createPluginRuntimeStore<PluginRuntime>(
60
+ `${CHANNEL_ID} runtime not initialized`
61
+ )
62
+ export { setDcgchatRuntime, getDcgchatRuntime }
63
+
64
+ export type MsgSessionStatus = 'running' | 'finished' | ''
65
+
66
+ const msgStatusBySessionKey = new Map<string, MsgSessionStatus>()
67
+
68
+ export function setMsgStatus(sessionKey: string, status: MsgSessionStatus) {
69
+ if (!sessionKey?.trim()) return
70
+ if (status === '') {
71
+ msgStatusBySessionKey.delete(sessionKey)
72
+ } else {
73
+ msgStatusBySessionKey.set(sessionKey, status)
74
+ }
75
+ }
76
+
77
+ export function getMsgStatus(sessionKey: string): MsgSessionStatus {
78
+ if (!sessionKey?.trim()) return ''
79
+ return msgStatusBySessionKey.get(sessionKey) ?? ''
80
+ }
81
+
82
+ /**
83
+ * 出站媒体去重 bucket:优先本轮用户 `messageId`(与 bot / sendFinal 清理一致),否则书灵 `sessionId`,最后可回落 `sessionKey`。
84
+ */
85
+ export function resolveOutboundMediaDedupeKey(messageId?: string, sessionId?: string, sessionKeyFallback?: string): string {
86
+ const mid = messageId?.trim()
87
+ if (mid) return mid
88
+ const sid = sessionId?.trim()
89
+ if (sid) return sid
90
+ return sessionKeyFallback?.trim() ?? ''
91
+ }
92
+
93
+ /**
94
+ * 本地路径用 `path.resolve`、http(s) 用「origin+pathname」并去掉 query,避免仅用 basename 导致跨目录误去重。
95
+ */
96
+ export function mediaPathDedupeKey(mediaUrl: string): string {
97
+ const t = mediaUrl.trim()
98
+ if (!t) return ''
99
+ if (/^https?:\/\//i.test(t)) {
100
+ try {
101
+ const u = new URL(t)
102
+ return `${u.origin}${u.pathname}`
103
+ } catch {
104
+ return t.split('?')[0] ?? t
105
+ }
106
+ }
107
+ try {
108
+ const r = path.resolve(t)
109
+ return process.platform === 'win32' ? r.toLowerCase() : r
110
+ } catch {
111
+ const n = path.normalize(t)
112
+ return process.platform === 'win32' ? n.toLowerCase() : n
113
+ }
114
+ }
115
+
116
+ /** 已发送媒体去重:外层 dedupe bucket(见 resolveOutboundMediaDedupeKey)→ 内层规范化路径/url key */
117
+ const sentMediaKeysBySession = new Map<string, Set<string>>()
118
+
119
+ function getSessionMediaSet(dedupeBucketKey: string): Set<string> {
120
+ let set = sentMediaKeysBySession.get(dedupeBucketKey)
121
+ if (!set) {
122
+ set = new Set<string>()
123
+ sentMediaKeysBySession.set(dedupeBucketKey, set)
124
+ }
125
+ return set
126
+ }
127
+
128
+ export function addSentMediaKey(dedupeBucketKey: string, url: string) {
129
+ const k = dedupeBucketKey.trim()
130
+ if (!k) return
131
+ getSessionMediaSet(k).add(mediaPathDedupeKey(url))
132
+ }
133
+
134
+ export function hasSentMediaKey(dedupeBucketKey: string, url: string): boolean {
135
+ const k = dedupeBucketKey.trim()
136
+ if (!k) return false
137
+ return sentMediaKeysBySession.get(k)?.has(mediaPathDedupeKey(url)) ?? false
138
+ }
139
+
140
+ /** 仅删除指定 bucket;`dedupeBucketKey` 为空则 no-op(不做「清空全部」以免误伤)。 */
141
+ export function clearSentMediaKeys(dedupeBucketKey?: string) {
142
+ const k = dedupeBucketKey?.trim()
143
+ if (!k) return
144
+ sentMediaKeysBySession.delete(k)
145
+ }
146
+
147
+ /** 每个 sessionKey 下多个定时任务 messageId,入队在尾、出队在头(FIFO) */
148
+ const cronMessageIdMap = new Map<string, string[]>()
149
+
150
+ export function setCronMessageId(sk: string, messageId: string | number | null | undefined) {
151
+ const mid = messageId != null && messageId !== '' ? String(messageId).trim() : ''
152
+ if (!sk?.trim() || !mid) return
153
+ let q = cronMessageIdMap.get(sk)
154
+ if (!q) {
155
+ q = []
156
+ cronMessageIdMap.set(sk, q)
157
+ }
158
+ q.push(mid)
159
+ }
160
+
161
+ /** 窥视队首 messageId(不移除),供发送中与 finished 配对使用 */
162
+ export function getCronMessageId(sk: string): string {
163
+ if (!sk?.trim()) return ''
164
+ return cronMessageIdMap.get(sk)?.[0] ?? ''
165
+ }
166
+
167
+ /** 弹出队首一条,与一次 finished 对应;队列为空时移除 key */
168
+ export function removeCronMessageId(sk: string) {
169
+ if (!sk?.trim()) return
170
+ const q = cronMessageIdMap.get(sk)
171
+ if (!q?.length) return
172
+ q.shift()
173
+ if (q.length === 0) {
174
+ cronMessageIdMap.delete(sk)
175
+ }
176
+ }
177
+
178
+ /**
179
+ * turn 已正常收尾时清空该 sessionKey 的整条 cron 队列。
180
+ *
181
+ * 背景:
182
+ * - `setCronMessageId` 同时服务于两类调用方:
183
+ * 1. `cron/message.ts onRunCronJob`:每条 cron.run 写入队首,与 `finishedDcgchatCron` 1.2s 后 `removeCronMessageId` 配对消费(队首一条)。
184
+ * 2. `outboundMedia.ts sendDcgchatMedia` 兜底:未传 `opts.messageId` 且队列为空时写入 `${Date.now()}`,本意是给后续 cron 气泡配 messageId。
185
+ * 这条 entry 永远不会被 `finishedDcgchatCron` 消费(其队首始终是 onRunCronJob 写入的),且每次 `deliver` 回调都会触发一条 → 队列无限增长。
186
+ * - turn 收尾时该 sessionKey 下不会再有 in-flight finished 配对,可安全清空。
187
+ * - 若 1.2s 内 `finishedDcgchatCron` 才被调用,`q.shift()` 在空队列上为 no-op(`removeCronMessageId` 已判空),不报错。
188
+ */
189
+ export function removeAllCronMessageIdsForSession(sk: string): void {
190
+ if (!sk?.trim()) return
191
+ cronMessageIdMap.delete(sk)
192
+ }
193
+
194
+ /** 用于网关重启时持久化/恢复的全局会话状态快照 */
195
+ export type GlobalSessionStateSnapshot = {
196
+ msgStatusBySessionKey: Array<[string, MsgSessionStatus]>
197
+ /** 媒体已发送去重:bucket 为 `resolveOutboundMediaDedupeKey`(优先 messageId) */
198
+ sentMediaKeysBySession: Array<[string, string[]]>
199
+ cronMessageIdMap: Array<[string, string[]]>
200
+ }
201
+
202
+ export function snapshotGlobalSessionState(): GlobalSessionStateSnapshot {
203
+ const runningEntries = [...msgStatusBySessionKey.entries()].filter(([, status]) => status === 'running')
204
+ const runningSessionKeys = new Set(runningEntries.map(([k]) => k))
205
+ return {
206
+ msgStatusBySessionKey: runningEntries,
207
+ sentMediaKeysBySession: [],
208
+ cronMessageIdMap: [...cronMessageIdMap.entries()]
209
+ .filter(([k]) => runningSessionKeys.has(k))
210
+ .map(([k, q]) => [k, [...q]])
211
+ }
212
+ }
213
+
214
+ export function restoreGlobalSessionState(snap: GlobalSessionStateSnapshot | null | undefined): void {
215
+ if (!snap) return
216
+ msgStatusBySessionKey.clear()
217
+ sentMediaKeysBySession.clear()
218
+ cronMessageIdMap.clear()
219
+ for (const [k, v] of snap.msgStatusBySessionKey ?? []) {
220
+ if (typeof k === 'string' && k && v === 'running') {
221
+ msgStatusBySessionKey.set(k, v)
222
+ }
223
+ }
224
+ for (const [k, arr] of snap.cronMessageIdMap ?? []) {
225
+ if (typeof k !== 'string' || !k || !Array.isArray(arr)) continue
226
+ const q = arr.filter((x): x is string => typeof x === 'string' && !!x)
227
+ if (q.length > 0) cronMessageIdMap.set(k, q)
228
+ }
229
+ }
230
+
231
+ /** 与 bot 侧一致:session_id 为 `agentId::对话标识` 时提取 agentId(专家会话可仅依赖此前缀)。 */
232
+ export function extractAgentIdFromConversationId(conversationId: string): string | null {
233
+ const idx = conversationId.indexOf('::')
234
+ if (idx <= 0) return null
235
+ return conversationId.slice(0, idx)
236
+ }
237
+
238
+ export const getSessionKey = (content: any, accountId: string) => {
239
+ const { real_mobook, agent_id, agent_clone_code, session_id } = content
240
+ const core = getDcgchatRuntime()
241
+
242
+ const sid = typeof session_id === 'string' ? session_id.trim() : ''
243
+ const embeddedAgent = sid ? extractAgentIdFromConversationId(sid) : null
244
+ const agentCode = (typeof agent_clone_code === 'string' && agent_clone_code.trim()) || embeddedAgent || 'main'
245
+
246
+ const route = core.channel.routing.resolveAgentRoute({
247
+ cfg: getOpenClawConfig() as OpenClawConfig,
248
+ channel: CHANNEL_ID,
249
+ accountId: accountId || 'default',
250
+ peer: { kind: 'direct', id: session_id }
251
+ })
252
+ return real_mobook == '1' ? route.sessionKey : `agent:${agentCode}:mobook:direct:${agent_id}:${session_id}`.toLowerCase()
253
+ }
254
+
255
+ interface TakeApartSessionKeyResult {
256
+ sessionId: string
257
+ agentId: string
258
+ }
259
+ export function takeApartSessionKey(sk: string): TakeApartSessionKeyResult {
260
+ const sessionInfo = sk.split(':')
261
+ const sessionId = sessionInfo.at(-1) ?? ''
262
+ const agentId = sessionInfo.at(-2) ?? ''
263
+ return { sessionId, agentId }
264
+ }
265
+
266
+ export function compressFields(obj: Record<string, unknown>, fields: string[] = []) {
267
+ const start = 8
268
+ const end = 8
269
+ const newObj = { ...obj }
270
+
271
+ const compressText = (str: unknown) => {
272
+ if (typeof str !== 'string') return str
273
+
274
+ if (str.length <= start + end) {
275
+ return str
276
+ }
277
+
278
+ const omittedCount = str.length - start - end
279
+
280
+ return str.slice(0, start) + `...省略${omittedCount}个字...` + str.slice(-end)
281
+ }
282
+
283
+ fields.forEach((field) => {
284
+ if (field in newObj) {
285
+ newObj[field] = compressText(newObj[field])
286
+ }
287
+ })
288
+
289
+ return newObj
290
+ }
291
+
292
+ export function formatText(text: string): string {
293
+ if (!text) return ''
294
+ const str = String(text).replace(/\s/g, '')
295
+ if (!str) return ''
296
+ if (str.length <= 50) {
297
+ return str
298
+ }
299
+ return str.slice(0, 25) + `...[此处省略${str.length - 50}字]....` + str.slice(-25)
300
+ }
301
+
302
+ export function isReviserExpert(sessionKey?: string): boolean {
303
+ if (!sessionKey) return false
304
+ const segment = sessionKey.split(':')[1]
305
+ return segment != null && segment.includes('reviser-expert')
306
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 入站 generation 与流式分片序号(bot 与会话清理共用)。
3
+ * 独立模块以避免 bot 与 sessionTermination 之间的循环依赖。
4
+ */
5
+
6
+ const inboundGenerationBySessionKey = new Map<string, number>()
7
+ const streamChunkIdxBySessionKey = new Map<string, number>()
8
+
9
+ /**
10
+ * stop 序列号:每条 /stop 在 turn 开始时递增,用于 stale handler 判定。
11
+ * 与 generation 独立:generation 区分不同用户消息,stopSeq 区分 /stop 与普通消息。
12
+ */
13
+ const stopSeqBySessionKey = new Map<string, number>()
14
+
15
+ /** 每条入站消息在 turn 开始时单调递增;用于 stale handler 判定 */
16
+ export function bumpInboundGeneration(sessionKey: string): number {
17
+ const n = (inboundGenerationBySessionKey.get(sessionKey) ?? 0) + 1
18
+ inboundGenerationBySessionKey.set(sessionKey, n)
19
+ return n
20
+ }
21
+
22
+ export function getInboundGeneration(sessionKey: string): number {
23
+ return inboundGenerationBySessionKey.get(sessionKey) ?? 0
24
+ }
25
+
26
+ /**
27
+ * 每条 /stop 在 turn 开始时单调递增;用于 stale handler 判定(与 generation 独立检查)。
28
+ * 普通消息 bumpGeneration 时不 bump stopSeq,只有 /stop 才 bump。
29
+ */
30
+ export function bumpStopSeq(sessionKey: string): number {
31
+ const n = (stopSeqBySessionKey.get(sessionKey) ?? 0) + 1
32
+ stopSeqBySessionKey.set(sessionKey, n)
33
+ return n
34
+ }
35
+
36
+ export function getStopSeq(sessionKey: string): number {
37
+ return stopSeqBySessionKey.get(sessionKey) ?? 0
38
+ }
39
+
40
+ /** 第一条 /stop 后的新用户消息已接管会话时,消费 stop 标记,避免后续消息一直被当作 after-stop。 */
41
+ export function clearStopSeqForSession(sessionKey: string): void {
42
+ stopSeqBySessionKey.delete(sessionKey)
43
+ }
44
+
45
+ /** 会话完全终止时清理 generation 与 streamChunkIdx,防止 Map 泄漏 */
46
+ export function clearInboundGenerationForSession(sessionKey: string): void {
47
+ inboundGenerationBySessionKey.delete(sessionKey)
48
+ streamChunkIdxBySessionKey.delete(sessionKey)
49
+ stopSeqBySessionKey.delete(sessionKey)
50
+ }
51
+
52
+ /** 本轮正常收尾时仅删除分片序号(generation 保留) */
53
+ export function deleteStreamChunkIdxForSession(sessionKey: string): void {
54
+ streamChunkIdxBySessionKey.delete(sessionKey)
55
+ }
56
+
57
+ export function resetStreamChunkIdxForSession(sessionKey: string, value: number): void {
58
+ streamChunkIdxBySessionKey.set(sessionKey, value)
59
+ }
60
+
61
+ /** 返回当前序号并将计数 +1,供 sendChunk(..., prev) 使用 */
62
+ export function takeStreamChunkIdxAndAdvance(sessionKey: string): number {
63
+ const prev = streamChunkIdxBySessionKey.get(sessionKey) ?? 0
64
+ streamChunkIdxBySessionKey.set(sessionKey, prev + 1)
65
+ return prev
66
+ }
@@ -0,0 +1,77 @@
1
+ import { RuntimeEnv } from 'openclaw/plugin-sdk'
2
+ import { CHANNEL_ID } from './constant.js'
3
+
4
+ let logger: RuntimeEnv | null = null
5
+ const STACK_ROOT = 'openclaw-dcgchat'
6
+ /** 发布包 scope,与 @dcrays/dcgchat-test 前段一致,用于匹配已安装到 node_modules 下的路径 */
7
+ const SCOPE = '@dcrays/dcgchat'
8
+
9
+ function isOurCodePath(line: string): boolean {
10
+ if (line.includes(STACK_ROOT)) return true
11
+ if (line.includes(SCOPE)) return true
12
+ // 无 node_modules 的本地单文件 /dist/index.js
13
+ if (line.replaceAll('\\', '/').includes('dist/index.js') && !line.includes('node_modules')) {
14
+ return true
15
+ }
16
+ return false
17
+ }
18
+
19
+ function isOtherNodeModuleLine(line: string): boolean {
20
+ if (!line.includes('node_modules/')) return false
21
+ // 本扩展装在 host 的 node_modules 下时,路径仍要保留
22
+ if (line.includes('node_modules/@dcrays/')) return false
23
+ if (line.includes(`node_modules/${STACK_ROOT}/`)) return false
24
+ return true
25
+ }
26
+
27
+ export function setLogger(next: RuntimeEnv | null) {
28
+ logger = next
29
+ }
30
+
31
+ function buildFilteredStack(): string {
32
+ const stack = new Error().stack
33
+ if (!stack) return ''
34
+ const frames = stack
35
+ .split('\n')
36
+ .slice(1)
37
+ .map((line) => line.trim())
38
+ .filter(
39
+ (line) =>
40
+ isOurCodePath(line) &&
41
+ !isOtherNodeModuleLine(line) &&
42
+ !line.includes('dcgLogger') &&
43
+ !line.includes('buildFilteredStack')
44
+ )
45
+ .map((line) => {
46
+ const normalized = line.replaceAll('\\', '/')
47
+ const fromRoot = normalized.indexOf(`${STACK_ROOT}/`)
48
+ if (fromRoot >= 0) {
49
+ const trimmed = normalized.slice(fromRoot + STACK_ROOT.length + 1)
50
+ return normalized.startsWith('at ') ? `at ${trimmed}` : trimmed
51
+ }
52
+ const scopeIdx = normalized.indexOf(`${SCOPE}`)
53
+ if (scopeIdx >= 0) {
54
+ const tail = normalized.slice(scopeIdx)
55
+ return normalized.startsWith('at ') ? `at ${tail}` : tail
56
+ }
57
+ return normalized.replace(/^at\s+/, '')
58
+ })
59
+
60
+ if (!frames.length) return ''
61
+ return `\nstack:\n${frames.join('\n')}`
62
+ }
63
+
64
+ export function dcgLogger(
65
+ message: string,
66
+ type: 'log' | 'error' = 'log',
67
+ options?: { includeStack?: boolean }
68
+ ): void {
69
+ const includeStack = options?.includeStack ?? false
70
+ const stack = includeStack ? buildFilteredStack() : ''
71
+ const fullMessage = `${message}${stack}`
72
+ if (logger) {
73
+ logger[type](`书灵墨宝🚀 ~ [${new Date().toISOString()}] ${fullMessage}`)
74
+ } else {
75
+ console[type](`书灵墨宝🚀 ~ ${new Date().toISOString()} [${CHANNEL_ID}]: ${fullMessage}`)
76
+ }
77
+ }
@@ -0,0 +1,244 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path'
3
+ import { buildAgentMediaPayload } from 'openclaw/plugin-sdk/agent-media-payload'
4
+ import { getDcgchatRuntime } from './global.js'
5
+ import { dcgLogger } from './log.js'
6
+ import { generateSignUrl } from '../request/api.js'
7
+
8
+ export type DcgchatInboundMediaInfo = {
9
+ path: string
10
+ fileName: string
11
+ contentType: string
12
+ placeholder: string
13
+ }
14
+
15
+ export type DcgchatInboundFileRef = { name: string; url: string }
16
+
17
+ export const DCGCHAT_INBOUND_MEDIA_MAX_BYTES = 500 * 1024 * 1024
18
+
19
+ const INBOUND_FILE_URL_KEYS = ['url', 'fileUrl', 'file_url', 'link', 'href', 'path', 'ossKey', 'oss_key', 'key'] as const
20
+ const INBOUND_FILE_NAME_KEYS = ['name', 'fileName', 'file_name', 'title', 'originalName', 'original_filename'] as const
21
+ const INBOUND_FILE_ARRAY_KEYS = [
22
+ 'files',
23
+ 'file_list',
24
+ 'fileList',
25
+ 'attachments',
26
+ 'media_files',
27
+ 'mediaFiles',
28
+ 'resources',
29
+ 'uploads'
30
+ ] as const
31
+
32
+ function firstNonEmptyString(o: Record<string, unknown>, keys: readonly string[]): string | undefined {
33
+ for (const k of keys) {
34
+ const v = o[k]
35
+ if (typeof v === 'string' && v.trim()) return v.trim()
36
+ }
37
+ return undefined
38
+ }
39
+
40
+ function fallbackFileNameFromUrl(url: string): string {
41
+ try {
42
+ return path.basename(new URL(url).pathname) || 'file'
43
+ } catch {
44
+ const base = url.split(/[\\/]/).pop() ?? url
45
+ return path.basename(base.split('?')[0] || base) || 'file'
46
+ }
47
+ }
48
+
49
+ function coerceInboundFileItem(item: unknown): DcgchatInboundFileRef | null {
50
+ if (item == null) return null
51
+ if (typeof item === 'string') {
52
+ const t = item.trim()
53
+ if (!t) return null
54
+ return { url: t, name: fallbackFileNameFromUrl(t) }
55
+ }
56
+ if (typeof item !== 'object') return null
57
+ const o = item as Record<string, unknown>
58
+ const url = firstNonEmptyString(o, INBOUND_FILE_URL_KEYS)
59
+ if (!url) return null
60
+ const name = firstNonEmptyString(o, INBOUND_FILE_NAME_KEYS) ?? ''
61
+ return { url, name: name || fallbackFileNameFromUrl(url) }
62
+ }
63
+
64
+ function asInboundFileArray(raw: unknown): unknown[] {
65
+ if (Array.isArray(raw)) return raw
66
+ if (typeof raw === 'string') {
67
+ const t = raw.trim()
68
+ if (!t) return []
69
+ try {
70
+ const p = JSON.parse(t) as unknown
71
+ return Array.isArray(p) ? p : []
72
+ } catch {
73
+ return []
74
+ }
75
+ }
76
+ return []
77
+ }
78
+
79
+ /** 合并服务端可能使用的多种附件字段与对象形状,避免仅认 `files`+`url` 时视频未入队 */
80
+ export function normalizeInboundFileList(content: Record<string, unknown>): DcgchatInboundFileRef[] {
81
+ const seen = new Set<string>()
82
+ const out: DcgchatInboundFileRef[] = []
83
+ for (const key of INBOUND_FILE_ARRAY_KEYS) {
84
+ for (const item of asInboundFileArray(content[key])) {
85
+ const f = coerceInboundFileItem(item)
86
+ if (f && !seen.has(f.url)) {
87
+ seen.add(f.url)
88
+ out.push(f)
89
+ }
90
+ }
91
+ }
92
+ for (const key of ['file', 'attachment', 'media'] as const) {
93
+ const f = coerceInboundFileItem(content[key])
94
+ if (f && !seen.has(f.url)) {
95
+ seen.add(f.url)
96
+ out.push(f)
97
+ }
98
+ }
99
+ return out
100
+ }
101
+
102
+ /** 供模型侧识别媒体槽位类型 */
103
+ export function inferPlaceholderFromContentType(contentType: string): string {
104
+ const ct = (contentType || '').toLowerCase()
105
+ if (ct.startsWith('image/')) return '<media:image>'
106
+ if (ct.startsWith('video/')) return '<media:video>'
107
+ if (ct.startsWith('audio/')) return '<media:audio>'
108
+ if (
109
+ ct === 'application/pdf' ||
110
+ ct.includes('word') ||
111
+ ct.includes('document') ||
112
+ ct.includes('msword') ||
113
+ ct.includes('officedocument') ||
114
+ ct.includes('spreadsheet') ||
115
+ ct.includes('presentation')
116
+ ) {
117
+ return '<media:document>'
118
+ }
119
+ return '<media:document>'
120
+ }
121
+
122
+ /**
123
+ * 使用 SDK 与飞书通道一致的 Media* 布局,并保留 dcgchat 原有的文件名字段供工具/模板使用。
124
+ */
125
+ export function buildDcgchatInboundMediaPayload(mediaList: DcgchatInboundMediaInfo[]): Record<string, unknown> {
126
+ if (mediaList.length === 0) return {}
127
+ const base = buildAgentMediaPayload(mediaList.map((m) => ({ path: m.path, contentType: m.contentType || null })))
128
+ const mediaFileNames = mediaList.map((m) => m.fileName).filter(Boolean)
129
+ if (mediaFileNames.length === 0) return { ...base }
130
+ return {
131
+ ...base,
132
+ MediaFileName: mediaFileNames[0],
133
+ MediaFileNames: mediaFileNames
134
+ }
135
+ }
136
+
137
+ /** BodyForAgent:含附件说明与槽位行;与 RawBody/CommandBody(纯用户侧文本)分离 */
138
+ export function buildAgentInboundBodySuffix(userText: string, mediaList: DcgchatInboundMediaInfo[]): string {
139
+ if (mediaList.length === 0) return userText
140
+ const brief = mediaList.map((m, i) => `${i + 1}) ${m.fileName} [${m.contentType || 'unknown'}]`).join('; ')
141
+ const slots = mediaList.map((m) => m.placeholder).join('\n')
142
+ return `${userText}\n\n[系统: 用户已随本消息上传 ${mediaList.length} 个附件: ${brief}。请勿要求用户再次上传;下列槽位与 MediaPaths/MediaTypes 顺序一致。]\n${slots}`
143
+ }
144
+
145
+ const DEFAULT_INBOUND_FETCH_TIMEOUT_MS = 180_000
146
+ const DEFAULT_INBOUND_DOWNLOAD_CONCURRENCY = 3
147
+
148
+ async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
149
+ const ctrl = new AbortController()
150
+ const t = setTimeout(() => ctrl.abort(new Error(`fetch timeout ${timeoutMs}ms`)), timeoutMs)
151
+ try {
152
+ return await fetch(url, { signal: ctrl.signal })
153
+ } finally {
154
+ clearTimeout(t)
155
+ }
156
+ }
157
+
158
+ function resolveFilepath(savedFilePath: string, filename: string) {
159
+ if (path.extname(savedFilePath)) return savedFilePath
160
+
161
+ const ext = path.extname(filename)
162
+ if (!ext) return savedFilePath
163
+
164
+ const newPath = `${savedFilePath}${ext}`
165
+ try {
166
+ fs.renameSync(savedFilePath, newPath);
167
+ return newPath
168
+ } catch (err) {
169
+ dcgLogger(`media: failed to append extension to ${savedFilePath}: ${String(err)}`, 'error')
170
+ return savedFilePath
171
+ }
172
+ }
173
+
174
+ async function resolveOneInboundFile(
175
+ file: DcgchatInboundFileRef,
176
+ botToken: string,
177
+ maxBytes: number,
178
+ fetchTimeoutMs: number
179
+ ): Promise<DcgchatInboundMediaInfo | null> {
180
+ const core = getDcgchatRuntime()
181
+ try {
182
+ let data = ''
183
+ if (/^https?:\/\//i.test(file.url)) {
184
+ data = file.url
185
+ } else {
186
+ data = await generateSignUrl(file.url, botToken)
187
+ }
188
+ const response = await fetchWithTimeout(data, fetchTimeoutMs)
189
+ if (!response.ok) {
190
+ dcgLogger?.(`media: ${file.url} fetch failed with HTTP ${response.status}`, 'error')
191
+ return null
192
+ }
193
+ const buffer = Buffer.from(await response.arrayBuffer())
194
+
195
+ let contentType = response.headers.get('content-type') || ''
196
+ if (!contentType) {
197
+ contentType = (await core.media.detectMime({ buffer })) || ''
198
+ }
199
+ const fileName = file.name || fallbackFileNameFromUrl(file.url)
200
+ const saved = await core.channel.media.saveMediaBuffer(buffer, contentType, 'inbound', maxBytes, fileName)
201
+ const effectiveType = saved.contentType || contentType
202
+
203
+ const finalPath = resolveFilepath(saved.path, fileName)
204
+
205
+ return {
206
+ path: finalPath,
207
+ fileName,
208
+ contentType: effectiveType,
209
+ placeholder: inferPlaceholderFromContentType(effectiveType)
210
+ }
211
+ } catch (err) {
212
+ dcgLogger(`media: ${file.url} FAILED to process: ${String(err)}`, 'error')
213
+ return null
214
+ }
215
+ }
216
+
217
+ export async function resolveMediaFromUrls(
218
+ files: DcgchatInboundFileRef[],
219
+ botToken: string,
220
+ opts?: { maxBytes?: number; fetchTimeoutMs?: number; concurrency?: number }
221
+ ): Promise<DcgchatInboundMediaInfo[]> {
222
+ const maxBytes = opts?.maxBytes ?? DCGCHAT_INBOUND_MEDIA_MAX_BYTES
223
+ const fetchTimeoutMs = opts?.fetchTimeoutMs ?? DEFAULT_INBOUND_FETCH_TIMEOUT_MS
224
+ const conc = Math.max(1, opts?.concurrency ?? DEFAULT_INBOUND_DOWNLOAD_CONCURRENCY)
225
+ const concurrency = files.length === 0 ? 1 : Math.min(conc, files.length)
226
+
227
+ dcgLogger(`media: inbound files count=${files.length}, concurrency=${concurrency}, fetchTimeoutMs=${fetchTimeoutMs}`)
228
+ const results: (DcgchatInboundMediaInfo | null)[] = new Array(files.length).fill(null)
229
+ let nextIndex = 0
230
+
231
+ async function worker() {
232
+ while (true) {
233
+ const i = nextIndex++
234
+ if (i >= files.length) break
235
+ results[i] = await resolveOneInboundFile(files[i], botToken, maxBytes, fetchTimeoutMs)
236
+ }
237
+ }
238
+
239
+ await Promise.all(Array.from({ length: concurrency }, () => worker()))
240
+
241
+ const out = results.filter((x): x is DcgchatInboundMediaInfo => x != null)
242
+ dcgLogger(`media: resolve complete, ${out.length}/${files.length} file(s) succeeded`)
243
+ return out
244
+ }