@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.
- package/index.js +292 -0
- package/openclaw.plugin.json +17 -1
- package/package.json +18 -13
- package/schemas/gateway-cron-finished.payload.json +39 -0
- package/index.ts +0 -26
- package/src/agent.ts +0 -128
- package/src/bot.ts +0 -500
- package/src/channel.ts +0 -470
- package/src/cron.ts +0 -194
- package/src/cronToolCall.ts +0 -202
- package/src/gateway/index.ts +0 -447
- package/src/gateway/security.ts +0 -95
- package/src/gateway/socket.ts +0 -285
- 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 +0 -165
- package/src/request/api.ts +0 -70
- package/src/request/oss.ts +0 -61
- package/src/request/request.ts +0 -192
- package/src/request/userInfo.ts +0 -99
- package/src/session.ts +0 -19
- package/src/sessionTermination.ts +0 -154
- package/src/skill.ts +0 -151
- package/src/tool.ts +0 -422
- package/src/tools/messageTool.ts +0 -224
- package/src/transport.ts +0 -203
- package/src/types.ts +0 -139
- package/src/utils/constant.ts +0 -7
- package/src/utils/gatewayMsgHanlder.ts +0 -55
- package/src/utils/global.ts +0 -160
- package/src/utils/log.ts +0 -15
- package/src/utils/params.ts +0 -88
- package/src/utils/searchFile.ts +0 -228
- package/src/utils/wsMessageHandler.ts +0 -64
- package/src/utils/zipExtract.ts +0 -97
- package/src/utils/zipPath.ts +0 -24
package/src/cronToolCall.ts
DELETED
|
@@ -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
|
-
}
|
package/src/gateway/index.ts
DELETED
|
@@ -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
|
-
}
|