@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,228 @@
1
+ /**
2
+ * 会话终止 / 抢占 / 网关 abort 的集中实现,便于单独调整策略与观测。
3
+ * 与 inboundTurnState(generation / streamChunkIdx)、入站业务上下文分离,仅负责:本地 AbortController、流抑制标记、
4
+ * 入站串行队尾、activeRunId、以及 /stop control barrier 时的 chat.abort(子会话→主会话)。
5
+ */
6
+ import { sendGatewayRpc } from '../gateway/socket.js'
7
+ import { dcgLogger } from './log.js'
8
+ import { bumpInboundGeneration, bumpStopSeq, clearInboundGenerationForSession, getStopSeq } from './inboundTurnState.js'
9
+ import { getDescendantSessionKeysForRequester, resetSubagentStateForRequesterSession, cleanupSubagentStateForSession } from '../tool.js'
10
+ import { clearParamsMessage } from './params.js'
11
+ import { setMsgStatus } from './global.js'
12
+ import { clearOutboundTextState } from './outboundAssistantText.js'
13
+
14
+ // 缩短超时时间,避免长时间阻塞
15
+ const GATEWAY_ABORT_TIMEOUT_MS = 5_000
16
+
17
+ // --- 状态(仅本模块内修改,供 bot 通过下方 API 使用)---
18
+
19
+ /** 当前会话最近一次 agent run 的 runId(网关 chat.abort 主会话时携带) */
20
+ const activeRunIdBySessionKey = new Map<string, string>()
21
+
22
+ /** dispatchReplyFromConfig 使用的 AbortSignal,用于真正掐断工具与模型 */
23
+ const dispatchAbortBySessionKey = new Map<string, AbortController>()
24
+
25
+ /** 打断后抑制 deliver / onPartialReply 继续下发 */
26
+ const sessionStreamSuppressed = new Set<string>()
27
+
28
+ /**
29
+ * 同 sessionKey 入站串行队尾;与 Core session lane 对齐,避免并发 dispatch 过早返回。
30
+ */
31
+ const inboundTurnTailBySessionKey = new Map<string, Promise<void>>()
32
+
33
+ /** 当前 session 正在执行的 /stop 终止屏障;普通消息必须等它结束后再进入新 turn。 */
34
+ const stopBarrierBySessionKey = new Map<string, Promise<void>>()
35
+
36
+ // --- activeRunId(供 bot 在 onAgentRunStart / 错误收尾时同步)---
37
+
38
+ export function setActiveRunIdForSession(sessionKey: string, runId: string): void {
39
+ activeRunIdBySessionKey.set(sessionKey, runId)
40
+ }
41
+
42
+ export function clearActiveRunIdForSession(sessionKey: string): void {
43
+ activeRunIdBySessionKey.delete(sessionKey)
44
+ }
45
+
46
+ // --- 流抑制 ---
47
+
48
+ export function isSessionStreamSuppressed(sessionKey: string): boolean {
49
+ return sessionStreamSuppressed.has(sessionKey)
50
+ }
51
+
52
+ export function clearSessionStreamSuppression(sessionKey: string): void {
53
+ sessionStreamSuppressed.delete(sessionKey)
54
+ }
55
+
56
+ export function markSessionStreamSuppressed(sessionKey: string): void {
57
+ sessionStreamSuppressed.add(sessionKey)
58
+ }
59
+
60
+ // --- 入站串行队列 ---
61
+
62
+ /**
63
+ * /stop control path:掐断当前 in-process,等待网关 chat.abort(子会话→主会话)完成后再释放普通消息。
64
+ *
65
+ * 返回 started=false 表示已有同 session 的 stop barrier 在跑;调用方仍应给当前 /stop 消息立即 ack。
66
+ */
67
+ export function requestStopInterruption(sessionKey: string): { started: boolean; barrier: Promise<void> } {
68
+ const key = sessionKey.trim()
69
+ if (!key) return { started: false, barrier: Promise.resolve() }
70
+
71
+ const existing = stopBarrierBySessionKey.get(key)
72
+ if (existing) {
73
+ dcgLogger(`stop barrier already running sessionKey=${key}`)
74
+ return { started: false, barrier: existing }
75
+ }
76
+
77
+ let barrier!: Promise<void>
78
+ barrier = Promise.resolve().then(async () => {
79
+ try {
80
+ const c = dispatchAbortBySessionKey.get(key)
81
+ if (c) {
82
+ c.abort()
83
+ dispatchAbortBySessionKey.delete(key)
84
+ dcgLogger(`stop barrier: aborted local controller sessionKey=${key}`)
85
+ }
86
+
87
+ markSessionStreamSuppressed(key)
88
+ const inboundGenAfterBump = bumpInboundGeneration(key)
89
+ const stopSeqAfterBump = bumpStopSeq(key)
90
+ dcgLogger(`stop barrier: bump inboundGen=${inboundGenAfterBump}, stopSeq=${stopSeqAfterBump}, sessionKey=${key}`)
91
+
92
+ const oldTail = inboundTurnTailBySessionKey.get(key)
93
+ if (oldTail) {
94
+ await Promise.race([oldTail.catch(() => {}), new Promise<void>((r) => setTimeout(r, 100))])
95
+ dcgLogger(`stop barrier: old tail done/timeout sessionKey=${key}`)
96
+ }
97
+
98
+ try {
99
+ await abortGatewayRunsForSession(key)
100
+ } catch (e) {
101
+ dcgLogger(`stop barrier: gateway abort: ${String(e)}`, 'error')
102
+ }
103
+
104
+ inboundTurnTailBySessionKey.set(key, Promise.resolve())
105
+ dcgLogger(`stop barrier: released inbound queue sessionKey=${key}`)
106
+ } finally {
107
+ if (stopBarrierBySessionKey.get(key) === barrier) {
108
+ stopBarrierBySessionKey.delete(key)
109
+ }
110
+ }
111
+ })
112
+
113
+ stopBarrierBySessionKey.set(key, barrier)
114
+ return { started: true, barrier }
115
+ }
116
+
117
+ /** 普通入站消息进入队列前调用,保证 /stop 的 gateway abort 已完成或超时。 */
118
+ export async function waitForStopInterruption(sessionKey: string): Promise<void> {
119
+ const key = sessionKey.trim()
120
+ if (!key) return
121
+ for (;;) {
122
+ const barrier = stopBarrierBySessionKey.get(key)
123
+ if (!barrier) return
124
+ dcgLogger(`waiting stop barrier before inbound turn sessionKey=${key}`)
125
+ await barrier.catch((e) => dcgLogger(`stop barrier wait failed: ${String(e)}`, 'error'))
126
+ }
127
+ }
128
+
129
+ /** 将本轮入站处理挂到 sessionKey 队尾,保证同会话顺序执行 */
130
+ export async function runInboundTurnSequenced(sessionKey: string, run: () => Promise<void>): Promise<void> {
131
+ const prev = inboundTurnTailBySessionKey.get(sessionKey) ?? Promise.resolve()
132
+
133
+ const next = prev.catch(() => {}).then(() => run())
134
+
135
+ inboundTurnTailBySessionKey.set(
136
+ sessionKey,
137
+ next.catch((e) => dcgLogger(`session turn failed: ${String(e)}`, 'error'))
138
+ )
139
+ await next
140
+ }
141
+
142
+ // --- 网关 abort ---
143
+
144
+ /**
145
+ * 终止网关上仍可能活跃的 run(子会话自深到浅,再主会话)。
146
+ * 主会话始终 chat.abort;有 mainRunId 则按 run 掐断,无则仅用 sessionKey 扫尾。
147
+ */
148
+ export async function abortGatewayRunsForSession(sessionKey: string): Promise<void> {
149
+ const prefix = 'interrupt'
150
+ const descendantKeys = getDescendantSessionKeysForRequester(sessionKey)
151
+ const abortSubKeys = [...descendantKeys].reverse()
152
+ const mainRunId = activeRunIdBySessionKey.get(sessionKey)
153
+
154
+ if (abortSubKeys.length > 0) {
155
+ dcgLogger(`${prefix}: chat.abort ${abortSubKeys.length} subagent session(s) (nested incl.)`)
156
+ }
157
+
158
+ const abortAllSequentially = async () => {
159
+ // 按“先子后主”顺序终止,降低主会话先结束导致子会话残留事件的概率。
160
+ for (const subKey of abortSubKeys) {
161
+ try {
162
+ await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: subKey } })
163
+ } catch (e: unknown) {
164
+ dcgLogger(`${prefix}: chat.abort subagent ${subKey}: ${String(e)}`, 'error')
165
+ }
166
+ }
167
+ try {
168
+ await sendGatewayRpc({
169
+ method: 'chat.abort',
170
+ params: mainRunId ? { sessionKey, runId: mainRunId } : { sessionKey }
171
+ })
172
+ } catch (e: unknown) {
173
+ dcgLogger(`${prefix}: chat.abort main ${sessionKey}: ${String(e)}`, 'error')
174
+ }
175
+ }
176
+
177
+ try {
178
+ await Promise.race([
179
+ abortAllSequentially(),
180
+ new Promise<void>((_, reject) => setTimeout(() => reject(new Error('batch abort timeout')), GATEWAY_ABORT_TIMEOUT_MS))
181
+ ])
182
+ } catch (e) {
183
+ dcgLogger(`${prefix}: batch abort timeout: ${String(e)}`, 'error')
184
+ }
185
+
186
+ activeRunIdBySessionKey.delete(sessionKey)
187
+ resetSubagentStateForRequesterSession(sessionKey)
188
+ }
189
+
190
+ // --- 本地 dispatch AbortController ---
191
+
192
+ /**
193
+ * 新一轮非 /stop 用户消息:本地中止上一轮 AbortController(若有)、清除流抑制,并安装本轮 AbortController。
194
+ * 不在此处发往网关 chat.abort;每轮流式由入站串行保证先后,网关 run 收尾由上一轮正常完结或用户 /stop 处理。
195
+ * 调用方须在之前或之后自行 `resetStreamChunkIdxForSession(sessionKey, 0)`(见 inboundTurnState)。
196
+ */
197
+ export function beginDispatchAbortForInboundTurn(sessionKey: string): AbortController {
198
+ dispatchAbortBySessionKey.get(sessionKey)?.abort()
199
+ sessionStreamSuppressed.delete(sessionKey)
200
+ const ac = new AbortController()
201
+ dispatchAbortBySessionKey.set(sessionKey, ac)
202
+ return ac
203
+ }
204
+
205
+ /** try/finally 中:仅当仍是当前 controller 时从 map 移除 */
206
+ export function releaseDispatchAbortIfCurrent(sessionKey: string, controller: AbortController | undefined): void {
207
+ if (controller && dispatchAbortBySessionKey.get(sessionKey) === controller) {
208
+ dispatchAbortBySessionKey.delete(sessionKey)
209
+ }
210
+ }
211
+
212
+ /**
213
+ * 会话完全终止时的资源清理:移除本模块及 tool 模块中与该会话相关的所有状态,
214
+ * 防止 Map/Set 内存泄漏。
215
+ */
216
+ export function cleanupTerminatedSession(sessionKey: string): void {
217
+ if (!sessionKey) return
218
+ activeRunIdBySessionKey.delete(sessionKey)
219
+ dispatchAbortBySessionKey.delete(sessionKey)
220
+ sessionStreamSuppressed.delete(sessionKey)
221
+ inboundTurnTailBySessionKey.delete(sessionKey)
222
+ stopBarrierBySessionKey.delete(sessionKey)
223
+ clearInboundGenerationForSession(sessionKey)
224
+ clearParamsMessage(sessionKey)
225
+ setMsgStatus(sessionKey, '')
226
+ clearOutboundTextState(sessionKey)
227
+ cleanupSubagentStateForSession(sessionKey)
228
+ }
@@ -0,0 +1,150 @@
1
+ import type { IMsgParams } from '../types.js'
2
+ import { sendChunk } from '../transport.js'
3
+ import { getWsConnection } from './global.js'
4
+ import { isSessionStreamSuppressed } from './sessionTermination.js'
5
+ import { getInboundGeneration, takeStreamChunkIdxAndAdvance } from './inboundTurnState.js'
6
+
7
+ export const STREAM_MERGE_PROFILES = [
8
+ { maxAvgDeltaGapMs: 20, flushIntervalMs: 50, deltaCount: 20, charCount: 100 },
9
+ { maxAvgDeltaGapMs: 60, flushIntervalMs: 30, deltaCount: 15, charCount: 60 },
10
+ { maxAvgDeltaGapMs: Number.POSITIVE_INFINITY, flushIntervalMs: 20, deltaCount: 6, charCount: 30 }
11
+ ] as const
12
+
13
+ export const STREAM_MERGE_ENABLE_WINDOW_MS = 1000
14
+ export const STREAM_MERGE_ENABLE_DELTA_COUNT = 50
15
+ export const STREAM_MERGE_IDLE_RESET_MS = 300
16
+ export const STREAM_BACKPRESSURE_HIGH_WATERMARK = 512 * 1024
17
+ export const STREAM_BACKPRESSURE_RETRY_MS = 50
18
+
19
+ export interface StreamMergeController {
20
+ queueDelta(delta: string): boolean
21
+ flush(force?: boolean): boolean
22
+ dispose(): void
23
+ }
24
+
25
+ export interface CreateStreamMergeControllerOptions {
26
+ sessionKey: string
27
+ inboundGenAtStart: number
28
+ outboundCtx: IMsgParams
29
+ }
30
+
31
+ export function createStreamMergeController(
32
+ options: CreateStreamMergeControllerOptions
33
+ ): StreamMergeController {
34
+ const { sessionKey, inboundGenAtStart, outboundCtx } = options
35
+
36
+ let pendingText = ''
37
+ let pendingDeltaCount = 0
38
+ let timer: ReturnType<typeof setTimeout> | undefined
39
+ let mergeMode = false
40
+ let burstWindowStart = 0
41
+ let burstDeltaCount = 0
42
+ let lastDeltaAt = 0
43
+ let deltaGapAvgMs = 0
44
+
45
+ const clearTimer = () => {
46
+ if (timer) {
47
+ clearTimeout(timer)
48
+ timer = undefined
49
+ }
50
+ }
51
+
52
+ const isBackpressured = () => (getWsConnection()?.bufferedAmount ?? 0) > STREAM_BACKPRESSURE_HIGH_WATERMARK
53
+
54
+ const currentProfile = () =>
55
+ STREAM_MERGE_PROFILES.find((profile) => deltaGapAvgMs <= profile.maxAvgDeltaGapMs) ?? STREAM_MERGE_PROFILES[1]
56
+
57
+ const isStale = () =>
58
+ getInboundGeneration(sessionKey) !== inboundGenAtStart || isSessionStreamSuppressed(sessionKey)
59
+
60
+ const emit = (text: string) => sendChunk(text, outboundCtx, takeStreamChunkIdxAndAdvance(sessionKey))
61
+
62
+ const resetMerge = () => {
63
+ mergeMode = false
64
+ burstWindowStart = 0
65
+ burstDeltaCount = 0
66
+ deltaGapAvgMs = 0
67
+ }
68
+
69
+ const flush = (force = false): boolean => {
70
+ clearTimer()
71
+ if (!pendingText.trim()) {
72
+ pendingText = ''
73
+ pendingDeltaCount = 0
74
+ return false
75
+ }
76
+ if (isStale()) {
77
+ pendingText = ''
78
+ pendingDeltaCount = 0
79
+ return false
80
+ }
81
+ if (!force && isBackpressured()) {
82
+ scheduleFlush(STREAM_BACKPRESSURE_RETRY_MS)
83
+ return false
84
+ }
85
+ const textToSend = pendingText
86
+ pendingText = ''
87
+ pendingDeltaCount = 0
88
+ return emit(textToSend)
89
+ }
90
+
91
+ const scheduleFlush = (delayMs = currentProfile().flushIntervalMs) => {
92
+ if (timer) return
93
+ timer = setTimeout(() => flush(), delayMs)
94
+ }
95
+
96
+ const queueDelta = (delta: string): boolean => {
97
+ const now = Date.now()
98
+ const deltaGapMs = lastDeltaAt > 0 ? now - lastDeltaAt : 0
99
+
100
+ if (lastDeltaAt > 0 && deltaGapMs >= STREAM_MERGE_IDLE_RESET_MS) {
101
+ flush(true)
102
+ resetMerge()
103
+ }
104
+
105
+ if (deltaGapMs > 0) {
106
+ deltaGapAvgMs = deltaGapAvgMs === 0 ? deltaGapMs : deltaGapAvgMs * 0.7 + deltaGapMs * 0.3
107
+ }
108
+ lastDeltaAt = now
109
+
110
+ if (!mergeMode) {
111
+ if (burstWindowStart === 0 || now - burstWindowStart > STREAM_MERGE_ENABLE_WINDOW_MS) {
112
+ burstWindowStart = now
113
+ burstDeltaCount = 1
114
+ } else {
115
+ burstDeltaCount += 1
116
+ }
117
+
118
+ pendingText += delta
119
+ pendingDeltaCount += 1
120
+ flush()
121
+
122
+ if (burstDeltaCount >= STREAM_MERGE_ENABLE_DELTA_COUNT) {
123
+ mergeMode = true
124
+ }
125
+ return true
126
+ }
127
+
128
+ pendingText += delta
129
+ pendingDeltaCount += 1
130
+ const profile = currentProfile()
131
+ if (pendingText.length >= profile.charCount || pendingDeltaCount >= profile.deltaCount) {
132
+ flush()
133
+ return true
134
+ }
135
+ scheduleFlush()
136
+ return true
137
+ }
138
+
139
+ const dispose = () => {
140
+ clearTimer()
141
+ pendingText = ''
142
+ pendingDeltaCount = 0
143
+ }
144
+
145
+ return {
146
+ queueDelta,
147
+ flush,
148
+ dispose
149
+ }
150
+ }
@@ -0,0 +1,71 @@
1
+ const requesterSessionKeyByRunId = new Map<string, string>()
2
+ const childSessionKeyByRunId = new Map<string, string>()
3
+
4
+ export type SubagentRunMapSnapshot = {
5
+ requesterSessionKeyByRunId?: Array<[string, string]>
6
+ childSessionKeyByRunId?: Array<[string, string]>
7
+ }
8
+
9
+ export function setSubagentRunRequester(runId: string | undefined, requesterSessionKey: string | undefined): void {
10
+ const rid = runId?.trim()
11
+ const req = requesterSessionKey?.trim()
12
+ if (!rid || !req) return
13
+ requesterSessionKeyByRunId.set(rid, req)
14
+ }
15
+
16
+ export function setSubagentRunChild(runId: string | undefined, childSessionKey: string | undefined): void {
17
+ const rid = runId?.trim()
18
+ const child = childSessionKey?.trim()
19
+ if (!rid || !child) return
20
+ childSessionKeyByRunId.set(rid, child)
21
+ }
22
+
23
+ export function deleteSubagentRunMappings(runId: string | undefined): void {
24
+ const rid = runId?.trim()
25
+ if (!rid) return
26
+ requesterSessionKeyByRunId.delete(rid)
27
+ childSessionKeyByRunId.delete(rid)
28
+ }
29
+
30
+ export function clearSubagentRunMappings(): void {
31
+ requesterSessionKeyByRunId.clear()
32
+ childSessionKeyByRunId.clear()
33
+ }
34
+
35
+ export function resolveRequesterSessionKeyBySubagentRunId(runId: string | undefined): string | undefined {
36
+ const rid = runId?.trim()
37
+ if (!rid) return undefined
38
+ return requesterSessionKeyByRunId.get(rid)
39
+ }
40
+
41
+ export function resolveChildSessionKeyBySubagentRunId(runId: string | undefined): string | undefined {
42
+ const rid = runId?.trim()
43
+ if (!rid) return undefined
44
+ return childSessionKeyByRunId.get(rid)
45
+ }
46
+
47
+ export function cleanupSubagentRunMappingsForSessionKeys(sessionKeys: Set<string>): void {
48
+ for (const [rid, child] of childSessionKeyByRunId.entries()) {
49
+ if (sessionKeys.has(child)) deleteSubagentRunMappings(rid)
50
+ }
51
+ for (const [rid, requester] of requesterSessionKeyByRunId.entries()) {
52
+ if (sessionKeys.has(requester)) deleteSubagentRunMappings(rid)
53
+ }
54
+ }
55
+
56
+ export function snapshotSubagentRunMappings(): SubagentRunMapSnapshot {
57
+ return {
58
+ requesterSessionKeyByRunId: [...requesterSessionKeyByRunId.entries()],
59
+ childSessionKeyByRunId: [...childSessionKeyByRunId.entries()]
60
+ }
61
+ }
62
+
63
+ export function restoreSubagentRunMappings(snap: SubagentRunMapSnapshot | null | undefined): void {
64
+ clearSubagentRunMappings()
65
+ for (const [k, v] of snap?.requesterSessionKeyByRunId ?? []) {
66
+ if (typeof k === 'string' && typeof v === 'string' && k && v) requesterSessionKeyByRunId.set(k, v)
67
+ }
68
+ for (const [k, v] of snap?.childSessionKeyByRunId ?? []) {
69
+ if (typeof k === 'string' && typeof v === 'string' && k && v) childSessionKeyByRunId.set(k, v)
70
+ }
71
+ }
@@ -0,0 +1,18 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+
4
+ /**
5
+ * 将正文里的 `~/...` / `~\...` 展开为绝对路径(`path.resolve` 不会处理 `~`)。
6
+ */
7
+ export function expandTildePath(input: string): string {
8
+ const s = input.trim()
9
+ if (!s) return s
10
+ if (s === '~') return os.homedir()
11
+ if (s.startsWith('~/')) {
12
+ return path.resolve(os.homedir(), s.slice(2))
13
+ }
14
+ if (s.startsWith('~\\')) {
15
+ return path.resolve(os.homedir(), s.slice(2))
16
+ }
17
+ return s
18
+ }
@@ -0,0 +1,124 @@
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/message.js'
7
+ import { ignoreToolCommand } from './constant.js'
8
+ import { createAgent, onRemoveAgent, onUpdateAgent } from '../agent.js'
9
+ import { onRemoveSession } from '../session.js'
10
+
11
+ interface WsEventContent {
12
+ event_type?: string
13
+ operation_type?: string
14
+ skill_url?: string
15
+ skill_code?: string
16
+ skill_id?: string
17
+ bot_token?: string
18
+ websocket_trace_id?: string
19
+ job_id?: string
20
+ message_id?: string
21
+ agent_id?: string
22
+ session_id?: string
23
+ agent_clone_code?: string
24
+ url?: string
25
+ clone_code?: string
26
+ name?: string
27
+ description?: string
28
+ [key: string]: unknown
29
+ }
30
+
31
+ export type ParsedWsPayload = {
32
+ messageType?: string
33
+ content: WsEventContent | null
34
+ }
35
+
36
+ function logUnsupportedOpenclawBotEvent(
37
+ branch: 'skill' | 'agent',
38
+ eventType: string,
39
+ operationType: unknown,
40
+ rawPayload: string
41
+ ): void {
42
+ dcgLogger(
43
+ `openclaw_bot_event ${branch}: unsupported operation_type=${String(operationType)} (event_type=${eventType}), ${rawPayload}`,
44
+ 'error'
45
+ )
46
+ }
47
+
48
+ /**
49
+ * 处理 WebSocket 已解析 JSON 且 content 已二次 parse 后的业务消息(openclaw_bot_chat / openclaw_bot_event)。
50
+ */
51
+ export async function handleParsedWsMessage(parsed: ParsedWsPayload, rawPayload: string, accountId: string): Promise<void> {
52
+ if (parsed.messageType === 'openclaw_bot_chat') {
53
+ const msg = parsed as unknown as InboundMessage
54
+ // 与 monitor 原逻辑一致:工具类指令不进入 running,避免误触工具链监控
55
+ const effectiveSessionKey = getSessionKey(msg.content, accountId)
56
+ const isToolOnly = ignoreToolCommand.includes(msg.content.text?.trim() ?? '')
57
+ if (!isToolOnly) {
58
+ setMsgStatus(effectiveSessionKey, 'running')
59
+ } else {
60
+ setMsgStatus(effectiveSessionKey, 'finished')
61
+ }
62
+ try {
63
+ await handleDcgchatMessage(msg, accountId)
64
+ } catch (err) {
65
+ dcgLogger(`handleDcgchatMessage failed: ${String(err)}`, 'error')
66
+ if (!isToolOnly) {
67
+ setMsgStatus(effectiveSessionKey, '')
68
+ }
69
+ // 不再向上 throw:否则 async onMessage 未接住的 rejection 会触发 Unhandled promise rejection
70
+ }
71
+ } else if (parsed.messageType === 'openclaw_bot_event') {
72
+ if (!parsed.content) {
73
+ dcgLogger(`openclaw_bot_event: content is null, ${rawPayload}`, 'error')
74
+ return
75
+ }
76
+ const { event_type, operation_type } = parsed.content
77
+ if (event_type === 'skill') {
78
+ const { skill_url = '', skill_code = '', skill_id, bot_token, websocket_trace_id } = parsed.content
79
+ const content = { event_type, operation_type, skill_url, skill_code, skill_id, bot_token, websocket_trace_id }
80
+ if (operation_type === 'install' || operation_type === 'enable' || operation_type === 'update') {
81
+ installSkill({ path: skill_url, code: skill_code }, content)
82
+ } else if (operation_type === 'remove' || operation_type === 'disable') {
83
+ uninstallSkill({ code: skill_code }, content)
84
+ } else {
85
+ logUnsupportedOpenclawBotEvent('skill', event_type, operation_type, rawPayload)
86
+ }
87
+ } else if (event_type === 'agent') {
88
+ if (operation_type === 'create') {
89
+ await createAgent(parsed.content)
90
+ } else if (operation_type === 'remove') {
91
+ await onRemoveAgent(parsed.content)
92
+ } else if (operation_type === 'update') {
93
+ await onUpdateAgent(parsed.content)
94
+ } else {
95
+ logUnsupportedOpenclawBotEvent('agent', event_type, operation_type, rawPayload)
96
+ }
97
+ } else if (event_type === 'cron') {
98
+ const { job_id = '', message_id = '' } = parsed.content
99
+ if (operation_type === 'remove') {
100
+ await onRemoveCronJob(job_id)
101
+ } else if (operation_type === 'enable') {
102
+ await onEnabledCronJob(job_id)
103
+ } else if (operation_type === 'disable') {
104
+ await onDisabledCronJob(job_id)
105
+ } else if (operation_type === 'run') {
106
+ await onRunCronJob(job_id, message_id)
107
+ } else {
108
+ dcgLogger(`openclaw_bot_event cron: unsupported operation_type=${String(operation_type)}, ${rawPayload}`, 'error')
109
+ }
110
+ } else if (event_type === 'session') {
111
+ const { agent_id = '', session_id = '', agent_clone_code } = parsed.content
112
+ if (operation_type === 'remove') {
113
+ await onRemoveSession({ agent_id, session_id, agent_clone_code, account_id: accountId })
114
+ }
115
+ } else {
116
+ dcgLogger(
117
+ `openclaw_bot_event: unknown event_type=${String(event_type)}, operation_type=${String(operation_type)}, ${rawPayload}`,
118
+ 'error'
119
+ )
120
+ }
121
+ } else {
122
+ dcgLogger(`ignoring unknown messageType: ${parsed.messageType}`, 'error')
123
+ }
124
+ }
@@ -0,0 +1,97 @@
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
+ }