@dcrays/dcgchat 0.4.27 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,202 +0,0 @@
1
- import { channelInfo, ENV } from './utils/constant.js'
2
- /**
3
- * cron-delivery-guard — 定时任务投递守护插件
4
- *
5
- * 核心机制:通过 before_tool_call 钩子拦截 cron 工具调用,
6
- * 当 delivery.mode 为 "announce" 且未指定 channel 时,
7
- * 自动注入 bestEffort: true,使投递失败时静默降级,
8
- * 不影响 cron 执行结果的保存。
9
- *
10
- * 背景:
11
- * - 定时任务的 delivery 设为 announce 模式,如果没有指定 channel,
12
- * 投递可能因找不到有效渠道而失败
13
- * - bestEffort: true 让框架在投递失败时不报错,避免丢失执行结果
14
- */
15
-
16
- import { dcgLogger } from './utils/log.js'
17
-
18
- const LOG_TAG = 'cron-delivery-guard'
19
-
20
- // ---- 类型定义 ----
21
-
22
- interface ToolCallEvent {
23
- toolName: string
24
- toolCallId: string
25
- params: Record<string, unknown>
26
- result?: { content: string }
27
- }
28
-
29
- interface HookContext {
30
- agentId: string
31
- sessionKey: string
32
- }
33
-
34
- interface BeforeToolCallResult {
35
- block?: boolean
36
- blockReason?: string
37
- params?: Record<string, unknown>
38
- }
39
-
40
- // ---- delivery 类型 ----
41
-
42
- interface CronDelivery {
43
- mode?: string
44
- channel?: string
45
- to?: string
46
- bestEffort?: boolean
47
- [key: string]: unknown
48
- }
49
- /**
50
- * 解析 OpenClaw mobook direct 会话 key。
51
- * 形如 `agent:main:mobook:direct:14:5466`(大小写不敏感,与路由 toLowerCase 一致)
52
- * - 倒数第二段:account / peer(delivery.accountId)
53
- * - 最后一段:会话 id(delivery.to)
54
- */
55
- export function formatterSessionKey(sessionKey: string): { agentId: string; sessionId: string } {
56
- const parts = sessionKey.split(':').filter((s) => s.length > 0)
57
- const norm = parts.map((s) => s.toLowerCase())
58
- if (parts.length >= 6 && norm[0] === 'agent' && norm[2] === 'mobook' && norm[3] === 'direct') {
59
- return {
60
- agentId: parts[4] ?? '',
61
- sessionId: parts[5] ?? ''
62
- }
63
- }
64
- if (parts.length >= 2) {
65
- return {
66
- agentId: parts[parts.length - 2] ?? '',
67
- sessionId: parts[parts.length - 1] ?? ''
68
- }
69
- }
70
- return { agentId: '', sessionId: '' }
71
- }
72
-
73
- // ---- 辅助函数 ----
74
-
75
- /**
76
- * 判断是否为 cron 工具调用
77
- */
78
- function isCronTool(toolName: string): boolean {
79
- return toolName === 'cron'
80
- }
81
-
82
- /**
83
- * 从 cron 参数中提取 delivery 配置
84
- * cron 工具的参数结构可能是:
85
- * - params.delivery (顶层)
86
- * - params.job.delivery (嵌套在 job 中)
87
- */
88
- function extractDelivery(params: Record<string, unknown>): CronDelivery | null {
89
- // 尝试顶层 delivery
90
- if (params.delivery && typeof params.delivery === 'object') {
91
- return params.delivery as CronDelivery
92
- }
93
-
94
- // 尝试 job.delivery
95
- const job = params.job as Record<string, unknown> | undefined
96
- if (job?.delivery && typeof job.delivery === 'object') {
97
- return job.delivery as CronDelivery
98
- }
99
-
100
- // 尝试 payload 中的 deliver 相关字段 (兼容 qqbot-cron 风格)
101
- const payload = job?.payload as Record<string, unknown> | undefined
102
- if (payload?.deliver === true && payload.channel === undefined) {
103
- // payload 风格: { deliver: true, channel?: string }
104
- // 这种情况不是 delivery 对象,跳过
105
- return null
106
- }
107
-
108
- return null
109
- }
110
-
111
- /**
112
- * 判断 delivery 是否需要注入 bestEffort
113
- * 条件: mode 为 "announce" 且没有 channel
114
- */
115
- function needsBestEffort(delivery: CronDelivery): boolean {
116
- return delivery.mode === 'announce' && !delivery.channel && !delivery.bestEffort
117
- }
118
-
119
- /**
120
- * 深拷贝 params,在 delivery 上写入 dcg 路由(to / accountId / sessionKey)。
121
- * bestEffort + 默认 channel 仅在 needsBestEffort 为真时写入(announce 且无 channel)。
122
- *
123
- * 说明:原先仅在 needsBestEffort 为真时才改 delivery,若 jobs 里已有 channel 会整段跳过,
124
- * 导致 `delivery.to` 永远不会被本钩子写入;运行时若缺 to 就会一直 not-delivered。
125
- */
126
- function patchCronDeliveryInParams(
127
- params: Record<string, unknown>,
128
- sk: string,
129
- deliverySnapshot: CronDelivery
130
- ): Record<string, unknown> | null {
131
- const newParams = JSON.parse(JSON.stringify(params)) as Record<string, unknown>
132
- const { agentId } = formatterSessionKey(sk)
133
- const announceNoChannel = needsBestEffort(deliverySnapshot)
134
-
135
- const apply = (d: CronDelivery) => {
136
- d.to = `dcg-cron:${sk}`
137
- if (agentId) d.accountId = agentId
138
- if (announceNoChannel) {
139
- d.bestEffort = true
140
- d.channel = "dcgchat"
141
- }
142
- }
143
-
144
- if (newParams.delivery && typeof newParams.delivery === 'object') {
145
- apply(newParams.delivery as CronDelivery)
146
- newParams.sessionKey = sk
147
- return newParams
148
- }
149
-
150
- const job = newParams.job as Record<string, unknown> | undefined
151
- if (job?.delivery && typeof job.delivery === 'object') {
152
- apply(job.delivery as CronDelivery)
153
- newParams.sessionKey = sk
154
- return newParams
155
- }
156
-
157
- return null
158
- }
159
-
160
- export function cronToolCall(event: { toolName: any; params: any; toolCallId: any }, sk: string) {
161
- const { toolName, params, toolCallId } = event
162
-
163
- // 仅处理 cron 工具
164
- if (isCronTool(toolName)) {
165
- const delivery = extractDelivery(params)
166
- if (!delivery) {
167
- dcgLogger(`[${LOG_TAG}] cron call (${toolCallId}) has no delivery config, skip.`)
168
- return undefined
169
- }
170
- const newParams = patchCronDeliveryInParams(params, sk, delivery)
171
- if (!newParams) {
172
- dcgLogger(`[${LOG_TAG}] cron call (${toolCallId}) could not locate delivery on params clone, skip.`)
173
- return undefined
174
- }
175
-
176
- const patched = newParams.delivery ?? (newParams.job as Record<string, unknown>)?.delivery
177
- if (needsBestEffort(delivery)) {
178
- dcgLogger(
179
- `[${LOG_TAG}] cron call (${toolCallId}) patched delivery (announce, no channel): bestEffort=true, ` +
180
- `delivery=${JSON.stringify(patched)}`
181
- )
182
- } else {
183
- dcgLogger(
184
- `[${LOG_TAG}] cron call (${toolCallId}) patched delivery.to / accountId (sessionKey=${sk}), ` +
185
- `delivery=${JSON.stringify(patched)}`
186
- )
187
- }
188
-
189
- return { params: newParams }
190
- } else if (toolName === 'exec') {
191
- if (params.command.indexOf('cron create') > -1 || params.command.indexOf('cron add') > -1) {
192
- const newParams = JSON.parse(JSON.stringify(params)) as Record<string, unknown>
193
- newParams.command =
194
- params.command.replace('--json', '') + ` --session-key ${sk} --channel ${"dcgchat"} --to dcg-cron:${sk} --json`
195
- return { params: newParams }
196
- } else {
197
- return undefined
198
- }
199
- }
200
-
201
- return undefined
202
- }
@@ -1,447 +0,0 @@
1
- // Gateway connection handler - connects to local OpenClaw gateway
2
- import { WebSocket } from 'ws'
3
- import crypto from 'crypto'
4
- import { deriveDeviceIdFromPublicKey, publicKeyRawBase64UrlFromPem, buildDeviceAuthPayloadV3, signDevicePayload } from './security.js'
5
- import { dcgLogger } from '../utils/log.js'
6
- import { getWorkspaceDir } from '../utils/global.js'
7
- import { handleGatewayEventMessage } from '../utils/gatewayMsgHanlder.js'
8
-
9
- export interface GatewayEvent {
10
- type: string
11
- payload?: Record<string, unknown>
12
- seq?: number
13
- }
14
-
15
- export interface GatewayHelloOk {
16
- server: {
17
- connId: string
18
- }
19
- features: {
20
- methods: string[]
21
- events: string[]
22
- }
23
- policy?: {
24
- tickIntervalMs?: number
25
- }
26
- auth?: {
27
- deviceToken?: string
28
- role?: string
29
- scopes?: string[]
30
- }
31
- }
32
-
33
- export interface GatewayResponse {
34
- type: 'res'
35
- id: string
36
- ok: boolean
37
- /** 多数 RPC 成功时的返回值 */
38
- result?: unknown
39
- /** connect 及部分响应使用 payload(见 docs/gateway/protocol.md) */
40
- payload?: unknown
41
- error?: {
42
- code: string
43
- message: string
44
- details?: Record<string, unknown>
45
- }
46
- }
47
-
48
- export interface GatewayEventFrame {
49
- type: 'event'
50
- event: string
51
- payload?: Record<string, unknown>
52
- seq?: number
53
- }
54
-
55
- export interface GatewayHelloOkFrame {
56
- type: 'hello-ok'
57
- server: {
58
- connId: string
59
- }
60
- features: {
61
- methods: string[]
62
- events: string[]
63
- }
64
- policy?: {
65
- tickIntervalMs?: number
66
- }
67
- }
68
-
69
- // Union of all possible gateway messages
70
- export type GatewayMessage = GatewayEventFrame | GatewayResponse | GatewayHelloOkFrame
71
-
72
- export interface GatewayConfig {
73
- url: string
74
- token: string
75
- role: string
76
- scopes: string[]
77
- reconnectInterval?: number
78
- maxReconnectAttempts?: number
79
- }
80
-
81
- /**
82
- * Gateway connection handler
83
- */
84
- export class GatewayConnection {
85
- private ws: WebSocket | null = null
86
- private config: Required<GatewayConfig>
87
- private deviceId: string
88
- private privateKeyPem: string
89
- private publicKeyB64Url: string
90
- private connected: boolean = false
91
- private connId: string | null = null
92
- /** 服务端 connect.challenge 提供的 nonce,须与签名载荷一致 */
93
- private connectChallengeNonce: string | null = null
94
- private connectSent: boolean = false
95
- private pendingRpcById: Map<string, (response: GatewayResponse) => void> = new Map()
96
- private eventHandlers: Set<(event: GatewayEvent) => void> = new Set()
97
-
98
- constructor(config: GatewayConfig) {
99
- this.config = {
100
- url: config.url,
101
- token: config.token,
102
- role: config.role || 'operator',
103
- scopes: config.scopes || ['operator.admin'],
104
- reconnectInterval: config.reconnectInterval || 5000,
105
- maxReconnectAttempts: config.maxReconnectAttempts || 10
106
- }
107
-
108
- const identity = this.loadOrCreateDeviceIdentity()
109
- // 必须与公钥指纹一致(deriveDeviceIdFromPublicKey),不可用随机 UUID
110
- this.deviceId = identity.deviceId
111
- this.privateKeyPem = identity.privateKeyPem
112
- this.publicKeyB64Url = identity.publicKeyB64Url
113
- }
114
-
115
- private loadOrCreateDeviceIdentity() {
116
- const fs = require('fs')
117
- const path = require('path')
118
- const workspaceDir = getWorkspaceDir()
119
- const stateDir = path.join(workspaceDir, '.state')
120
- const deviceFile = path.join(stateDir, 'device.json')
121
-
122
- // Try to load existing identity
123
- if (fs.existsSync(deviceFile)) {
124
- const stored = JSON.parse(fs.readFileSync(deviceFile, 'utf8'))
125
- if (stored.deviceId && stored.publicKeyPem && stored.privateKeyPem) {
126
- const derivedId = deriveDeviceIdFromPublicKey(stored.publicKeyPem)
127
- const deviceId = derivedId !== stored.deviceId ? derivedId : stored.deviceId
128
- if (derivedId !== stored.deviceId) {
129
- try {
130
- fs.writeFileSync(deviceFile, JSON.stringify({ ...stored, deviceId: derivedId }, null, 2))
131
- } catch {
132
- /* keep in-memory fixed id only */
133
- }
134
- }
135
- return {
136
- deviceId,
137
- publicKeyPem: stored.publicKeyPem,
138
- privateKeyPem: stored.privateKeyPem,
139
- publicKeyB64Url: publicKeyRawBase64UrlFromPem(stored.publicKeyPem)
140
- }
141
- }
142
- }
143
-
144
- // Create new identity
145
- const keyPair = crypto.generateKeyPairSync('ed25519')
146
- const publicKeyPem = keyPair.publicKey.export({ type: 'spki', format: 'pem' }).toString()
147
- const privateKeyPem = keyPair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
148
- const deviceId = deriveDeviceIdFromPublicKey(publicKeyPem)
149
- const publicKeyB64Url = publicKeyRawBase64UrlFromPem(publicKeyPem)
150
-
151
- // Ensure directory exists
152
- if (!fs.existsSync(stateDir)) {
153
- fs.mkdirSync(stateDir, { recursive: true })
154
- }
155
-
156
- // Save identity
157
- fs.writeFileSync(
158
- deviceFile,
159
- JSON.stringify(
160
- {
161
- version: 1,
162
- deviceId,
163
- publicKeyPem,
164
- privateKeyPem,
165
- createdAtMs: Date.now()
166
- },
167
- null,
168
- 2
169
- )
170
- )
171
- fs.chmodSync(deviceFile, 0o600)
172
-
173
- return { deviceId, publicKeyPem, privateKeyPem, publicKeyB64Url }
174
- }
175
-
176
- /**
177
- * Connect to the gateway
178
- */
179
- async connect(): Promise<GatewayHelloOk> {
180
- return new Promise((resolve, reject) => {
181
- this.connectChallengeNonce = null
182
- this.connectSent = false
183
- this.ws = new WebSocket(this.config.url)
184
-
185
- let handshakeSettled = false
186
- const finishHandshake = (fn: () => void) => {
187
- if (handshakeSettled) return
188
- handshakeSettled = true
189
- clearTimeout(timeout)
190
- fn()
191
- }
192
-
193
- const timeout = setTimeout(() => {
194
- finishHandshake(() => reject(new Error('Gateway connection timeout')))
195
- }, 15000)
196
-
197
- this.ws.on('open', () => {
198
- dcgLogger('Gateway connection opened(等待 connect.challenge)')
199
- })
200
-
201
- this.ws.on('message', (data, ...args) => {
202
- try {
203
- const msg = JSON.parse(data.toString())
204
- this.handleMessage(
205
- msg,
206
- (hello) => finishHandshake(() => resolve(hello)),
207
- (err) => finishHandshake(() => reject(err)),
208
- timeout
209
- )
210
- } catch (err) {
211
- dcgLogger(`[Gateway] 解析消息失败: ${err}`, 'error')
212
- }
213
- })
214
-
215
- this.ws.on('close', () => {
216
- this.connected = false
217
- finishHandshake(() => reject(new Error('Gateway 在握手完成前关闭了连接')))
218
- })
219
-
220
- this.ws.on('error', (err) => {
221
- dcgLogger(`Gateway 连接错误: ${err}`, 'error')
222
- finishHandshake(() => reject(err))
223
- })
224
- })
225
- }
226
-
227
- /**
228
- * Send initial connect request
229
- */
230
- private sendConnect(): void {
231
- if (this.connectSent) return
232
- const nonce = this.connectChallengeNonce?.trim() ?? ''
233
- if (!nonce) return
234
-
235
- this.connectSent = true
236
- const signedAtMs = Date.now()
237
- const platform = process.platform
238
-
239
- const payload = buildDeviceAuthPayloadV3({
240
- deviceId: this.deviceId,
241
- clientId: 'gateway-client',
242
- clientMode: 'backend',
243
- role: this.config.role,
244
- scopes: this.config.scopes,
245
- signedAtMs,
246
- token: this.config.token,
247
- nonce,
248
- platform,
249
- deviceFamily: ''
250
- })
251
-
252
- const signature = signDevicePayload(this.privateKeyPem, payload)
253
-
254
- this.ws?.send(
255
- JSON.stringify({
256
- type: 'req',
257
- id: '1',
258
- method: 'connect',
259
- params: {
260
- minProtocol: 3,
261
- maxProtocol: 3,
262
- client: {
263
- id: 'gateway-client',
264
- version: '1.0.0',
265
- platform,
266
- mode: 'backend'
267
- },
268
- auth: { token: this.config.token },
269
- role: this.config.role,
270
- scopes: this.config.scopes,
271
- device: {
272
- id: this.deviceId,
273
- publicKey: this.publicKeyB64Url,
274
- signature,
275
- signedAt: signedAtMs,
276
- nonce
277
- }
278
- }
279
- })
280
- )
281
- }
282
-
283
- /**
284
- * Handle incoming messages
285
- */
286
- private mapHelloPayloadToHelloOk(payload: Record<string, unknown>): GatewayHelloOk {
287
- const serverRaw = payload.server as Record<string, unknown> | undefined
288
- const featuresRaw = payload.features as Record<string, unknown> | undefined
289
- return {
290
- server: { connId: typeof serverRaw?.connId === 'string' ? serverRaw.connId : '' },
291
- features: {
292
- methods: Array.isArray(featuresRaw?.methods) ? (featuresRaw.methods as string[]) : [],
293
- events: Array.isArray(featuresRaw?.events) ? (featuresRaw.events as string[]) : []
294
- },
295
- policy: payload.policy as GatewayHelloOk['policy'],
296
- auth: payload.auth as GatewayHelloOk['auth']
297
- }
298
- }
299
-
300
- private handleMessage(
301
- msg: Record<string, any>,
302
- resolveHello: (helloOk: GatewayHelloOk) => void,
303
- rejectHello: (err: Error) => void,
304
- timeout: NodeJS.Timeout
305
- ): void {
306
- const msgType = msg.type as string | undefined
307
-
308
- if (msg.type === 'event' && msg.event === 'connect.challenge') {
309
- const payload = msg.payload as Record<string, unknown> | undefined
310
- const nonce = typeof payload?.nonce === 'string' ? payload.nonce.trim() : ''
311
- if (!nonce) {
312
- rejectHello(new Error('connect.challenge 缺少 nonce'))
313
- return
314
- }
315
- this.connectChallengeNonce = nonce
316
- this.sendConnect()
317
- return
318
- }
319
-
320
- // 协议 v3:握手成功为 type:"res" + payload.type:"hello-ok"(见 openclaw docs/gateway/protocol.md)
321
- if (msg.type === 'res' && !this.connected) {
322
- const ok = msg.ok === true
323
- const inner = msg.payload as Record<string, unknown> | undefined
324
- if (ok && inner && inner.type === 'hello-ok') {
325
- this.connected = true
326
- const serverRaw = inner.server as Record<string, unknown> | undefined
327
- this.connId = typeof serverRaw?.connId === 'string' ? serverRaw.connId : null
328
- clearTimeout(timeout)
329
- dcgLogger(`[Gateway] 已连接 connId=${this.connId ?? '(none)'}`)
330
- resolveHello(this.mapHelloPayloadToHelloOk(inner))
331
- return
332
- }
333
- if (msg.ok === false) {
334
- const errObj = msg.error as Record<string, unknown> | undefined
335
- const message = (typeof errObj?.message === 'string' && errObj.message) || 'Gateway connect 被拒绝'
336
- clearTimeout(timeout)
337
- rejectHello(new Error(message))
338
- return
339
- }
340
- }
341
-
342
- // 旧式或其它实现:顶层 hello-ok
343
- if (msgType === 'hello-ok' || (!msgType && msg.server)) {
344
- this.connected = true
345
- this.connId = ((msg.server as Record<string, unknown>)?.connId as string) || null
346
- clearTimeout(timeout)
347
- dcgLogger(`[Gateway] 已连接 connId=${this.connId ?? '(none)'}`)
348
- resolveHello(msg as unknown as GatewayHelloOk)
349
- return
350
- }
351
- if (msg.type === 'res') {
352
- const handler = this.pendingRpcById.get(msg.id as string)
353
- if (handler) {
354
- this.pendingRpcById.delete(msg.id as string)
355
- handler(msg as unknown as GatewayResponse)
356
- }
357
- return
358
- }
359
-
360
- if (msg.type === 'event') {
361
- const event = handleGatewayEventMessage(msg)
362
- this.eventHandlers.forEach((h) => h(event))
363
- }
364
- }
365
-
366
- /**
367
- * Call a gateway method
368
- */
369
- async callMethod<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
370
- const id = crypto.randomUUID()
371
-
372
- return new Promise((resolve, reject) => {
373
- if (!this.connected || !this.ws) {
374
- reject(new Error('Not connected to gateway'))
375
- return
376
- }
377
-
378
- const timeout = setTimeout(() => {
379
- this.pendingRpcById.delete(id)
380
- reject(new Error('Method call timeout'))
381
- }, 30000)
382
-
383
- this.pendingRpcById.set(id, (response) => {
384
- clearTimeout(timeout)
385
- if (response.ok) {
386
- const body = response.result !== undefined ? response.result : (response as GatewayResponse).payload
387
- resolve(body as T)
388
- } else {
389
- reject(new Error(response.error?.message || 'Method call failed'))
390
- }
391
- })
392
-
393
- this.ws.send(
394
- JSON.stringify({
395
- type: 'req',
396
- id,
397
- method,
398
- params
399
- })
400
- )
401
- })
402
- }
403
-
404
- /**
405
- * Register event handler
406
- */
407
- onEvent(handler: (event: GatewayEvent) => void): () => void {
408
- this.eventHandlers.add(handler)
409
- return () => this.eventHandlers.delete(handler)
410
- }
411
-
412
- /**
413
- * Close connection
414
- */
415
- close(): void {
416
- this.ws?.close(1000, 'Plugin stopped')
417
- this.connected = false
418
- }
419
-
420
- /**
421
- * Check if connected
422
- */
423
- isConnected(): boolean {
424
- return this.connected
425
- }
426
-
427
- /**
428
- * Get connection ID
429
- */
430
- getConnId(): string | null {
431
- return this.connId
432
- }
433
-
434
- /**
435
- * Get the WebSocket instance (for external use)
436
- */
437
- getWebSocket(): WebSocket | null {
438
- return this.ws
439
- }
440
-
441
- /**
442
- * Ping the gateway
443
- */
444
- ping(): void {
445
- this.ws?.ping()
446
- }
447
- }