@fengye_nobug/dsh-qq-bot 0.0.0-stage → 0.2.4

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/onebot.js ADDED
@@ -0,0 +1,388 @@
1
+ // onebot.js — OneBot v11(NapCat / go-cqhttp)传输层。
2
+ //
3
+ // 设计要点(都是被环境逼出来的,改之前先读):
4
+ //
5
+ // 1) **用 Node 内置的全局 WebSocket,不引 `ws` 依赖。**
6
+ // 插件目录里只挂了一个 `@deepseek-ai` junction(见 README 的 link 安装坑),
7
+ // `ws` 从这个目录解析不到(ERR_MODULE_NOT_FOUND)。Node >= 22 自带
8
+ // WHATWG WebSocket 客户端,零依赖。
9
+ //
10
+ // 2) **access_token 只能走查询串。**
11
+ // 内置 WebSocket 是 WHATWG 接口,构造函数只收 (url, protocols),
12
+ // **不支持自定义请求头**,所以没法像别的客户端那样发 `Authorization: Bearer`。
13
+ // OneBot 的 WS 服务端(NapCat / go-cqhttp)都支持 `?access_token=…`,走它。
14
+ //
15
+ // 3) **getUrl / getToken 传“函数”而不是值。**
16
+ // DSH 的凭据契约要求“每次操作重新解析、不得跨操作缓存”,这样轮换 token
17
+ // 不用重启进程。所以每次建连环都重新取一次。
18
+ //
19
+ // 4) **所有定时器都归 dispose() 管。**
20
+ // 插件被 stop / update / undefine 时必须能干净地停掉,不留悬挂连接和定时器。
21
+
22
+ /** WebSocket.OPEN(不依赖全局常量,避免运行时差异)。 */
23
+ const WS_OPEN = 1
24
+
25
+ /**
26
+ * 建一个 OneBot 传输实例。返回的对象是插件生命周期内唯一持有的东西,
27
+ * 所有副作用(连接、定时器、待决调用)都由 dispose() 回收。
28
+ *
29
+ * @param {object} options
30
+ * @param {() => string} options.getUrl 每次建连时取 WS 地址;返回空串 = 不连。
31
+ * @param {() => (string | Promise<string>)} options.getToken 每次建连时取 token(可为异步)。
32
+ * @param {(event: object) => void} options.onEvent OneBot 事件(非 echo 回包)。
33
+ * @param {(status: object) => void} [options.onStateChange] 连接状态变化通知。
34
+ * @param {{info: Function, warn: Function}} options.logger
35
+ * @param {number} [options.reconnectMinMs]
36
+ * @param {number} [options.reconnectMaxMs]
37
+ * @param {number} [options.heartbeatTimeoutMs] 多久没有任何流量就判定连接已死。
38
+ * @param {number} [options.echoTimeoutMs] action 调用等待 echo 回包的超时。
39
+ */
40
+ export function createOneBotTransport(options) {
41
+ const {
42
+ getUrl,
43
+ getToken,
44
+ onEvent,
45
+ onStateChange,
46
+ logger,
47
+ reconnectMinMs = 1000,
48
+ reconnectMaxMs = 30000,
49
+ heartbeatTimeoutMs = 90000,
50
+ echoTimeoutMs = 20000,
51
+ } = options
52
+
53
+ let socket = null
54
+ let stopped = true
55
+ /** 每次 connect() 加一。先发出、后返回的那次看到编号变了就退出,不再动新连接。 */
56
+ let connectGen = 0
57
+ let reconnectDelay = reconnectMinMs
58
+ let reconnectTimer = null
59
+ let watchdogTimer = null
60
+ let lastTrafficAt = 0
61
+ let echoSeq = 0
62
+ const pending = new Map()
63
+
64
+ const status = {
65
+ state: 'idle', // idle | connecting | open | closed | reconnecting | disabled
66
+ url: '',
67
+ selfId: null,
68
+ connectedAt: null,
69
+ lastError: null,
70
+ reconnects: 0,
71
+ received: 0,
72
+ sent: 0,
73
+ }
74
+
75
+ /**
76
+ * 事件里先带了 self_id、get_login_info 还没回来时,先记住。
77
+ * 已经有值就不覆盖:配置或登录信息优先。
78
+ */
79
+ function rememberSelfId(id) {
80
+ const next = String(id ?? '').trim()
81
+ if (next === '' || status.selfId) return
82
+ status.selfId = next
83
+ notify()
84
+ logger.info(`self_id = ${status.selfId}(来自事件,get_login_info 尚未返回)`)
85
+ }
86
+
87
+ function snapshot() {
88
+ return {
89
+ ...status,
90
+ pendingCalls: pending.size,
91
+ lastTrafficAt: lastTrafficAt || null,
92
+ reconnectDelayMs: reconnectDelay,
93
+ }
94
+ }
95
+
96
+ function notify() {
97
+ try {
98
+ onStateChange?.(snapshot())
99
+ } catch (error) {
100
+ logger.warn(`onStateChange failed: ${error?.message ?? error}`)
101
+ }
102
+ }
103
+
104
+ function setState(next, patch = {}) {
105
+ status.state = next
106
+ Object.assign(status, patch)
107
+ notify()
108
+ }
109
+
110
+ // ── 定时器 ────────────────────────────────────────────────────────────────
111
+
112
+ function clearReconnectTimer() {
113
+ if (reconnectTimer !== null) {
114
+ clearTimeout(reconnectTimer)
115
+ reconnectTimer = null
116
+ }
117
+ }
118
+
119
+ function armWatchdog() {
120
+ if (watchdogTimer !== null) return
121
+ // 用一个较长周期的 interval 检查“最近有没有流量”;OneBot 的 heartbeat
122
+ // 元事件默认 5s 一条,所以正常情况永远不会触发。
123
+ const tick = Math.max(5000, Math.floor(heartbeatTimeoutMs / 3))
124
+ watchdogTimer = setInterval(() => {
125
+ if (stopped || !socket || socket.readyState !== WS_OPEN) return
126
+ if (Date.now() - lastTrafficAt <= heartbeatTimeoutMs) return
127
+ logger.warn(`no traffic for ${heartbeatTimeoutMs}ms, forcing reconnect`)
128
+ status.lastError = `heartbeat timeout (${heartbeatTimeoutMs}ms)`
129
+ teardownSocket()
130
+ scheduleReconnect()
131
+ }, tick)
132
+ }
133
+
134
+ function clearWatchdog() {
135
+ if (watchdogTimer !== null) {
136
+ clearInterval(watchdogTimer)
137
+ watchdogTimer = null
138
+ }
139
+ }
140
+
141
+ function scheduleReconnect() {
142
+ if (stopped) return
143
+ clearReconnectTimer()
144
+ setState('reconnecting', { reconnects: status.reconnects + 1 })
145
+ // 指数退避 + 抖动,避免断网恢复时所有实例同时重连。
146
+ const jitter = Math.floor(Math.random() * 250)
147
+ const delay = reconnectDelay + jitter
148
+ logger.info(`reconnecting in ${delay}ms`)
149
+ reconnectTimer = setTimeout(() => {
150
+ reconnectTimer = null
151
+ connect()
152
+ }, delay)
153
+ reconnectDelay = Math.min(reconnectDelay * 2, reconnectMaxMs)
154
+ }
155
+
156
+ // ── 连接 ──────────────────────────────────────────────────────────────────
157
+
158
+ function teardownSocket() {
159
+ const ws = socket
160
+ socket = null
161
+ if (!ws) return
162
+ try {
163
+ ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null
164
+ if (ws.readyState === WS_OPEN || ws.readyState === 0) ws.close()
165
+ } catch {
166
+ /* 关闭失败无所谓,连接已经不可用 */
167
+ }
168
+ }
169
+
170
+ function rejectPending(reason) {
171
+ for (const [, entry] of pending) {
172
+ clearTimeout(entry.timer)
173
+ entry.reject(new Error(reason))
174
+ }
175
+ pending.clear()
176
+ }
177
+
178
+ function isCurrent(gen) {
179
+ return gen === connectGen && !stopped
180
+ }
181
+
182
+ async function connect() {
183
+ if (stopped) return
184
+ const gen = ++connectGen
185
+ // 只拆这一次调用开始时还留着的连接。过期的调用不能在返回时再拆一次。
186
+ teardownSocket()
187
+ rejectPending('reconnect')
188
+
189
+ let url
190
+ try {
191
+ url = String(getUrl?.() ?? '').trim()
192
+ } catch (error) {
193
+ logger.warn(`getUrl failed: ${error?.message ?? error}`)
194
+ url = ''
195
+ }
196
+ if (!isCurrent(gen)) return
197
+ if (url === '') {
198
+ setState('disabled', { url: '' })
199
+ return
200
+ }
201
+
202
+ let token = ''
203
+ try {
204
+ token = String((await getToken?.()) ?? '').trim()
205
+ } catch (error) {
206
+ logger.warn(`resolve access token failed: ${error?.message ?? error}`)
207
+ }
208
+ if (!isCurrent(gen)) return
209
+
210
+ // 内置 WebSocket 无法自定义请求头 → token 走查询串(OneBot 服务端支持)。
211
+ let target
212
+ try {
213
+ const parsed = new URL(url)
214
+ if (token !== '') parsed.searchParams.set('access_token', token)
215
+ target = parsed.toString()
216
+ } catch (error) {
217
+ status.lastError = `invalid onebotUrl: ${error?.message ?? error}`
218
+ setState('closed', { lastError: status.lastError })
219
+ scheduleReconnect()
220
+ return
221
+ }
222
+
223
+ // 日志里只打脱敏后的地址,绝不把 token 写进日志或状态文件。
224
+ const safeUrl = url.replace(/access_token=[^&]*/i, 'access_token=***')
225
+ setState('connecting', { url: safeUrl })
226
+
227
+ if (!isCurrent(gen)) return
228
+ let ws
229
+ try {
230
+ ws = new WebSocket(target)
231
+ } catch (error) {
232
+ if (!isCurrent(gen)) return
233
+ status.lastError = `WebSocket ctor failed: ${error?.message ?? error}`
234
+ setState('closed', { lastError: status.lastError })
235
+ scheduleReconnect()
236
+ return
237
+ }
238
+ if (!isCurrent(gen)) {
239
+ try {
240
+ ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null
241
+ ws.close()
242
+ } catch {
243
+ // 这次建连已经过期,关掉即可
244
+ }
245
+ return
246
+ }
247
+ socket = ws
248
+
249
+ ws.onopen = () => {
250
+ if (socket !== ws) return
251
+ reconnectDelay = reconnectMinMs // 成功一次就把退避清零
252
+ lastTrafficAt = Date.now()
253
+ setState('open', { connectedAt: Date.now(), lastError: null })
254
+ logger.info(`connected to ${safeUrl}`)
255
+ armWatchdog()
256
+ // 主动问一次登录信息:selfId 是过滤自身消息的依据(防回复死循环)。
257
+ call('get_login_info', {}).then(
258
+ (data) => {
259
+ const id = data?.user_id ?? data?.data?.user_id
260
+ if (id !== undefined && id !== null && String(id) !== '') {
261
+ status.selfId = String(id)
262
+ notify()
263
+ logger.info(`self_id = ${status.selfId}`)
264
+ }
265
+ },
266
+ () => {
267
+ /* 拿不到就算了:配置里的 selfId 仍可兜底 */
268
+ },
269
+ )
270
+ }
271
+
272
+ ws.onmessage = (ev) => {
273
+ if (socket !== ws) return
274
+ lastTrafficAt = Date.now()
275
+ status.received += 1
276
+ let payload
277
+ try {
278
+ payload = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data))
279
+ } catch (error) {
280
+ logger.warn(`non-JSON frame ignored: ${error?.message ?? error}`)
281
+ return
282
+ }
283
+ if (!payload || typeof payload !== 'object') return
284
+
285
+ if (payload.echo !== undefined && pending.has(String(payload.echo))) {
286
+ const entry = pending.get(String(payload.echo))
287
+ pending.delete(String(payload.echo))
288
+ clearTimeout(entry.timer)
289
+ if (payload.status === 'failed' || payload.retcode !== 0) {
290
+ entry.reject(new Error(`OneBot action failed: retcode=${payload.retcode} ${payload.message ?? ''}`.trim()))
291
+ } else {
292
+ entry.resolve(payload.data ?? payload)
293
+ }
294
+ return
295
+ }
296
+
297
+ try {
298
+ onEvent?.(payload)
299
+ } catch (error) {
300
+ logger.warn(`onEvent failed: ${error?.message ?? error}`)
301
+ }
302
+ }
303
+
304
+ ws.onclose = (ev) => {
305
+ if (socket !== ws) return
306
+ socket = null
307
+ clearWatchdog()
308
+ rejectPending('transport closed')
309
+ const reason = `closed (code=${ev?.code ?? '?'}${ev?.reason ? `, ${ev.reason}` : ''})`
310
+ setState('closed', { connectedAt: null, lastError: status.lastError ?? reason })
311
+ logger.warn(`disconnected: ${reason}`)
312
+ scheduleReconnect()
313
+ }
314
+
315
+ ws.onerror = () => {
316
+ // WHATWG 的 error 事件不带细节;真正的诊断信息在随后的 close 事件里。
317
+ // 这里只记录“发生过错误”,绝不冒泡(插件 bug 不能弄崩宿主)。
318
+ if (socket !== ws) return
319
+ status.lastError = status.lastError ?? 'websocket error'
320
+ }
321
+ }
322
+
323
+ // ── 对外 API ──────────────────────────────────────────────────────────────
324
+
325
+ /** 启动(幂等)。地址为空时不连,状态停在 disabled。 */
326
+ function start() {
327
+ if (!stopped) return
328
+ stopped = false
329
+ reconnectDelay = reconnectMinMs
330
+ connect()
331
+ }
332
+
333
+ /** 立刻重连一次(调试用)。 */
334
+ function reconnectNow() {
335
+ if (stopped) {
336
+ start()
337
+ return
338
+ }
339
+ clearReconnectTimer()
340
+ teardownSocket()
341
+ rejectPending('manual reconnect')
342
+ reconnectDelay = reconnectMinMs
343
+ connect()
344
+ }
345
+
346
+ /** 是否已连上。 */
347
+ function isOpen() {
348
+ return socket !== null && socket.readyState === WS_OPEN
349
+ }
350
+
351
+ /**
352
+ * 发一个 OneBot action 并等待 echo 回包。
353
+ * 未连接时抛错——上层据此决定“出站丢弃”还是“排队”。
354
+ */
355
+ function call(action, params = {}) {
356
+ if (!isOpen()) return Promise.reject(new Error('OneBot transport is not connected'))
357
+ echoSeq += 1
358
+ const echo = `qq-bridge-${echoSeq}`
359
+ return new Promise((resolve, reject) => {
360
+ const timer = setTimeout(() => {
361
+ pending.delete(echo)
362
+ reject(new Error(`OneBot action "${action}" timed out after ${echoTimeoutMs}ms`))
363
+ }, echoTimeoutMs)
364
+ pending.set(echo, { resolve, reject, timer })
365
+ try {
366
+ socket.send(JSON.stringify({ action, params, echo }))
367
+ status.sent += 1
368
+ } catch (error) {
369
+ clearTimeout(timer)
370
+ pending.delete(echo)
371
+ reject(error)
372
+ }
373
+ })
374
+ }
375
+
376
+ /** 停止并回收一切副作用。可以重复调用。 */
377
+ function dispose() {
378
+ stopped = true
379
+ clearReconnectTimer()
380
+ clearWatchdog()
381
+ teardownSocket()
382
+ rejectPending('transport disposed')
383
+ status.state = 'idle'
384
+ status.connectedAt = null
385
+ }
386
+
387
+ return { start, dispose, reconnectNow, isOpen, call, rememberSelfId, status: snapshot }
388
+ }
package/package.json CHANGED
@@ -1,6 +1,75 @@
1
1
  {
2
2
  "name": "@fengye_nobug/dsh-qq-bot",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.4",
4
+ "type": "module",
5
+ "main": "index.js",
6
+ "description": "把 DSH 会话接到 QQ(OneBot/NapCat)的插件。白名单默认全关;被 @、叫到昵称或被戳一定叫醒,其余消息按可调概率抽样。Fork 自 sj244/dsh-qq-bridge(MIT)。",
7
+ "license": "MIT",
8
+ "author": "yunfengnobug",
9
+ "contributors": [
10
+ "sj244 (upstream dsh-qq-bridge)"
11
+ ],
12
+ "keywords": [
13
+ "deepseek-harness",
14
+ "dsh-plugin",
15
+ "onebot",
16
+ "napcat",
17
+ "qq",
18
+ "bridge"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/yunfengnobug/dsh-qq-bot.git"
23
+ },
24
+ "homepage": "https://github.com/yunfengnobug/dsh-qq-bot#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/yunfengnobug/dsh-qq-bot/issues"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "engines": {
32
+ "node": ">=22"
33
+ },
34
+ "dsh": {
35
+ "bundle": {
36
+ "patch": "./cordis.patch.yml"
37
+ },
38
+ "client": {
39
+ "inject": [
40
+ "@deepseek-ai/dsh-client-ui-settings"
41
+ ],
42
+ "platform": "web"
43
+ }
44
+ },
45
+ "exports": {
46
+ ".": "./index.js",
47
+ "./client": "./client/client.js",
48
+ "./onebot.js": "./onebot.js",
49
+ "./napcat.js": "./napcat.js",
50
+ "./cordis.patch.yml": "./cordis.patch.yml",
51
+ "./package.json": "./package.json"
52
+ },
53
+ "files": [
54
+ "index.js",
55
+ "updates.js",
56
+ "onebot.js",
57
+ "napcat.js",
58
+ "stickers.js",
59
+ "faces.js",
60
+ "client",
61
+ "assets",
62
+ "screenshots.json",
63
+ "cordis.patch.yml",
64
+ "README.md",
65
+ "CHANGELOG.md",
66
+ "DESIGN.md",
67
+ "LICENSE"
68
+ ],
69
+ "peerDependencies": {
70
+ "@deepseek-ai/cordis": "^4.0.1",
71
+ "@deepseek-ai/dsh-llm": "*",
72
+ "@deepseek-ai/dsh-tools": "*",
73
+ "@deepseek-ai/schemastery": "*"
74
+ }
75
+ }
@@ -0,0 +1,3 @@
1
+ [
2
+ "assets/market-cover.jpg"
3
+ ]