@dcrays/dcgchat-test 0.4.28 → 0.5.0-alpha.1
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.
- package/index.ts +8 -10
- package/openclaw.plugin.json +17 -1
- package/package.json +15 -2
- package/src/bot.ts +73 -64
- package/src/channel.ts +19 -15
- package/src/cron.ts +7 -2
- package/src/gateway/cronFinishedPayload.ts +118 -0
- package/src/gateway/index.ts +7 -2
- package/src/monitor.ts +5 -5
- package/src/request/oss.ts +92 -9
- package/src/request/userInfo.ts +1 -7
- package/src/skill.ts +4 -3
- package/src/tool.ts +10 -29
- package/src/tools/messageTool.ts +60 -11
- package/src/utils/agentErrors.ts +23 -0
- package/src/utils/gatewayMsgHanlder.ts +38 -9
- package/src/utils/global.ts +2 -1
- package/src/utils/workspaceFilePaths.ts +89 -0
package/index.ts
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { emptyPluginConfigSchema } from 'openclaw/plugin-sdk'
|
|
1
|
+
import { defineChannelPluginEntry } from 'openclaw/plugin-sdk/core'
|
|
3
2
|
import { dcgchatPlugin } from './src/channel.js'
|
|
4
3
|
import { setDcgchatRuntime, setWorkspaceDir } from './src/utils/global.js'
|
|
5
4
|
import { monitoringToolMessage } from './src/tool.js'
|
|
6
5
|
import { setOpenClawConfig } from './src/utils/global.js'
|
|
7
6
|
import { createDcgchatMessageTool } from './src/tools/messageTool.js'
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
export default defineChannelPluginEntry({
|
|
10
9
|
id: "dcgchat-test",
|
|
11
10
|
name: '书灵墨宝',
|
|
12
11
|
description: '连接 OpenClaw 与 书灵墨宝 产品(WebSocket)',
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
setDcgchatRuntime(
|
|
12
|
+
plugin: dcgchatPlugin,
|
|
13
|
+
setRuntime: (runtime) => {
|
|
14
|
+
setDcgchatRuntime(runtime)
|
|
15
|
+
},
|
|
16
|
+
registerFull: (api) => {
|
|
16
17
|
monitoringToolMessage(api)
|
|
17
18
|
setOpenClawConfig(api.config)
|
|
18
|
-
api.registerChannel({ plugin: dcgchatPlugin })
|
|
19
19
|
setWorkspaceDir(api.config?.agents?.defaults?.workspace)
|
|
20
20
|
api.registerTool((ctx) => {
|
|
21
21
|
return createDcgchatMessageTool(ctx)
|
|
22
22
|
})
|
|
23
23
|
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export default plugin
|
|
24
|
+
})
|
package/openclaw.plugin.json
CHANGED
|
@@ -3,9 +3,25 @@
|
|
|
3
3
|
"channels": [
|
|
4
4
|
"dcgchat-test"
|
|
5
5
|
],
|
|
6
|
+
"description": "Gateway `event=cron` + `action=finished` 时:优先在 payload 中按 schemas/gateway-cron-finished.payload.json 提供 `attachments`;若缺省,插件在工作区规则下从 summary 回退提取路径并经 sendMedia 发送。",
|
|
6
7
|
"configSchema": {
|
|
7
8
|
"type": "object",
|
|
8
9
|
"additionalProperties": false,
|
|
9
|
-
"properties": {
|
|
10
|
+
"properties": {
|
|
11
|
+
"allowedPaths": {
|
|
12
|
+
"type": "array",
|
|
13
|
+
"items": {
|
|
14
|
+
"type": "string"
|
|
15
|
+
},
|
|
16
|
+
"description": "允许发送文件的额外路径列表"
|
|
17
|
+
},
|
|
18
|
+
"allowedAttachmentExtensions": {
|
|
19
|
+
"type": "array",
|
|
20
|
+
"items": {
|
|
21
|
+
"type": "string"
|
|
22
|
+
},
|
|
23
|
+
"description": "在插件默认附件扩展名之外额外允许的后缀,如 [\".go\", \".rs\"]。默认已含常见办公/媒体及 .py/.ipynb 等;可省略本项。"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
10
26
|
}
|
|
11
27
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dcrays/dcgchat-test",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-alpha.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw channel plugin for 书灵墨宝 (WebSocket)",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"files": [
|
|
9
9
|
"index.ts",
|
|
10
10
|
"src",
|
|
11
|
+
"schemas",
|
|
11
12
|
"openclaw.plugin.json"
|
|
12
13
|
],
|
|
13
14
|
"keywords": [
|
|
@@ -16,6 +17,14 @@
|
|
|
16
17
|
"websocket",
|
|
17
18
|
"ai"
|
|
18
19
|
],
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"openclaw": ">=2026.4.11"
|
|
22
|
+
},
|
|
23
|
+
"peerDependenciesMeta": {
|
|
24
|
+
"openclaw": {
|
|
25
|
+
"optional": true
|
|
26
|
+
}
|
|
27
|
+
},
|
|
19
28
|
"dependencies": {
|
|
20
29
|
"ali-oss": "file:src/libs/ali-oss-6.23.0.tgz",
|
|
21
30
|
"axios": "file:src/libs/axios-1.13.6.tgz",
|
|
@@ -39,7 +48,11 @@
|
|
|
39
48
|
"install": {
|
|
40
49
|
"npmSpec": "@dcrays/dcgchat-test",
|
|
41
50
|
"localPath": "extensions/dcgchat-test",
|
|
42
|
-
"defaultChoice": "npm"
|
|
51
|
+
"defaultChoice": "npm",
|
|
52
|
+
"minHostVersion": ">=2026.4.11"
|
|
53
|
+
},
|
|
54
|
+
"compat": {
|
|
55
|
+
"pluginApi": ">=2026.4.11"
|
|
43
56
|
}
|
|
44
57
|
}
|
|
45
58
|
}
|
package/src/bot.ts
CHANGED
|
@@ -1,34 +1,20 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto'
|
|
2
1
|
import path from 'node:path'
|
|
3
2
|
import type { ReplyPayload } from 'openclaw/plugin-sdk'
|
|
4
|
-
import { createReplyPrefixContext, createTypingCallbacks } from 'openclaw/plugin-sdk'
|
|
3
|
+
import { createReplyPrefixContext, createTypingCallbacks } from 'openclaw/plugin-sdk/channel-reply-pipeline'
|
|
5
4
|
import type { InboundMessage } from './types.js'
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
getDcgchatRuntime,
|
|
9
|
-
getOpenClawConfig,
|
|
10
|
-
getSessionKey,
|
|
11
|
-
getWorkspaceDir,
|
|
12
|
-
setMsgStatus
|
|
13
|
-
} from './utils/global.js'
|
|
5
|
+
import { getSessionKey, getWorkspaceDir, setMsgStatus } from './utils/global.js'
|
|
6
|
+
import { clearSentMediaKeys, getDcgchatRuntime, getOpenClawConfig } from './utils/global.js'
|
|
14
7
|
import { normalizeOutboundMediaPaths, resolveAccount, sendDcgchatMedia } from './channel.js'
|
|
15
8
|
import { generateSignUrl } from './request/api.js'
|
|
16
9
|
import { sendChunk, sendFinal, sendText as sendTextMsg, sendError, wsSendRaw, sendText } from './transport.js'
|
|
17
10
|
import { dcgLogger } from './utils/log.js'
|
|
11
|
+
import { contextOverflowUserHint, isContextOverflowError } from './utils/agentErrors.js'
|
|
18
12
|
import { channelInfo, systemCommand, stopCommand, ENV, ignoreToolCommand } from './utils/constant.js'
|
|
19
13
|
import { clearParamsMessage, getEffectiveMsgParams, setParamsMessage } from './utils/params.js'
|
|
20
14
|
import { waitUntilSubagentsIdle } from './tool.js'
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
clearSessionStreamSuppression,
|
|
25
|
-
interruptLocalDispatchAndGateway,
|
|
26
|
-
isSessionStreamSuppressed,
|
|
27
|
-
preemptInboundQueueForStop,
|
|
28
|
-
releaseDispatchAbortIfCurrent,
|
|
29
|
-
runInboundTurnSequenced,
|
|
30
|
-
setActiveRunIdForSession
|
|
31
|
-
} from './sessionTermination.js'
|
|
15
|
+
import { beginSupersedingUserTurn, clearActiveRunIdForSession, clearSessionStreamSuppression } from './sessionTermination.js'
|
|
16
|
+
import { interruptLocalDispatchAndGateway, isSessionStreamSuppressed, preemptInboundQueueForStop } from './sessionTermination.js'
|
|
17
|
+
import { releaseDispatchAbortIfCurrent, runInboundTurnSequenced, setActiveRunIdForSession } from './sessionTermination.js'
|
|
32
18
|
|
|
33
19
|
type MediaInfo = {
|
|
34
20
|
path: string
|
|
@@ -75,7 +61,6 @@ async function resolveMediaFromUrls(files: TFileInfo[], botToken: string): Promi
|
|
|
75
61
|
const core = getDcgchatRuntime()
|
|
76
62
|
const out: MediaInfo[] = []
|
|
77
63
|
dcgLogger(`media: user upload files: ${JSON.stringify(files)}`)
|
|
78
|
-
|
|
79
64
|
for (let i = 0; i < files.length; i++) {
|
|
80
65
|
const file = files[i]
|
|
81
66
|
try {
|
|
@@ -232,6 +217,8 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
|
|
|
232
217
|
}
|
|
233
218
|
|
|
234
219
|
try {
|
|
220
|
+
/** 为 true 表示 createReplyDispatcherWithTyping 的 onError 已执行(含 sendFinal),内部 catch 勿再收尾 */
|
|
221
|
+
let dispatchReplyErrorHandledByOnError = false
|
|
235
222
|
if (msg.content.skills_scope.length > 0 && !msg.content?.agent_clone_code && !ignoreToolCommand.includes(text?.trim())) {
|
|
236
223
|
const workspaceDir = getWorkspaceDir()
|
|
237
224
|
const skill = msg.content.skills_scope[0]
|
|
@@ -287,6 +274,27 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
|
|
|
287
274
|
/** 与 Feishu snapshot 模式一致:payload.text 为当前轮助手全文快照,据此算增量,避免工具前后快照变短或非单调时丢字 */
|
|
288
275
|
let lastStreamSnapshot = ''
|
|
289
276
|
|
|
277
|
+
/** Core 在 block/final(及可选 tool)路径走 `deliver`,流式 token 才走 `onPartialReply`;二者需共用快照,避免双发或漏发 */
|
|
278
|
+
const emitAssistantTextChunkFromSnapshot = (raw: string | undefined) => {
|
|
279
|
+
if (!raw) return
|
|
280
|
+
const t = raw
|
|
281
|
+
let delta = ''
|
|
282
|
+
if (t.startsWith(lastStreamSnapshot)) {
|
|
283
|
+
delta = t.slice(lastStreamSnapshot.length)
|
|
284
|
+
lastStreamSnapshot = t
|
|
285
|
+
} else if (lastStreamSnapshot.startsWith(t)) {
|
|
286
|
+
// 快照缩短(模型修订等):不重复下发
|
|
287
|
+
} else {
|
|
288
|
+
delta = t
|
|
289
|
+
lastStreamSnapshot = t
|
|
290
|
+
}
|
|
291
|
+
if (delta.trim()) {
|
|
292
|
+
const prev = streamChunkIdxBySessionKey.get(dcgSessionKey) ?? 0
|
|
293
|
+
streamChunkIdxBySessionKey.set(dcgSessionKey, prev + 1)
|
|
294
|
+
sendChunk(delta, outboundCtx, prev)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
290
298
|
if (msg.content.skills_scope.length > 0 && !msg.content?.agent_clone_code && !ignoreToolCommand.includes(text?.trim())) {
|
|
291
299
|
const workspaceDir = getWorkspaceDir()
|
|
292
300
|
const skill = msg.content.skills_scope[0]
|
|
@@ -316,14 +324,23 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
|
|
|
316
324
|
sentMediaKeys.add(key)
|
|
317
325
|
await sendDcgchatMedia({ sessionKey: dcgSessionKey, mediaUrl, text: '' })
|
|
318
326
|
}
|
|
319
|
-
|
|
327
|
+
if (payload?.text?.trim()) {
|
|
328
|
+
emitAssistantTextChunkFromSnapshot(payload.text)
|
|
329
|
+
}
|
|
330
|
+
dcgLogger(
|
|
331
|
+
`[deliver]: kind=${info.kind} len=${payload?.text?.length} sessionId=${outboundCtx.sessionId} ${formatText(payload?.text ?? '')}`
|
|
332
|
+
)
|
|
320
333
|
},
|
|
321
334
|
onError: (err: unknown, info: { kind: string }) => {
|
|
335
|
+
dispatchReplyErrorHandledByOnError = true
|
|
322
336
|
setMsgStatus(dcgSessionKey, 'finished')
|
|
337
|
+
const suppressed = isSessionStreamSuppressed(dcgSessionKey)
|
|
338
|
+
if (!suppressed && isContextOverflowError(err)) {
|
|
339
|
+
sendText(contextOverflowUserHint(), outboundCtx)
|
|
340
|
+
}
|
|
323
341
|
sendFinal(outboundCtx, 'error')
|
|
324
342
|
clearActiveRunIdForSession(dcgSessionKey)
|
|
325
343
|
streamChunkIdxBySessionKey.delete(dcgSessionKey)
|
|
326
|
-
const suppressed = isSessionStreamSuppressed(dcgSessionKey)
|
|
327
344
|
dcgLogger(`${info.kind} reply failed${suppressed ? ' (stream suppressed)' : ''}: ${String(err)}`, 'error')
|
|
328
345
|
},
|
|
329
346
|
onIdle: () => {
|
|
@@ -338,27 +355,28 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
|
|
|
338
355
|
dispatchAbort = await beginSupersedingUserTurn(dcgSessionKey)
|
|
339
356
|
}
|
|
340
357
|
|
|
341
|
-
if (systemCommand.includes(text?.trim())) {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
} else
|
|
358
|
+
// if (systemCommand.includes(text?.trim())) {
|
|
359
|
+
// dcgLogger(`dispatching ${text?.trim()}`)
|
|
360
|
+
// await core.channel.reply.withReplyDispatcher({
|
|
361
|
+
// dispatcher,
|
|
362
|
+
// onSettled: () => markDispatchIdle(),
|
|
363
|
+
// run: () =>
|
|
364
|
+
// core.channel.reply.dispatchReplyFromConfig({
|
|
365
|
+
// ctx: ctxPayload,
|
|
366
|
+
// cfg: config,
|
|
367
|
+
// dispatcher,
|
|
368
|
+
// replyOptions: {
|
|
369
|
+
// ...replyOptions,
|
|
370
|
+
// abortSignal: dispatchAbort!.signal,
|
|
371
|
+
// onModelSelected: prefixContext.onModelSelected,
|
|
372
|
+
// onAgentRunStart: (runId) => {
|
|
373
|
+
// setActiveRunIdForSession(dcgSessionKey, runId)
|
|
374
|
+
// }
|
|
375
|
+
// }
|
|
376
|
+
// })
|
|
377
|
+
// })
|
|
378
|
+
// } else
|
|
379
|
+
if (stopCommand.includes(text?.trim())) {
|
|
362
380
|
const ctxForAbort = priorOutboundCtx.messageId?.trim() || priorOutboundCtx.sessionId?.trim() ? priorOutboundCtx : outboundCtx
|
|
363
381
|
await interruptLocalDispatchAndGateway(dcgSessionKey, ctxForAbort)
|
|
364
382
|
streamChunkIdxBySessionKey.delete(dcgSessionKey)
|
|
@@ -402,26 +420,9 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
|
|
|
402
420
|
},
|
|
403
421
|
onPartialReply: async (payload: ReplyPayload) => {
|
|
404
422
|
if (isSessionStreamSuppressed(dcgSessionKey)) return
|
|
405
|
-
|
|
406
423
|
// --- Streaming text chunks ---
|
|
407
424
|
if (payload.text) {
|
|
408
|
-
|
|
409
|
-
let delta = ''
|
|
410
|
-
if (t.startsWith(lastStreamSnapshot)) {
|
|
411
|
-
delta = t.slice(lastStreamSnapshot.length)
|
|
412
|
-
lastStreamSnapshot = t
|
|
413
|
-
} else if (lastStreamSnapshot.startsWith(t)) {
|
|
414
|
-
// 快照缩短(模型修订等):不重复下发
|
|
415
|
-
} else {
|
|
416
|
-
// 与上一轮快照不衔接(常见于工具后快照从新的助手片段重新开始):整段下发
|
|
417
|
-
delta = t
|
|
418
|
-
lastStreamSnapshot = t
|
|
419
|
-
}
|
|
420
|
-
if (delta.trim()) {
|
|
421
|
-
const prev = streamChunkIdxBySessionKey.get(dcgSessionKey) ?? 0
|
|
422
|
-
streamChunkIdxBySessionKey.set(dcgSessionKey, prev + 1)
|
|
423
|
-
sendChunk(delta, outboundCtx, prev)
|
|
424
|
-
}
|
|
425
|
+
emitAssistantTextChunkFromSnapshot(payload.text)
|
|
425
426
|
} else {
|
|
426
427
|
dcgLogger(`onPartialReply no text (media/tool metadata): ${JSON.stringify(payload)}`)
|
|
427
428
|
}
|
|
@@ -442,6 +443,13 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
|
|
|
442
443
|
dcgLogger(`handleDcgchatMessage error: ${String(err)}`, 'error')
|
|
443
444
|
if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
|
|
444
445
|
setMsgStatus(dcgSessionKey, 'finished')
|
|
446
|
+
if (!dispatchReplyErrorHandledByOnError && isContextOverflowError(err) && !isSessionStreamSuppressed(dcgSessionKey)) {
|
|
447
|
+
sendText(contextOverflowUserHint(), outboundCtx)
|
|
448
|
+
sendFinal(outboundCtx, 'error')
|
|
449
|
+
clearActiveRunIdForSession(dcgSessionKey)
|
|
450
|
+
streamChunkIdxBySessionKey.delete(dcgSessionKey)
|
|
451
|
+
return
|
|
452
|
+
}
|
|
445
453
|
}
|
|
446
454
|
} finally {
|
|
447
455
|
releaseDispatchAbortIfCurrent(dcgSessionKey, dispatchAbort)
|
|
@@ -495,6 +503,7 @@ async function handleDcgchatMessageInboundTurn(msg: InboundMessage, accountId: s
|
|
|
495
503
|
if ((inboundGenerationBySessionKey.get(dcgSessionKey) ?? 0) === inboundGenAtStart) {
|
|
496
504
|
setMsgStatus(dcgSessionKey, 'finished')
|
|
497
505
|
}
|
|
498
|
-
|
|
506
|
+
const rawErr = err instanceof Error ? err.message : String(err)
|
|
507
|
+
sendError(isContextOverflowError(err) ? contextOverflowUserHint() : rawErr, outboundCtx)
|
|
499
508
|
}
|
|
500
509
|
}
|
package/src/channel.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
|
-
import type { ChannelPlugin, OpenClawConfig
|
|
3
|
-
import {
|
|
2
|
+
import type { ChannelPlugin, OpenClawConfig } from 'openclaw/plugin-sdk'
|
|
3
|
+
import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk/account-id'
|
|
4
4
|
import type { ResolvedDcgchatAccount, DcgchatConfig } from './types.js'
|
|
5
5
|
import { ossUpload } from './request/oss.js'
|
|
6
6
|
import {
|
|
@@ -192,18 +192,29 @@ export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<v
|
|
|
192
192
|
return
|
|
193
193
|
}
|
|
194
194
|
const mediaUrl = expanded[0]
|
|
195
|
+
|
|
196
|
+
const { sessionId, agentId } = getInfoBySessionKey(sessionKey)
|
|
197
|
+
if (!msgCtx.sessionId) {
|
|
198
|
+
msgCtx.sessionId = sessionId
|
|
199
|
+
}
|
|
200
|
+
if (!msgCtx.agentId) {
|
|
201
|
+
msgCtx.agentId = agentId
|
|
202
|
+
}
|
|
195
203
|
if (!mediaUrl || !msgCtx.sessionId) {
|
|
196
204
|
dcgLogger(`dcgchat: sendMedia skipped (duplicate in session): ${mediaUrl} sessionId=${msgCtx.sessionId} sessionKey=${sessionKey}`)
|
|
197
205
|
return
|
|
198
206
|
}
|
|
199
|
-
|
|
207
|
+
let mediaStat: fs.Stats
|
|
200
208
|
try {
|
|
201
|
-
|
|
202
|
-
dcgLogger(`dcgchat: sendMedia skipped (file not found): ${mediaUrl} sessionKey=${sessionKey}`, 'error')
|
|
203
|
-
return
|
|
204
|
-
}
|
|
209
|
+
mediaStat = fs.statSync(mediaUrl)
|
|
205
210
|
} catch (err) {
|
|
206
211
|
dcgLogger(`dcgchat: sendMedia skipped (cannot stat path): ${mediaUrl} ${String(err)} sessionKey=${sessionKey}`, 'error')
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
if (!mediaStat.isFile()) {
|
|
215
|
+
const kind = mediaStat.isDirectory() ? 'directory' : 'non-regular'
|
|
216
|
+
dcgLogger(`dcgchat: sendMedia skipped (not a regular file, ${kind}): ${mediaUrl} sessionKey=${sessionKey}`, 'error')
|
|
217
|
+
return
|
|
207
218
|
}
|
|
208
219
|
|
|
209
220
|
if (mediaUrl && msgCtx.sessionId) {
|
|
@@ -213,19 +224,11 @@ export async function sendDcgchatMedia(opts: DcgchatMediaSendOptions): Promise<v
|
|
|
213
224
|
}
|
|
214
225
|
addSentMediaKey(msgCtx.sessionId, mediaUrl)
|
|
215
226
|
}
|
|
216
|
-
|
|
217
|
-
const { sessionId, agentId } = getInfoBySessionKey(sessionKey)
|
|
218
|
-
if (!msgCtx.sessionId) {
|
|
219
|
-
msgCtx.sessionId = sessionId
|
|
220
|
-
}
|
|
221
227
|
const fileName = mediaUrl?.split(/[\\/]/).pop() || ''
|
|
222
228
|
const notMessageId = `${msgCtx?.messageId}`?.length === 13 || !msgCtx?.messageId
|
|
223
229
|
try {
|
|
224
230
|
const botToken = msgCtx.botToken ?? getOpenClawConfig()?.channels?.["dcgchat-test"]?.botToken ?? ''
|
|
225
231
|
const url = mediaUrl ? await ossUpload(mediaUrl, botToken, 1) : ''
|
|
226
|
-
if (!msgCtx.agentId) {
|
|
227
|
-
msgCtx.agentId = agentId
|
|
228
|
-
}
|
|
229
232
|
wsSendRaw(msgCtx, {
|
|
230
233
|
response: opts.text ?? '',
|
|
231
234
|
is_finish: notMessageId ? -1 : 0,
|
|
@@ -363,6 +366,7 @@ export const dcgchatPlugin: ChannelPlugin<ResolvedDcgchatAccount> = {
|
|
|
363
366
|
'书灵墨宝 / 内置 `message`:回复当前会话时 **不要传 `target`**,由 OpenClaw 使用工具上下文里的 `currentChannelId`(来自入站 `To`,即当前 SessionKey)。',
|
|
364
367
|
'必须指定会话时:`target` 须为上下文中出现的 **整段 SessionKey,逐字一致**(如 `agent:…:mobook:direct:…` 或以 `agent:` 开头的路由键);**禁止**使用 `userId`、`From`、纯数字会话号等代替。',
|
|
365
368
|
'生成文件后,**尽可能不要**把文件路径、地址直接告诉用户;把文件名告诉用户;须通过工具发文件,勿在正文里直接输出可访问路径。',
|
|
369
|
+
'用户待发送文件若**不在**兼容挂载 **`/workspace/`**、**`/mobook/`**(及 Windows 下 `workspace`、`mobook` 盘符路径),且也不在 **当前 Agent 工作区** 或通道 **`allowedPaths`** 所列目录内:**建议**用户「先复制进工作区再发」作为首选。',
|
|
366
370
|
'使用 `dcgchat_message` 时同样遵守上述 SessionKey 规则(该工具通常由插件注入当前会话,一般无需自造 target)。'
|
|
367
371
|
]
|
|
368
372
|
},
|
package/src/cron.ts
CHANGED
|
@@ -135,7 +135,12 @@ export const onRunCronJob = async (jobId: string, messageId: string) => {
|
|
|
135
135
|
)
|
|
136
136
|
sendMessageToGateway(JSON.stringify({ method: 'cron.run', params: { id: jobId, mode: 'force' } }))
|
|
137
137
|
}
|
|
138
|
-
|
|
138
|
+
/**
|
|
139
|
+
* 发送定时完成摘要(纯文本)。附件须由网关在 `payload.attachments` 中给出,
|
|
140
|
+
* 并由 `gatewayMsgHanlder` 在调用本函数前经 `sendDcgchatMedia` 发送。
|
|
141
|
+
* @param hasSchemaAttachments `payload.attachments` 非空时为 true,用于 `message_tags.hasFile`
|
|
142
|
+
*/
|
|
143
|
+
export const finishedDcgchatCron = async (jobId: string, summary: string, hasSchemaAttachments?: boolean) => {
|
|
139
144
|
const id = jobId?.trim()
|
|
140
145
|
if (!id) {
|
|
141
146
|
dcgLogger('finishedDcgchatCron: empty jobId', 'error')
|
|
@@ -160,7 +165,7 @@ export const finishedDcgchatCron = async (jobId: string, summary: string, hasFil
|
|
|
160
165
|
real_mobook: !sessionId ? 1 : ''
|
|
161
166
|
})
|
|
162
167
|
const message_tags = { source: 'cron' } as Record<string, string | boolean>
|
|
163
|
-
if (
|
|
168
|
+
if (hasSchemaAttachments) {
|
|
164
169
|
message_tags.hasFile = true
|
|
165
170
|
}
|
|
166
171
|
wsSendRaw(merged, { response: summary, message_tags, is_finish: -1 })
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 与 `schemas/gateway-cron-finished.payload.json` 对齐:`attachments` 为首选。
|
|
3
|
+
* 若宿主未下发 `attachments`(当前 Gateway 常见情况),则仅在 `summary` 中按工作区规则提取路径(见 `resolveCronFinishedLocalPaths`),仍经 `sendDcgchatMedia` 发送。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
expandTildePath,
|
|
8
|
+
extractWorkspaceFilePathsFromText,
|
|
9
|
+
isAllowedSendPath,
|
|
10
|
+
trimArtifactPathCandidate
|
|
11
|
+
} from '../utils/workspaceFilePaths.js'
|
|
12
|
+
|
|
13
|
+
export type CronFinishedAttachmentItem = string | { path?: string; file?: string }
|
|
14
|
+
|
|
15
|
+
/** 网关 `event: "cron"` 且 `action === "finished"` 时插件消费的 payload 子集 */
|
|
16
|
+
export type CronGatewayFinishedPayload = {
|
|
17
|
+
jobId?: string
|
|
18
|
+
action?: string
|
|
19
|
+
status?: string
|
|
20
|
+
summary?: string
|
|
21
|
+
delivered?: boolean
|
|
22
|
+
deliveryStatus?: string
|
|
23
|
+
sessionId?: string
|
|
24
|
+
sessionKey?: string
|
|
25
|
+
attachments?: CronFinishedAttachmentItem[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** JSON Schema 中 `attachments` 数组片段(便于在代码里引用、与仓库内 JSON 文件保持语义一致) */
|
|
29
|
+
export const CRON_FINISHED_ATTACHMENTS_ITEMS_SCHEMA = {
|
|
30
|
+
type: 'array',
|
|
31
|
+
description: '本轮定时任务产出的本地文件路径,按顺序经通道发送为附件',
|
|
32
|
+
items: {
|
|
33
|
+
oneOf: [
|
|
34
|
+
{ type: 'string', minLength: 1, description: '绝对路径' },
|
|
35
|
+
{
|
|
36
|
+
type: 'object',
|
|
37
|
+
additionalProperties: false,
|
|
38
|
+
properties: {
|
|
39
|
+
path: { type: 'string', minLength: 1 },
|
|
40
|
+
file: { type: 'string', minLength: 1 }
|
|
41
|
+
},
|
|
42
|
+
description: '优先 path,否则 file'
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
} as const
|
|
47
|
+
|
|
48
|
+
function pushAttachmentItem(item: unknown, out: string[]): void {
|
|
49
|
+
if (typeof item === 'string') {
|
|
50
|
+
const s = item.trim()
|
|
51
|
+
if (s) out.push(s)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
if (item && typeof item === 'object') {
|
|
55
|
+
const o = item as Record<string, unknown>
|
|
56
|
+
const p = typeof o.path === 'string' ? o.path.trim() : ''
|
|
57
|
+
const f = typeof o.file === 'string' ? o.file.trim() : ''
|
|
58
|
+
const s = p || f
|
|
59
|
+
if (s) out.push(s)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 从网关 payload 取出 `attachments` 中的路径字符串(去重保序)。
|
|
65
|
+
* 不解析 `summary`、不扫描 Markdown。
|
|
66
|
+
*/
|
|
67
|
+
export function normalizeCronFinishedAttachmentPaths(payload: unknown): string[] {
|
|
68
|
+
if (payload == null || typeof payload !== 'object') return []
|
|
69
|
+
const p = payload as CronGatewayFinishedPayload
|
|
70
|
+
const raw = p.attachments
|
|
71
|
+
if (!Array.isArray(raw)) return []
|
|
72
|
+
const acc: string[] = []
|
|
73
|
+
for (const item of raw) {
|
|
74
|
+
pushAttachmentItem(item, acc)
|
|
75
|
+
}
|
|
76
|
+
const seen = new Set<string>()
|
|
77
|
+
const deduped: string[] = []
|
|
78
|
+
for (const s of acc) {
|
|
79
|
+
if (seen.has(s)) continue
|
|
80
|
+
seen.add(s)
|
|
81
|
+
deduped.push(s)
|
|
82
|
+
}
|
|
83
|
+
return deduped
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function dedupePaths(paths: string[]): string[] {
|
|
87
|
+
const seen = new Set<string>()
|
|
88
|
+
const out: string[] = []
|
|
89
|
+
for (const s of paths) {
|
|
90
|
+
if (!s || seen.has(s)) continue
|
|
91
|
+
seen.add(s)
|
|
92
|
+
out.push(s)
|
|
93
|
+
}
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 解析本轮待经通道发送的本地路径:优先 `payload.attachments`;
|
|
99
|
+
* 若为空则用 `summary` 与 `extractWorkspaceFilePathsFromText` 一致的工作区/挂载规则提取(非任意全文扫描)。
|
|
100
|
+
*/
|
|
101
|
+
export function resolveCronFinishedLocalPaths(
|
|
102
|
+
payload: unknown,
|
|
103
|
+
summary: string | undefined,
|
|
104
|
+
workspaceDir: string,
|
|
105
|
+
allowedPaths?: string[]
|
|
106
|
+
): string[] {
|
|
107
|
+
const fromSchema = normalizeCronFinishedAttachmentPaths(payload)
|
|
108
|
+
.map((s) => expandTildePath(trimArtifactPathCandidate(s)))
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
const schemaOk = fromSchema.filter((p: string) => isAllowedSendPath(p, workspaceDir, allowedPaths))
|
|
111
|
+
if (schemaOk.length > 0) {
|
|
112
|
+
return dedupePaths(schemaOk)
|
|
113
|
+
}
|
|
114
|
+
const fromSummary = extractWorkspaceFilePathsFromText(summary, workspaceDir).filter((p: string) =>
|
|
115
|
+
isAllowedSendPath(p, workspaceDir, allowedPaths)
|
|
116
|
+
)
|
|
117
|
+
return dedupePaths(fromSummary)
|
|
118
|
+
}
|
package/src/gateway/index.ts
CHANGED
|
@@ -358,8 +358,13 @@ export class GatewayConnection {
|
|
|
358
358
|
}
|
|
359
359
|
|
|
360
360
|
if (msg.type === 'event') {
|
|
361
|
-
|
|
362
|
-
|
|
361
|
+
void handleGatewayEventMessage(msg)
|
|
362
|
+
.then((event) => {
|
|
363
|
+
this.eventHandlers.forEach((h) => h(event))
|
|
364
|
+
})
|
|
365
|
+
.catch((err) => {
|
|
366
|
+
dcgLogger(`[Gateway] event 处理异步失败: ${String(err)}`, 'error')
|
|
367
|
+
})
|
|
363
368
|
}
|
|
364
369
|
}
|
|
365
370
|
|
package/src/monitor.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { OpenClawConfig, RuntimeEnv } from 'openclaw/plugin-sdk'
|
|
2
2
|
import WebSocket from 'ws'
|
|
3
3
|
import { resolveAccount } from './channel.js'
|
|
4
4
|
import { setWsConnection, getOpenClawConfig } from './utils/global.js'
|
|
@@ -7,15 +7,12 @@ import { isWsOpen } from './transport.js'
|
|
|
7
7
|
import { handleParsedWsMessage } from './utils/wsMessageHandler.js'
|
|
8
8
|
|
|
9
9
|
export type MonitorDcgchatOpts = {
|
|
10
|
-
config?:
|
|
10
|
+
config?: OpenClawConfig
|
|
11
11
|
runtime?: RuntimeEnv
|
|
12
12
|
abortSignal?: AbortSignal
|
|
13
13
|
accountId?: string
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const RECONNECT_DELAY_MS = 3000
|
|
17
|
-
const HEARTBEAT_INTERVAL_MS = 30_000
|
|
18
|
-
|
|
19
16
|
function buildConnectUrl(account: Record<string, string>): string {
|
|
20
17
|
const { wsUrl, botToken, userId, domainId, appId } = account
|
|
21
18
|
const url = new URL(wsUrl)
|
|
@@ -27,6 +24,9 @@ function buildConnectUrl(account: Record<string, string>): string {
|
|
|
27
24
|
}
|
|
28
25
|
|
|
29
26
|
export async function monitorDcgchatProvider(opts: MonitorDcgchatOpts): Promise<void> {
|
|
27
|
+
// WebSocket `open` 可能在 connect() 同 tick 内同步触发,间隔须在此行之前完成初始化
|
|
28
|
+
const RECONNECT_DELAY_MS = 3000
|
|
29
|
+
const HEARTBEAT_INTERVAL_MS = 30_000
|
|
30
30
|
const { abortSignal, accountId } = opts
|
|
31
31
|
|
|
32
32
|
const config = getOpenClawConfig()
|
package/src/request/oss.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises'
|
|
2
|
+
import { extname } from 'node:path'
|
|
1
3
|
import { fileURLToPath } from 'node:url'
|
|
2
4
|
// @ts-ignore
|
|
3
5
|
import OSS from 'ali-oss'
|
|
4
6
|
import { getStsToken, getUserToken } from './api.js'
|
|
5
7
|
import { dcgLogger } from '../utils/log.js'
|
|
6
8
|
|
|
9
|
+
/** 仅对内存 Buffer 超过此大小使用分片(本地路径一律走 put,避免 multipart 对类型的限制) */
|
|
10
|
+
const MULTIPART_THRESHOLD_BYTES = 1024 * 1024
|
|
11
|
+
|
|
7
12
|
/** 分片大小:OSS 要求每片 ≥100 KB(最后一片可更小) */
|
|
8
13
|
const MULTIPART_PART_SIZE = 1024 * 1024
|
|
9
14
|
|
|
10
15
|
/** ali-oss 默认 timeout 为 60s,大文件单 PUT 或慢网易触发 ResponseTimeoutError */
|
|
11
|
-
const OSS_HTTP_TIMEOUT_MS =
|
|
16
|
+
const OSS_HTTP_TIMEOUT_MS = 15 * 60 * 1000
|
|
12
17
|
|
|
13
18
|
/** 归一化入参,避免 file://、包装对象、TypedArray 等导致 SDK 识别失败 */
|
|
14
19
|
function coerceOssFileInput(input: File | string | Buffer): File | string | Buffer {
|
|
@@ -38,11 +43,83 @@ function coerceOssFileInput(input: File | string | Buffer): File | string | Buff
|
|
|
38
43
|
return input
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
function
|
|
42
|
-
if (
|
|
43
|
-
|
|
46
|
+
async function getUploadByteLength(input: File | string | Buffer): Promise<number> {
|
|
47
|
+
if (Buffer.isBuffer(input)) return input.length
|
|
48
|
+
if (typeof input === 'string') {
|
|
49
|
+
const s = await stat(input)
|
|
50
|
+
return s.size
|
|
51
|
+
}
|
|
52
|
+
return input.size
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 常见可在浏览器内联预览的类型(避免一律 application/octet-stream 触发下载) */
|
|
56
|
+
const PREVIEW_EXT_MIME: Record<string, string> = {
|
|
57
|
+
'.jpg': 'image/jpeg',
|
|
58
|
+
'.jpeg': 'image/jpeg',
|
|
59
|
+
'.png': 'image/png',
|
|
60
|
+
'.gif': 'image/gif',
|
|
61
|
+
'.webp': 'image/webp',
|
|
62
|
+
'.bmp': 'image/bmp',
|
|
63
|
+
'.svg': 'image/svg+xml',
|
|
64
|
+
'.ico': 'image/x-icon',
|
|
65
|
+
'.avif': 'image/avif',
|
|
66
|
+
'.heic': 'image/heic',
|
|
67
|
+
'.heif': 'image/heif',
|
|
68
|
+
'.pdf': 'application/pdf',
|
|
69
|
+
'.mp4': 'video/mp4',
|
|
70
|
+
'.webm': 'video/webm',
|
|
71
|
+
'.mov': 'video/quicktime',
|
|
72
|
+
'.mp3': 'audio/mpeg',
|
|
73
|
+
'.wav': 'audio/wav',
|
|
74
|
+
'.ogg': 'audio/ogg',
|
|
75
|
+
'.opus': 'audio/opus',
|
|
76
|
+
'.m4a': 'audio/mp4',
|
|
77
|
+
'.aac': 'audio/aac',
|
|
78
|
+
'.flac': 'audio/flac',
|
|
79
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
80
|
+
'.log': 'text/plain; charset=utf-8',
|
|
81
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
82
|
+
'.html': 'text/html; charset=utf-8',
|
|
83
|
+
'.htm': 'text/html; charset=utf-8',
|
|
84
|
+
'.css': 'text/css; charset=utf-8',
|
|
85
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
86
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
87
|
+
'.json': 'application/json; charset=utf-8',
|
|
88
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
89
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
90
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
91
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
92
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function mimeFromPathOrName(pathOrName: string): string | undefined {
|
|
96
|
+
const base = pathOrName.split(/[/\\]/).pop() ?? pathOrName
|
|
97
|
+
const ext = extname(base).toLowerCase()
|
|
98
|
+
return ext ? PREVIEW_EXT_MIME[ext] : undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 解析上传 Content-Type,并配合 Content-Disposition: inline 便于直链预览 */
|
|
102
|
+
function resolveMime(input: File | string | Buffer, fileNameHint?: string): string {
|
|
103
|
+
if (typeof input === 'string') {
|
|
104
|
+
return mimeFromPathOrName(input) ?? 'application/octet-stream'
|
|
105
|
+
}
|
|
106
|
+
if (Buffer.isBuffer(input)) {
|
|
107
|
+
if (fileNameHint) {
|
|
108
|
+
const fromName = mimeFromPathOrName(fileNameHint)
|
|
109
|
+
if (fromName) return fromName
|
|
110
|
+
}
|
|
111
|
+
return 'application/octet-stream'
|
|
112
|
+
}
|
|
113
|
+
const declared = input.type?.trim()
|
|
114
|
+
if (declared && declared !== 'application/octet-stream') {
|
|
115
|
+
return declared
|
|
44
116
|
}
|
|
45
|
-
|
|
117
|
+
const name = typeof input.name === 'string' && input.name ? input.name : ''
|
|
118
|
+
if (name) {
|
|
119
|
+
const fromName = mimeFromPathOrName(name)
|
|
120
|
+
if (fromName) return fromName
|
|
121
|
+
}
|
|
122
|
+
return declared || 'application/octet-stream'
|
|
46
123
|
}
|
|
47
124
|
|
|
48
125
|
/**
|
|
@@ -82,7 +159,7 @@ export const ossUpload = async (
|
|
|
82
159
|
const file = coerceOssFileInput(rawFile)
|
|
83
160
|
const { content, fileName } = await toUploadContent(file)
|
|
84
161
|
const data = await getStsToken(fileName, botToken, isPrivate)
|
|
85
|
-
const mime = resolveMime(file)
|
|
162
|
+
const mime = resolveMime(file, fileName)
|
|
86
163
|
const onProgress = uploadOptions?.onProgress
|
|
87
164
|
|
|
88
165
|
const options: OSS.Options = {
|
|
@@ -114,15 +191,21 @@ export const ossUpload = async (
|
|
|
114
191
|
},
|
|
115
192
|
parallel: 4,
|
|
116
193
|
partSize: MULTIPART_PART_SIZE,
|
|
117
|
-
mime
|
|
194
|
+
mime,
|
|
195
|
+
/** 直链打开时优先内联展示,而非附件下载 */
|
|
196
|
+
headers: {
|
|
197
|
+
'Content-Disposition': 'inline'
|
|
198
|
+
}
|
|
118
199
|
}
|
|
119
200
|
objectResult = await client.multipartUpload(name, content, multipartUploadOptions)
|
|
120
201
|
|
|
121
202
|
if (objectResult?.res?.status !== 200) {
|
|
122
203
|
dcgLogger(`OSS 上传失败, ${objectResult?.res?.status}`)
|
|
123
204
|
}
|
|
124
|
-
|
|
125
|
-
|
|
205
|
+
const requestUrls = objectResult?.res?.requestUrls || []
|
|
206
|
+
const url = requestUrls[0] || ''
|
|
207
|
+
dcgLogger(`OSS 上传成功, ${isPrivate === 1 ? objectResult.name || url : url}`)
|
|
208
|
+
return isPrivate === 1 ? objectResult.name || url : url
|
|
126
209
|
} catch (error) {
|
|
127
210
|
dcgLogger(`OSS 上传失败: ${error}`, 'error')
|
|
128
211
|
}
|
package/src/request/userInfo.ts
CHANGED
|
@@ -24,9 +24,7 @@ const tokenCache = new Map<string, TokenCacheEntry>()
|
|
|
24
24
|
export function setUserTokenCache(botToken: string, userToken: string): void {
|
|
25
25
|
const expiresAt = Date.now() + TOKEN_CACHE_DURATION
|
|
26
26
|
tokenCache.set(botToken, { token: userToken, expiresAt })
|
|
27
|
-
dcgLogger(
|
|
28
|
-
`[token-cache] cached userToken for botToken=${botToken.slice(0, 10)}..., expires at ${new Date(expiresAt).toISOString()}`
|
|
29
|
-
)
|
|
27
|
+
dcgLogger(`[token-cache] cached userToken for botToken=${botToken.slice(0, 10)}..., expires at ${new Date(expiresAt).toISOString()}`)
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
/**
|
|
@@ -47,10 +45,6 @@ export function getUserTokenCache(botToken: string): string | null {
|
|
|
47
45
|
tokenCache.delete(botToken)
|
|
48
46
|
return null
|
|
49
47
|
}
|
|
50
|
-
|
|
51
|
-
dcgLogger(
|
|
52
|
-
`[token-cache] cache hit for botToken=${botToken.slice(0, 10)}..., valid until ${new Date(entry.expiresAt).toISOString()}`
|
|
53
|
-
)
|
|
54
48
|
return entry.token
|
|
55
49
|
}
|
|
56
50
|
|
package/src/skill.ts
CHANGED
|
@@ -55,7 +55,7 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
|
|
|
55
55
|
// 创建目标目录
|
|
56
56
|
fs.mkdirSync(skillDir, { recursive: true })
|
|
57
57
|
// 解压文件到目标目录,跳过顶层文件夹
|
|
58
|
-
await new Promise((resolve, reject) => {
|
|
58
|
+
const result = await new Promise((resolve, reject) => {
|
|
59
59
|
const tasks: Promise<void>[] = []
|
|
60
60
|
let rootDir: string | null = null
|
|
61
61
|
let hasError = false
|
|
@@ -64,7 +64,7 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
|
|
|
64
64
|
.pipe(unzipper.Parse())
|
|
65
65
|
.on('entry', (entry: any) => {
|
|
66
66
|
if (hasError) {
|
|
67
|
-
entry.autodrain()
|
|
67
|
+
entry.autodrain() // 消耗并丢弃当前 zip 条目的数据流
|
|
68
68
|
return
|
|
69
69
|
}
|
|
70
70
|
try {
|
|
@@ -113,6 +113,7 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
|
|
|
113
113
|
await Promise.all(tasks)
|
|
114
114
|
resolve(null)
|
|
115
115
|
} catch (err) {
|
|
116
|
+
hasError = true
|
|
116
117
|
reject(err)
|
|
117
118
|
}
|
|
118
119
|
})
|
|
@@ -121,7 +122,7 @@ export async function installSkill(params: ISkillParams, msgContent: Record<stri
|
|
|
121
122
|
reject(new Error(`解压流错误: ${err.message}`))
|
|
122
123
|
})
|
|
123
124
|
})
|
|
124
|
-
sendEvent({ ...msgContent, status: 'ok' })
|
|
125
|
+
sendEvent({ ...msgContent, status: result === null ? 'ok' : 'fail' })
|
|
125
126
|
sendMessageToGateway(JSON.stringify({ method: 'skills.status', params: {} }))
|
|
126
127
|
} catch (error) {
|
|
127
128
|
// 如果安装失败,清理目录
|
package/src/tool.ts
CHANGED
|
@@ -5,34 +5,11 @@ import { sendFinal, sendText, wsSendRaw } from './transport.js'
|
|
|
5
5
|
import { getEffectiveMsgParams, deleteSessionKeyBySubAgentRunId, setSessionKeyBySubAgentRunId } from './utils/params.js'
|
|
6
6
|
import { cronToolCall } from './cronToolCall.js'
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
| 'before_prompt_build'
|
|
11
|
-
| 'before_agent_start'
|
|
12
|
-
| 'llm_input'
|
|
13
|
-
| 'llm_output'
|
|
14
|
-
| 'agent_end'
|
|
15
|
-
| 'before_compaction'
|
|
16
|
-
| 'after_compaction'
|
|
17
|
-
| 'before_reset'
|
|
18
|
-
| 'message_received'
|
|
19
|
-
| 'message_sending'
|
|
20
|
-
| 'message_sent'
|
|
21
|
-
| 'before_tool_call'
|
|
22
|
-
| 'after_tool_call'
|
|
23
|
-
| 'tool_result_persist'
|
|
24
|
-
| 'before_message_write'
|
|
25
|
-
| 'session_start'
|
|
26
|
-
| 'session_end'
|
|
27
|
-
| 'subagent_spawning'
|
|
28
|
-
| 'subagent_delivery_target'
|
|
29
|
-
| 'subagent_spawned'
|
|
30
|
-
| 'subagent_ended'
|
|
31
|
-
| 'gateway_start'
|
|
32
|
-
| 'gateway_stop'
|
|
8
|
+
/** 与 `OpenClawPluginApi['on']` 对齐,随宿主 SDK 扩展钩子名时自动一致 */
|
|
9
|
+
type PluginHookName = Parameters<OpenClawPluginApi['on']>[0]
|
|
33
10
|
|
|
34
11
|
// message_received 没有 sessionKey 前置到bot中执行
|
|
35
|
-
const eventList = [
|
|
12
|
+
const eventList: ReadonlyArray<{ event: PluginHookName; message: string }> = [
|
|
36
13
|
// { event: 'message_received', message: '' },
|
|
37
14
|
// {event: 'before_model_resolve', message: ''},
|
|
38
15
|
// {event: 'before_prompt_build', message: '正在查阅背景资料,构建思考逻辑'},
|
|
@@ -357,7 +334,11 @@ function trackSubagentLifecycle(eventName: string, event: any, args: any): void
|
|
|
357
334
|
|
|
358
335
|
export function monitoringToolMessage(api: OpenClawPluginApi) {
|
|
359
336
|
for (const item of eventList) {
|
|
360
|
-
api.on(item.event
|
|
337
|
+
api.on(item.event, (event: any, args: any) => {
|
|
338
|
+
// ACP 等非 subagent 的 ended 事件不应驱动书灵子会话状态机(见 SDK PluginHookSubagentEndedEvent.targetKind)
|
|
339
|
+
if (item.event === 'subagent_ended' && event?.targetKind === 'acp') {
|
|
340
|
+
return
|
|
341
|
+
}
|
|
361
342
|
trackSubagentLifecycle(item.event, event, args)
|
|
362
343
|
const sk = resolveHookSessionKey(item.event, args ?? {})
|
|
363
344
|
if (sk) {
|
|
@@ -397,9 +378,9 @@ export function monitoringToolMessage(api: OpenClawPluginApi) {
|
|
|
397
378
|
const msgCtx = resolveOutboundParamsForSession(sk)
|
|
398
379
|
if (item.event === 'llm_output') {
|
|
399
380
|
if (event.lastAssistant?.errorMessage === '1003-额度不足请充值') {
|
|
400
|
-
const message = '
|
|
381
|
+
const message = '您的墨滴已消耗完,您可以通过充值墨滴来继续使用'
|
|
401
382
|
sendText(message, msgCtx, { message_tags: { insufficient_balance: 1 }, is_finish: -1 })
|
|
402
|
-
sendFinal(msgCtx, '
|
|
383
|
+
sendFinal(msgCtx, '墨滴不足')
|
|
403
384
|
return
|
|
404
385
|
}
|
|
405
386
|
}
|
package/src/tools/messageTool.ts
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs'
|
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import type { AnyAgentTool } from 'openclaw/plugin-sdk'
|
|
5
|
-
import { jsonResult } from 'openclaw/plugin-sdk'
|
|
5
|
+
import { jsonResult } from 'openclaw/plugin-sdk/channel-actions'
|
|
6
6
|
import { sendDcgchatMedia } from '../channel.js'
|
|
7
7
|
import { getOutboundMsgParams } from '../utils/params.js'
|
|
8
8
|
import { sendText } from '../transport.js'
|
|
@@ -11,6 +11,9 @@ import { sendText } from '../transport.js'
|
|
|
11
11
|
export type DcgchatMessageToolContext = {
|
|
12
12
|
sessionKey?: string
|
|
13
13
|
workspaceDir?: string
|
|
14
|
+
allowedPaths?: string[]
|
|
15
|
+
/** 通道配置 `allowedAttachmentExtensions`:在插件内置扩展名之外额外允许发送的附件后缀(如 `.py`、`.ipynb`),项可写 `.py` 或 `py`。 */
|
|
16
|
+
allowedAttachmentExtensions?: string[]
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
/** 统一为 POSIX 风格斜杠,便于跨平台判断(不改变语义,仅用于匹配)。 */
|
|
@@ -32,9 +35,19 @@ function isPathInsideDir(filepath: string, rootDir: string): boolean {
|
|
|
32
35
|
* - 当前 Agent 工作区根及其子路径(`workspaceDir`,如 ~/.openclaw/workspace-xxx/output/...);
|
|
33
36
|
* - 兼容旧挂载:Unix `/workspace`、`/mobook`;Windows 盘符下 `workspace`、`mobook`。
|
|
34
37
|
*/
|
|
35
|
-
function isSafePath(filepath: string, workspaceDir?: string): boolean {
|
|
38
|
+
function isSafePath(filepath: string, workspaceDir?: string, allowedPaths?: string[]): boolean {
|
|
39
|
+
// Check workspaceDir
|
|
36
40
|
const ws = workspaceDir?.trim()
|
|
37
41
|
if (ws && isPathInsideDir(filepath, ws)) return true
|
|
42
|
+
|
|
43
|
+
// Check allowedPaths from config
|
|
44
|
+
if (allowedPaths?.length) {
|
|
45
|
+
for (const allowed of allowedPaths) {
|
|
46
|
+
if (isPathInsideDir(filepath, allowed)) return true
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check legacy mounts
|
|
38
51
|
const p = toPosixPath(filepath)
|
|
39
52
|
if (p.startsWith('/workspace/') || p === '/workspace') return true
|
|
40
53
|
if (p.startsWith('/mobook/') || p === '/mobook') return true
|
|
@@ -48,10 +61,43 @@ function pathKey(filepath: string): string {
|
|
|
48
61
|
}
|
|
49
62
|
|
|
50
63
|
const fileType1 = ['.webp', '.gif', '.bmp', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.pdf', '.txt', '.rtf', '.odt', '.json']
|
|
51
|
-
const fileType2 = [
|
|
64
|
+
const fileType2 = [
|
|
65
|
+
'.xml',
|
|
66
|
+
'.csv',
|
|
67
|
+
'.yaml',
|
|
68
|
+
'.yml',
|
|
69
|
+
'.html',
|
|
70
|
+
'.htm',
|
|
71
|
+
'.md',
|
|
72
|
+
'.markdown',
|
|
73
|
+
'.css',
|
|
74
|
+
'.js',
|
|
75
|
+
'.ts',
|
|
76
|
+
'.py',
|
|
77
|
+
'.pyi',
|
|
78
|
+
'.ipynb',
|
|
79
|
+
'.png',
|
|
80
|
+
'.jpg',
|
|
81
|
+
'.jpeg'
|
|
82
|
+
]
|
|
52
83
|
const fileType3 = ['.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.exe', '.dmg', '.pkg', '.apk', '.ipa', '.log', '.dat', '.bin']
|
|
53
84
|
const fileType4 = ['.svg', '.ico', '.mp3', '.wav', '.ogg', '.aac', '.m4a', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.webm']
|
|
54
|
-
const
|
|
85
|
+
const DEFAULT_SAFE_EXTENSIONS = new Set([...fileType1, ...fileType2, ...fileType3, ...fileType4])
|
|
86
|
+
|
|
87
|
+
function normalizeAttachmentExt(raw: string): string | null {
|
|
88
|
+
const t = raw.trim().toLowerCase()
|
|
89
|
+
if (!t) return null
|
|
90
|
+
return t.startsWith('.') ? t : `.${t}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildSafeExtensions(extra?: string[]): Set<string> {
|
|
94
|
+
const set = new Set(DEFAULT_SAFE_EXTENSIONS)
|
|
95
|
+
for (const e of extra ?? []) {
|
|
96
|
+
const n = normalizeAttachmentExt(e)
|
|
97
|
+
if (n && n !== '.') set.add(n)
|
|
98
|
+
}
|
|
99
|
+
return set
|
|
100
|
+
}
|
|
55
101
|
|
|
56
102
|
const messageToolParameters = {
|
|
57
103
|
type: 'object',
|
|
@@ -82,7 +128,8 @@ const messageToolParameters = {
|
|
|
82
128
|
}
|
|
83
129
|
}
|
|
84
130
|
},
|
|
85
|
-
|
|
131
|
+
// 须至少提供正文或附件之一;用 anyOf(非 oneOf),否则同时带 content+media 时两个分支都满足会违反「恰好其一」而校验失败
|
|
132
|
+
anyOf: [{ required: ['content'] }, { required: ['media'] }]
|
|
86
133
|
}
|
|
87
134
|
|
|
88
135
|
/** 从正文提取可发送的文件路径(固定挂载 + 当前工作区前缀)。 */
|
|
@@ -113,13 +160,13 @@ function extractPaths(text: string | undefined, workspaceDir?: string): string[]
|
|
|
113
160
|
return [...new Set([...unix, ...win, ...underWs])]
|
|
114
161
|
}
|
|
115
162
|
|
|
116
|
-
function isSafeFile(filepath: string) {
|
|
163
|
+
function isSafeFile(filepath: string, extensions: Set<string>) {
|
|
117
164
|
if (!fs.existsSync(filepath)) return false
|
|
118
165
|
const stat = fs.statSync(filepath)
|
|
119
166
|
if (!stat.isFile()) return false
|
|
120
167
|
if (stat.size === 0) return false
|
|
121
168
|
const ext = path.extname(filepath).toLowerCase()
|
|
122
|
-
return
|
|
169
|
+
return extensions.has(ext)
|
|
123
170
|
}
|
|
124
171
|
|
|
125
172
|
/**
|
|
@@ -128,6 +175,7 @@ function isSafeFile(filepath: string) {
|
|
|
128
175
|
* 通过注册时的 `OpenClawPluginToolContext.sessionKey` 出站,不再使用非标准的 `execute(args, ctx)`。
|
|
129
176
|
*/
|
|
130
177
|
export function createDcgchatMessageTool(pluginCtx: DcgchatMessageToolContext): AnyAgentTool {
|
|
178
|
+
const safeExtensions = buildSafeExtensions(pluginCtx.allowedAttachmentExtensions)
|
|
131
179
|
return {
|
|
132
180
|
name: 'dcgchat_message',
|
|
133
181
|
label: 'dcgchat_message',
|
|
@@ -155,13 +203,14 @@ export function createDcgchatMessageTool(pluginCtx: DcgchatMessageToolContext):
|
|
|
155
203
|
const sentFiles = new Set<string>()
|
|
156
204
|
const sentKeys = new Set<string>()
|
|
157
205
|
const workspaceDir = pluginCtx.workspaceDir
|
|
206
|
+
const allowedPaths = pluginCtx.allowedPaths
|
|
158
207
|
|
|
159
208
|
if (args.media?.length) {
|
|
160
209
|
for (const media of args.media) {
|
|
161
210
|
const filepath = media.file
|
|
162
211
|
if (!filepath) continue
|
|
163
|
-
if (!isSafePath(filepath, workspaceDir)) continue
|
|
164
|
-
if (!isSafeFile(filepath)) continue
|
|
212
|
+
if (!isSafePath(filepath, workspaceDir, allowedPaths)) continue
|
|
213
|
+
if (!isSafeFile(filepath, safeExtensions)) continue
|
|
165
214
|
const key = pathKey(filepath)
|
|
166
215
|
if (sentKeys.has(key)) continue
|
|
167
216
|
|
|
@@ -173,8 +222,8 @@ export function createDcgchatMessageTool(pluginCtx: DcgchatMessageToolContext):
|
|
|
173
222
|
|
|
174
223
|
const fallbackPaths = extractPaths(args.content, workspaceDir)
|
|
175
224
|
for (const filepath of fallbackPaths) {
|
|
176
|
-
if (!isSafePath(filepath, workspaceDir)) continue
|
|
177
|
-
if (!isSafeFile(filepath)) continue
|
|
225
|
+
if (!isSafePath(filepath, workspaceDir, allowedPaths)) continue
|
|
226
|
+
if (!isSafeFile(filepath, safeExtensions)) continue
|
|
178
227
|
const key = pathKey(filepath)
|
|
179
228
|
if (sentKeys.has(key)) continue
|
|
180
229
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 识别 OpenClaw embedded agent 抛出的上下文/压缩相关错误(日志与异常文案可能略有差异)。
|
|
3
|
+
*/
|
|
4
|
+
function errorText(err: unknown): string {
|
|
5
|
+
if (err instanceof Error) return err.message
|
|
6
|
+
if (typeof err === 'string') return err
|
|
7
|
+
return String(err)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isContextOverflowError(err: unknown): boolean {
|
|
11
|
+
const t = errorText(err)
|
|
12
|
+
return (
|
|
13
|
+
/context overflow/i.test(t) ||
|
|
14
|
+
/prompt too large/i.test(t) ||
|
|
15
|
+
/auto-compaction failed/i.test(t) ||
|
|
16
|
+
/\(precheck\)/i.test(t)
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 用户可见说明(不含 transport 层前缀) */
|
|
21
|
+
export function contextOverflowUserHint(): string {
|
|
22
|
+
return '当前对话过长,已超过模型上下文限制;自动压缩未完全生效时会出现此情况。请尝试新开对话、缩短任务,或换用更大上下文的模型。'
|
|
23
|
+
}
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import type { GatewayEvent } from '../gateway/index.js'
|
|
2
|
-
import { finishedDcgchatCron, sendDcgchatCron } from '../cron.js'
|
|
2
|
+
import { finishedDcgchatCron, getCronJobsPath, readCronJob, sendDcgchatCron } from '../cron.js'
|
|
3
|
+
import { sendDcgchatMedia } from '../channel.js'
|
|
4
|
+
import { resolveCronFinishedLocalPaths } from '../gateway/cronFinishedPayload.js'
|
|
5
|
+
import { channelInfo, ENV } from './constant.js'
|
|
3
6
|
import { dcgLogger } from './log.js'
|
|
4
|
-
import {
|
|
5
|
-
import { sendChunk
|
|
6
|
-
import {
|
|
7
|
-
import { setMsgStatus } from './global.js'
|
|
7
|
+
import { getEffectiveMsgParams, getSessionKeyBySubAgentRunId } from './params.js'
|
|
8
|
+
import { sendChunk } from '../transport.js'
|
|
9
|
+
import { getCronMessageId, getOpenClawConfig, getWorkspaceDir, setCronMessageId } from './global.js'
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* 处理网关 event 帧的副作用(agent 流式输出、cron 同步),并构造供上层分发的 GatewayEvent。
|
|
11
13
|
*/
|
|
12
|
-
export function handleGatewayEventMessage(msg: {
|
|
14
|
+
export async function handleGatewayEventMessage(msg: {
|
|
15
|
+
event?: string
|
|
16
|
+
payload?: Record<string, unknown>
|
|
17
|
+
seq?: number
|
|
18
|
+
}): Promise<GatewayEvent> {
|
|
13
19
|
try {
|
|
14
20
|
// 子agent消息输出
|
|
15
21
|
if (msg.event === 'agent') {
|
|
@@ -36,12 +42,35 @@ export function handleGatewayEventMessage(msg: { event?: string; payload?: Recor
|
|
|
36
42
|
sendDcgchatCron(p?.jobId as string)
|
|
37
43
|
}
|
|
38
44
|
if (p?.action === 'finished' && p?.status === 'ok') {
|
|
39
|
-
|
|
40
|
-
let summary = p?.summary as string
|
|
45
|
+
let summary = typeof p?.summary === 'string' ? p.summary : ''
|
|
41
46
|
if (summary.indexOf('HEARTBEAT_OK') >= 0 && summary !== 'HEARTBEAT_OK') {
|
|
42
47
|
summary = summary.replace('HEARTBEAT_OK', '')
|
|
43
48
|
}
|
|
44
|
-
|
|
49
|
+
|
|
50
|
+
const jobIdStr = typeof p?.jobId === 'string' ? p.jobId.trim() : ''
|
|
51
|
+
const cfg = getOpenClawConfig()
|
|
52
|
+
const chCfg = cfg?.channels?.["dcgchat-test" as keyof NonNullable<typeof cfg.channels>] as { allowedPaths?: string[] } | undefined
|
|
53
|
+
const attachmentPaths = resolveCronFinishedLocalPaths(p, summary, getWorkspaceDir(), chCfg?.allowedPaths)
|
|
54
|
+
const jobPath = getCronJobsPath()
|
|
55
|
+
const job = jobIdStr ? readCronJob(jobPath, jobIdStr) : null
|
|
56
|
+
const sessionKey = job && typeof job.sessionKey === 'string' ? job.sessionKey.trim() : ''
|
|
57
|
+
|
|
58
|
+
if (sessionKey && attachmentPaths.length > 0) {
|
|
59
|
+
const messageId = getCronMessageId(sessionKey) || `${Date.now()}`
|
|
60
|
+
setCronMessageId(sessionKey, messageId)
|
|
61
|
+
for (const mediaPath of attachmentPaths) {
|
|
62
|
+
try {
|
|
63
|
+
await sendDcgchatMedia({ sessionKey, mediaUrl: mediaPath, messageId })
|
|
64
|
+
} catch (err) {
|
|
65
|
+
dcgLogger(`[Gateway] cron 附件经通道发送失败: ${mediaPath} ${String(err)}`, 'error')
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} else if (attachmentPaths.length > 0 && !sessionKey) {
|
|
69
|
+
dcgLogger(`[Gateway] cron finished 有可发送附件路径但 jobs.json 无 sessionKey: jobId=${jobIdStr}`, 'error')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const hasAttachments = attachmentPaths.length > 0
|
|
73
|
+
finishedDcgchatCron(jobIdStr, summary, hasAttachments)
|
|
45
74
|
}
|
|
46
75
|
}
|
|
47
76
|
} catch (error) {
|
package/src/utils/global.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type WebSocket from 'ws'
|
|
|
2
2
|
import fs from 'node:fs'
|
|
3
3
|
import os from 'node:os'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
-
import {
|
|
5
|
+
import type { OpenClawConfig, PluginRuntime } from 'openclaw/plugin-sdk'
|
|
6
|
+
import { createPluginRuntimeStore } from 'openclaw/plugin-sdk/runtime-store'
|
|
6
7
|
import { channelInfo, ENV } from './constant.js'
|
|
7
8
|
import { dcgLogger } from './log.js'
|
|
8
9
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** 统一为 POSIX 风格斜杠,便于跨平台判断。 */
|
|
5
|
+
export function toPosixPath(p: string): string {
|
|
6
|
+
return path.normalize(p.trim()).replace(/\\/g, '/')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** `filepath` 解析后在 `rootDir` 内或等于 `rootDir`(防 `..` 逃逸)。 */
|
|
10
|
+
export function isPathInsideDir(filepath: string, rootDir: string): boolean {
|
|
11
|
+
const root = path.resolve(rootDir)
|
|
12
|
+
const resolved = path.resolve(filepath)
|
|
13
|
+
const rel = path.relative(root, resolved)
|
|
14
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) return false
|
|
15
|
+
return true
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 与 dcgchat_message 一致:允许的路径为当前工作区根、`allowedPaths`、或兼容挂载 /workspace、/mobook。
|
|
20
|
+
*/
|
|
21
|
+
export function isAllowedSendPath(filepath: string, workspaceDir?: string, allowedPaths?: string[]): boolean {
|
|
22
|
+
const ws = workspaceDir?.trim()
|
|
23
|
+
if (ws && isPathInsideDir(filepath, ws)) return true
|
|
24
|
+
if (allowedPaths?.length) {
|
|
25
|
+
for (const allowed of allowedPaths) {
|
|
26
|
+
if (isPathInsideDir(filepath, allowed)) return true
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const p = toPosixPath(filepath)
|
|
30
|
+
if (p.startsWith('/workspace/') || p === '/workspace') return true
|
|
31
|
+
if (p.startsWith('/mobook/') || p === '/mobook') return true
|
|
32
|
+
return /^[A-Za-z]:\/(workspace|mobook)(\/|$)/.test(p)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 去掉 Markdown/标点误粘在路径末尾的字符(如 `**`、反引号、右括号)。 */
|
|
36
|
+
export function trimArtifactPathCandidate(raw: string): string {
|
|
37
|
+
return raw.replace(/[`'",,*_)\]]+$/u, '').trimEnd()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 将正文里的 `~/...` / `~\...` 展开为绝对路径(`path.resolve` 不会处理 `~`)。
|
|
42
|
+
*/
|
|
43
|
+
export function expandTildePath(input: string): string {
|
|
44
|
+
const s = input.trim()
|
|
45
|
+
if (!s) return s
|
|
46
|
+
if (s === '~') return os.homedir()
|
|
47
|
+
if (s.startsWith('~/')) {
|
|
48
|
+
return path.resolve(os.homedir(), s.slice(2))
|
|
49
|
+
}
|
|
50
|
+
if (s.startsWith('~\\')) {
|
|
51
|
+
return path.resolve(os.homedir(), s.slice(2))
|
|
52
|
+
}
|
|
53
|
+
return s
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 从正文中提取可作为附件发送的本地路径(与 messageTool 规则一致:工作区前缀、`/workspace`、`~` 等)。
|
|
58
|
+
*/
|
|
59
|
+
export function extractWorkspaceFilePathsFromText(text: string | undefined, workspaceDir?: string): string[] {
|
|
60
|
+
if (!text) return []
|
|
61
|
+
const unix = text.match(/\/workspace\/[^\s]+|\/mobook\/[^\s]+/g) ?? []
|
|
62
|
+
const win = text.match(/[A-Za-z]:[/\\](?:workspace|mobook)[/\\][^\s]+/g) ?? []
|
|
63
|
+
const tildePaths = text.match(/~[/\\][^\s`'")\]]+/g) ?? []
|
|
64
|
+
const underWs: string[] = []
|
|
65
|
+
const ws = workspaceDir?.trim()
|
|
66
|
+
if (ws) {
|
|
67
|
+
const variants = new Set<string>()
|
|
68
|
+
variants.add(ws)
|
|
69
|
+
variants.add(toPosixPath(ws))
|
|
70
|
+
if (path.sep === '\\') variants.add(ws.replace(/\//g, '\\'))
|
|
71
|
+
for (const prefix of variants) {
|
|
72
|
+
if (!prefix) continue
|
|
73
|
+
let from = 0
|
|
74
|
+
while (from < text.length) {
|
|
75
|
+
const i = text.indexOf(prefix, from)
|
|
76
|
+
if (i === -1) break
|
|
77
|
+
let end = i + prefix.length
|
|
78
|
+
while (end < text.length && !/\s/.test(text[end])) end++
|
|
79
|
+
underWs.push(text.slice(i, end))
|
|
80
|
+
from = i + 1
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const cleaned = [...unix, ...win, ...tildePaths, ...underWs]
|
|
85
|
+
.map((s) => trimArtifactPathCandidate(s))
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
const resolved = cleaned.map((s) => (s.startsWith('~') ? expandTildePath(s) : s))
|
|
88
|
+
return [...new Set(resolved)]
|
|
89
|
+
}
|