@dcrays/dcgchat-test 0.6.10 → 0.6.12
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/README.md +167 -0
- package/index.ts +35 -0
- package/openclaw.plugin.json +12 -41
- package/package.json +35 -8
- package/src/agent.ts +568 -0
- package/src/bot.ts +646 -0
- package/src/channel/index.ts +329 -0
- package/src/channel/outboundMedia.ts +144 -0
- package/src/channel/outboundTarget.ts +62 -0
- package/src/channel/uploadMediaUrl.ts +76 -0
- package/src/cron/message.ts +114 -0
- package/src/cron/params.ts +164 -0
- package/src/cron/toolGuard.ts +466 -0
- package/src/cron/types.ts +15 -0
- package/src/gateway/index.ts +430 -0
- package/src/gateway/security.ts +95 -0
- package/src/gateway/socket.ts +256 -0
- package/src/libs/ali-oss-6.23.0.tgz +0 -0
- package/src/libs/axios-1.13.6.tgz +0 -0
- package/src/libs/md5-2.3.0.tgz +0 -0
- package/src/libs/mime-types-3.0.2.tgz +0 -0
- package/src/libs/unzipper-0.12.3.tgz +0 -0
- package/src/libs/ws-8.19.0.tgz +0 -0
- package/src/monitor.ts +269 -0
- package/src/request/api.ts +80 -0
- package/src/request/oss.ts +200 -0
- package/src/request/request.ts +191 -0
- package/src/request/userInfo.ts +44 -0
- package/src/session.ts +28 -0
- package/src/skill.ts +114 -0
- package/src/tool.ts +603 -0
- package/src/tools/messageTool.ts +334 -0
- package/src/tools/toolCallGuard.ts +327 -0
- package/src/transport.ts +217 -0
- package/src/types.ts +139 -0
- package/src/utils/agentErrors.ts +116 -0
- package/src/utils/constant.ts +60 -0
- package/src/utils/env-config.ts +19 -0
- package/src/utils/formatLlmInputEvent.ts +48 -0
- package/src/utils/gatewayMsgHandler.ts +147 -0
- package/src/utils/global.ts +309 -0
- package/src/utils/inboundTurnState.ts +66 -0
- package/src/utils/log.ts +77 -0
- package/src/utils/mediaAttached.ts +244 -0
- package/src/utils/mediaEmitter.ts +54 -0
- package/src/utils/outboundAssistantText.ts +117 -0
- package/src/utils/params.ts +104 -0
- package/src/utils/passTxt.ts +4 -0
- package/src/utils/resolveRegisterConfig.ts +33 -0
- package/src/utils/searchFile.ts +228 -0
- package/src/utils/sessionState.ts +137 -0
- package/src/utils/sessionTermination.ts +228 -0
- package/src/utils/streamMerge.ts +150 -0
- package/src/utils/subagentRunMap.ts +71 -0
- package/src/utils/undiciFetchInterceptor.ts +346 -0
- package/src/utils/workspaceFilePaths.ts +18 -0
- package/src/utils/wsMessageHandler.ts +124 -0
- package/src/utils/zipExtract.ts +97 -0
- package/src/utils/zipPath.ts +24 -0
- package/index.js +0 -304
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { GatewayEvent } from '../gateway/index.js'
|
|
2
|
+
import { finishedDcgchatCron } from '../cron/message.js'
|
|
3
|
+
import { cronAddHandler } from '../cron/params.js'
|
|
4
|
+
import { sendGatewayRpc } from '../gateway/socket.js'
|
|
5
|
+
import { dcgLogger } from './log.js'
|
|
6
|
+
import { getEffectiveMsgParams } from './params.js'
|
|
7
|
+
import { sendChunk, sendEventMessage } from '../transport.js'
|
|
8
|
+
import { setCronSessionKeyMapsForJob } from '../channel/outboundTarget.js'
|
|
9
|
+
import { resolveRequesterSessionKeyBySubagentRunId } from './subagentRunMap.js'
|
|
10
|
+
import { isSessionActiveForTool } from '../tool.js'
|
|
11
|
+
import { isSessionStreamSuppressed } from './sessionTermination.js'
|
|
12
|
+
import { takeStreamChunkIdxAndAdvance } from './inboundTurnState.js'
|
|
13
|
+
import { agentErrorUserHint } from './agentErrors.js'
|
|
14
|
+
|
|
15
|
+
function collectCronSessionKeys(payload: Record<string, any>): unknown[] {
|
|
16
|
+
const job = payload.job ?? {}
|
|
17
|
+
const delivery = payload.delivery ?? {}
|
|
18
|
+
const jobDelivery = job.delivery ?? {}
|
|
19
|
+
return [
|
|
20
|
+
payload.sessionKey,
|
|
21
|
+
job.sessionKey,
|
|
22
|
+
delivery.to,
|
|
23
|
+
delivery.intended?.to,
|
|
24
|
+
delivery.resolved?.to,
|
|
25
|
+
jobDelivery.to,
|
|
26
|
+
jobDelivery.intended?.to,
|
|
27
|
+
jobDelivery.resolved?.to
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getCronDeliveryTo(job: Record<string, any>): string {
|
|
32
|
+
return typeof job.delivery?.to === 'string' ? job.delivery.to.replace('dcg-cron:', '').trim() : ''
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getCronSessionKey(job: Record<string, any>): string {
|
|
36
|
+
return typeof job.sessionKey === 'string' ? job.sessionKey.trim() : ''
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readableErrorText(value: unknown): string {
|
|
40
|
+
if (typeof value === 'string') return value.trim()
|
|
41
|
+
if (value instanceof Error) return value.message.trim()
|
|
42
|
+
if (value && typeof value === 'object' && 'message' in value && typeof value.message === 'string') {
|
|
43
|
+
return value.message.trim()
|
|
44
|
+
}
|
|
45
|
+
return ''
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function canSendCronInstallFromAddedEvent(payload: Record<string, any>): boolean {
|
|
49
|
+
const job = payload.job
|
|
50
|
+
if (!job || typeof job !== 'object' || Array.isArray(job)) return false
|
|
51
|
+
return Boolean(getCronSessionKey(job) && getCronDeliveryTo(job))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sendCronInstallFromAddedEvent(payload: Record<string, any>): void {
|
|
55
|
+
const job = payload.job as Record<string, any>
|
|
56
|
+
sendEventMessage({
|
|
57
|
+
event_type: 'cron',
|
|
58
|
+
operation_type: 'install',
|
|
59
|
+
data: {
|
|
60
|
+
...job,
|
|
61
|
+
id: job.id ?? payload.jobId
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 处理网关 event 帧的副作用(agent 流式输出、cron 同步),并构造供上层分发的 GatewayEvent。
|
|
68
|
+
*/
|
|
69
|
+
export async function handleGatewayEventMessage(msg: { event?: string; payload?: Record<string, unknown>; seq?: number }): Promise<GatewayEvent> {
|
|
70
|
+
try {
|
|
71
|
+
// 子agent消息输出
|
|
72
|
+
if (msg.event === 'agent') {
|
|
73
|
+
const pl = msg.payload as { runId: string; data?: { delta?: unknown } }
|
|
74
|
+
const sessionKey = resolveRequesterSessionKeyBySubagentRunId(pl.runId)
|
|
75
|
+
if (!sessionKey) return { type: msg.event as string, payload: msg.payload, seq: msg.seq as number | undefined }
|
|
76
|
+
if (!isSessionActiveForTool(sessionKey)) {
|
|
77
|
+
dcgLogger(`[Gateway] drop subagent delta: session not active, runId=${pl.runId}, sessionKey=${sessionKey}`)
|
|
78
|
+
return { type: msg.event as string, payload: msg.payload, seq: msg.seq as number | undefined }
|
|
79
|
+
}
|
|
80
|
+
if (isSessionStreamSuppressed(sessionKey)) {
|
|
81
|
+
dcgLogger(`[Gateway] drop subagent delta: stream suppressed, runId=${pl.runId}, sessionKey=${sessionKey}`)
|
|
82
|
+
return { type: msg.event as string, payload: msg.payload, seq: msg.seq as number | undefined }
|
|
83
|
+
}
|
|
84
|
+
if (typeof pl.data?.delta !== 'string' || !pl.data.delta) {
|
|
85
|
+
return { type: msg.event as string, payload: msg.payload, seq: msg.seq as number | undefined }
|
|
86
|
+
}
|
|
87
|
+
const outboundCtx = getEffectiveMsgParams(sessionKey)
|
|
88
|
+
if (outboundCtx.sessionId) {
|
|
89
|
+
const chunkIdx = takeStreamChunkIdxAndAdvance(sessionKey)
|
|
90
|
+
sendChunk(pl.data.delta, outboundCtx, chunkIdx)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// 定时任务
|
|
94
|
+
if (msg.event === 'cron') {
|
|
95
|
+
const p = msg.payload as Record<string, any>
|
|
96
|
+
if (p?.action === 'started') {
|
|
97
|
+
const registeredKeys = setCronSessionKeyMapsForJob(p?.jobId ?? p?.job?.id, collectCronSessionKeys(p))
|
|
98
|
+
dcgLogger(`[Gateway] 定时任务活跃会话: jobId=${String(p?.jobId ?? p?.job?.id ?? '')} keys=${registeredKeys.join(',')}`)
|
|
99
|
+
dcgLogger(`[Gateway] 定时任务执行开始: ${JSON.stringify(p)}`)
|
|
100
|
+
} else {
|
|
101
|
+
dcgLogger(`[Gateway] 收到定时任务事件: ${JSON.stringify(p)}`)
|
|
102
|
+
}
|
|
103
|
+
if (p?.action === 'added') {
|
|
104
|
+
if (canSendCronInstallFromAddedEvent(p)) {
|
|
105
|
+
sendCronInstallFromAddedEvent(p)
|
|
106
|
+
} else {
|
|
107
|
+
await cronAddHandler(p?.jobId as string)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (p?.action === 'updated') {
|
|
111
|
+
const data = (await sendGatewayRpc({ method: 'cron.get', params: { id: p?.jobId as string } })) as Record<string, any>
|
|
112
|
+
const params = {
|
|
113
|
+
event_type: 'cron',
|
|
114
|
+
operation_type: 'install',
|
|
115
|
+
data
|
|
116
|
+
}
|
|
117
|
+
sendEventMessage(params)
|
|
118
|
+
}
|
|
119
|
+
if (p?.action === 'removed') {
|
|
120
|
+
const params = {
|
|
121
|
+
event_type: 'cron',
|
|
122
|
+
operation_type: 'remove',
|
|
123
|
+
data: { ...p }
|
|
124
|
+
}
|
|
125
|
+
sendEventMessage(params)
|
|
126
|
+
}
|
|
127
|
+
if (p?.action === 'finished') {
|
|
128
|
+
const jobId = typeof p?.jobId === 'string' ? p.jobId.trim() : ''
|
|
129
|
+
const summary = typeof p?.summary === 'string' ? p.summary : ''
|
|
130
|
+
const failureDetail = summary.trim() || readableErrorText(p?.error) || readableErrorText(p?.diagnostics?.summary)
|
|
131
|
+
const userHint = agentErrorUserHint(failureDetail)
|
|
132
|
+
let text = ''
|
|
133
|
+
if (p?.status !== 'ok') {
|
|
134
|
+
text = '定时任务执行失败' + (userHint || failureDetail ? `\n ${userHint ?? failureDetail}` : '')
|
|
135
|
+
}
|
|
136
|
+
finishedDcgchatCron(jobId, text)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch (error) {
|
|
140
|
+
dcgLogger(`[Gateway] 处理事件失败: ${error}`, 'error')
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
type: msg.event as string,
|
|
144
|
+
payload: msg.payload,
|
|
145
|
+
seq: msg.seq as number | undefined
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
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()].filter(([k]) => runningSessionKeys.has(k)).map(([k, q]) => [k, [...q]])
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function restoreGlobalSessionState(snap: GlobalSessionStateSnapshot | null | undefined): void {
|
|
213
|
+
if (!snap) return
|
|
214
|
+
msgStatusBySessionKey.clear()
|
|
215
|
+
sentMediaKeysBySession.clear()
|
|
216
|
+
cronMessageIdMap.clear()
|
|
217
|
+
for (const [k, v] of snap.msgStatusBySessionKey ?? []) {
|
|
218
|
+
if (typeof k === 'string' && k && v === 'running') {
|
|
219
|
+
msgStatusBySessionKey.set(k, v)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const [k, arr] of snap.cronMessageIdMap ?? []) {
|
|
223
|
+
if (typeof k !== 'string' || !k || !Array.isArray(arr)) continue
|
|
224
|
+
const q = arr.filter((x): x is string => typeof x === 'string' && !!x)
|
|
225
|
+
if (q.length > 0) cronMessageIdMap.set(k, q)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 与 bot 侧一致:session_id 为 `agentId::对话标识` 时提取 agentId(专家会话可仅依赖此前缀)。 */
|
|
230
|
+
export function extractAgentIdFromConversationId(conversationId: string): string | null {
|
|
231
|
+
const idx = conversationId.indexOf('::')
|
|
232
|
+
if (idx <= 0) return null
|
|
233
|
+
return conversationId.slice(0, idx)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export const getSessionKey = (content: any, accountId: string) => {
|
|
237
|
+
const { real_mobook, agent_id, agent_clone_code, session_id } = content
|
|
238
|
+
const core = getDcgchatRuntime()
|
|
239
|
+
|
|
240
|
+
const sid = typeof session_id === 'string' ? session_id.trim() : ''
|
|
241
|
+
const embeddedAgent = sid ? extractAgentIdFromConversationId(sid) : null
|
|
242
|
+
const agentCode = (typeof agent_clone_code === 'string' && agent_clone_code.trim()) || embeddedAgent || 'main'
|
|
243
|
+
|
|
244
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
245
|
+
cfg: getOpenClawConfig() as OpenClawConfig,
|
|
246
|
+
channel: CHANNEL_ID,
|
|
247
|
+
accountId: accountId || 'default',
|
|
248
|
+
peer: { kind: 'direct', id: session_id }
|
|
249
|
+
})
|
|
250
|
+
return real_mobook == '1' ? route.sessionKey : `agent:${agentCode}:mobook:direct:${agent_id}:${session_id}`.toLowerCase()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
interface TakeApartSessionKeyResult {
|
|
254
|
+
sessionId: string
|
|
255
|
+
agentId: string
|
|
256
|
+
}
|
|
257
|
+
export function takeApartSessionKey(sk: string): TakeApartSessionKeyResult {
|
|
258
|
+
const sessionInfo = sk?.split(':')
|
|
259
|
+
const sessionId = sessionInfo?.at(-1) ?? ''
|
|
260
|
+
const agentId = sessionInfo?.at(-2) ?? ''
|
|
261
|
+
return { sessionId, agentId }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function compressFields(obj: Record<string, unknown>, fields: string[] = []) {
|
|
265
|
+
const start = 8
|
|
266
|
+
const end = 8
|
|
267
|
+
const newObj = { ...obj }
|
|
268
|
+
|
|
269
|
+
const compressText = (str: unknown) => {
|
|
270
|
+
if (typeof str !== 'string') return str
|
|
271
|
+
|
|
272
|
+
if (str.length <= start + end) {
|
|
273
|
+
return str
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const omittedCount = str.length - start - end
|
|
277
|
+
|
|
278
|
+
return str.slice(0, start) + `...省略${omittedCount}个字...` + str.slice(-end)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
fields.forEach((field) => {
|
|
282
|
+
if (field in newObj) {
|
|
283
|
+
newObj[field] = compressText(newObj[field])
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
return newObj
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function formatText(text: string): string {
|
|
291
|
+
if (!text) return ''
|
|
292
|
+
const str = String(text).replace(/\s/g, '')
|
|
293
|
+
if (!str) return ''
|
|
294
|
+
if (str.length <= 50) {
|
|
295
|
+
return str
|
|
296
|
+
}
|
|
297
|
+
return str.slice(0, 25) + `...[此处省略${str.length - 50}字]....` + str.slice(-25)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function isReviserExpert(sessionKey?: string): boolean {
|
|
301
|
+
if (!sessionKey) return false
|
|
302
|
+
const segment = sessionKey.split(':')[1]
|
|
303
|
+
return segment != null && segment.includes('reviser-expert')
|
|
304
|
+
}
|
|
305
|
+
export function isCollectionReviewer(sessionKey?: string): boolean {
|
|
306
|
+
if (!sessionKey) return false
|
|
307
|
+
const segment = sessionKey.split(':')[1]
|
|
308
|
+
return segment != null && segment.includes('collection-reviewer')
|
|
309
|
+
}
|
|
@@ -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
|
+
}
|
package/src/utils/log.ts
ADDED
|
@@ -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
|
+
}
|