dsh-bridge-gateway 0.1.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +250 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +408 -0
  4. package/README.md +430 -0
  5. package/client/build.mjs +43 -0
  6. package/client/client.js +5209 -0
  7. package/client/index.js +4967 -0
  8. package/cordis.patch.yml +4 -0
  9. package/docs/banner.jpg +0 -0
  10. package/docs/custom-tunnel.md +220 -0
  11. package/docs/feishu-usage.md +116 -0
  12. package/docs/fix-tunnel-sse-and-session-list.md +134 -0
  13. package/docs/platform-abstraction-design.md +473 -0
  14. package/docs/qq-usage.md +387 -0
  15. package/docs/screenshots/admin-lock-screen.jpg +0 -0
  16. package/docs/screenshots/feishu-bot-config.jpg +0 -0
  17. package/docs/screenshots/feishu-chat.jpg +0 -0
  18. package/docs/screenshots/lan-access.jpg +0 -0
  19. package/docs/screenshots/mobile-chat.jpg +0 -0
  20. package/docs/screenshots/mobile-drawer.jpg +0 -0
  21. package/docs/screenshots/mobile-remote-settings.jpg +0 -0
  22. package/docs/screenshots/mobile-settings-im.jpg +0 -0
  23. package/docs/screenshots/mobile-settings-lan.jpg +0 -0
  24. package/docs/screenshots/mobile-settings-security.jpg +0 -0
  25. package/docs/screenshots/mobile-settings-tunnel.jpg +0 -0
  26. package/docs/screenshots/mobile-workspace-picker.jpg +0 -0
  27. package/docs/screenshots/qq-bot-config.jpg +0 -0
  28. package/docs/screenshots/qq-chat.jpg +0 -0
  29. package/docs/screenshots/qq-group.jpg +0 -0
  30. package/docs/screenshots/qr-scan.jpg +0 -0
  31. package/docs/screenshots/remote-auth-login.jpg +0 -0
  32. package/docs/screenshots/remote-web-mobile.jpg +0 -0
  33. package/docs/screenshots/security-auth-config.jpg +0 -0
  34. package/docs/screenshots/telegram-bot-config.jpg +0 -0
  35. package/docs/screenshots/tunnel-access.jpg +0 -0
  36. package/docs/screenshots/wechat-bot-config.jpg +0 -0
  37. package/docs/screenshots/wechat-chat.jpg +0 -0
  38. package/docs/telegram-usage.md +93 -0
  39. package/docs/wechat-usage.md +104 -0
  40. package/lib/auth/login-template.js +382 -0
  41. package/lib/auth/manager.js +470 -0
  42. package/lib/bridge-rpc-constants.js +55 -0
  43. package/lib/bridge-rpc.js +553 -0
  44. package/lib/cloudflared-manager.mjs +345 -0
  45. package/lib/feishu/gateway.js +550 -0
  46. package/lib/feishu/index.js +222 -0
  47. package/lib/feishu/lark-bundled.mjs +125547 -0
  48. package/lib/feishu/node.js +409 -0
  49. package/lib/gateway/cert.js +214 -0
  50. package/lib/index.js +1963 -0
  51. package/lib/platform/base.js +156 -0
  52. package/lib/platform/conversation-bridge.js +1570 -0
  53. package/lib/platform/index.js +10 -0
  54. package/lib/platform/manager.js +74 -0
  55. package/lib/qq/gateway.js +616 -0
  56. package/lib/qq/index.js +309 -0
  57. package/lib/qq/node.js +533 -0
  58. package/lib/security/path-validator.js +208 -0
  59. package/lib/security/rate-limiter.js +93 -0
  60. package/lib/telegram/gateway.js +630 -0
  61. package/lib/telegram/index.js +213 -0
  62. package/lib/telegram/node.js +351 -0
  63. package/lib/tunnel-client.mjs +402 -0
  64. package/lib/wechat/gateway.js +960 -0
  65. package/lib/wechat/index.js +241 -0
  66. package/lib/wechat/media.js +281 -0
  67. package/lib/wechat/node.js +350 -0
  68. package/package.json +102 -0
@@ -0,0 +1,10 @@
1
+ // dsh-bridge 平台抽象层统一导出
2
+ //
3
+ // 提供多平台 IM 接入的基础设施:
4
+ // Platform 平台适配器基类(协议/连接/登录/收发消息)
5
+ // ConversationBridge 平台无关会话桥(白名单/会话/审批/命令/digest)
6
+ // PlatformManager 多平台注册与状态聚合
7
+
8
+ export { Platform } from './base.js'
9
+ export { ConversationBridge, conversationBridgeHelpers, BRIDGE_MARK, textOfAssistantMessage } from './conversation-bridge.js'
10
+ export { PlatformManager } from './manager.js'
@@ -0,0 +1,74 @@
1
+ // dsh-bridge 平台管理器
2
+ //
3
+ // 注册/协调多个 IM 平台适配器(Platform 子类),向主插件与 RPC 层提供:
4
+ // - 统一的多平台状态聚合(getStatus)
5
+ // - 平台实例查找(get)
6
+ // - 统一生命周期(dispose)
7
+ //
8
+ // 主插件注入已构造的平台实例列表,本类只负责登记与聚合,不负责构造平台。
9
+
10
+ export class PlatformManager {
11
+ /**
12
+ * @param {object} opts
13
+ * @param {object} opts.logger 日志器
14
+ */
15
+ constructor({ logger } = {}) {
16
+ this.logger = logger
17
+ this.platforms = new Map() // platformId -> Platform
18
+ }
19
+
20
+ /** 注册一个平台实例。重复注册同 id 时替换旧实例并 dispose 旧的。 */
21
+ register(platform) {
22
+ if (!platform || !platform.id) {
23
+ this.logger?.warn?.('[dsh-bridge] PlatformManager.register: 平台缺少 id,已忽略')
24
+ return platform
25
+ }
26
+ const existing = this.platforms.get(platform.id)
27
+ if (existing && existing !== platform) {
28
+ try {
29
+ const result = existing.dispose()
30
+ if (result instanceof Promise) {
31
+ // 异步 dispose,记录警告(当前架构假定同步清理)
32
+ this.logger?.warn?.(`[dsh-bridge] Platform ${platform.id} dispose is async but not awaited (may leave dangling resources)`)
33
+ }
34
+ } catch { /* 忽略 */ }
35
+ }
36
+ this.platforms.set(platform.id, platform)
37
+ this.logger?.info?.(`[dsh-bridge] platform registered: ${platform.id} (${platform.name ?? ''})`)
38
+ return platform
39
+ }
40
+
41
+ /** 获取平台实例;不存在返回 undefined。 */
42
+ get(platformId) {
43
+ return this.platforms.get(platformId)
44
+ }
45
+
46
+ /** 所有已注册平台 id。 */
47
+ list() {
48
+ return [...this.platforms.values()]
49
+ }
50
+
51
+ /** 聚合所有平台状态:{ [platformId]: status }。 */
52
+ getStatus() {
53
+ const out = {}
54
+ for (const [id, platform] of this.platforms) {
55
+ try {
56
+ out[id] = platform.getStatus()
57
+ } catch (err) {
58
+ // 防御性读取 platform.name(getter 可能抛异常)
59
+ let name = id
60
+ try { name = platform.name ?? id } catch { /* 忽略 */ }
61
+ out[id] = { id, name, status: 'error', error: err?.message ?? String(err) }
62
+ }
63
+ }
64
+ return out
65
+ }
66
+
67
+ /** 释放所有平台。 */
68
+ dispose() {
69
+ for (const platform of this.platforms.values()) {
70
+ try { platform.dispose() } catch { /* 忽略 */ }
71
+ }
72
+ this.platforms.clear()
73
+ }
74
+ }
@@ -0,0 +1,616 @@
1
+ // QQ Bot OpenAPI v2 gateway
2
+ // Official API: https://bot.q.qq.com/wiki/develop/api-v2/
3
+
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+ import { Service } from '@deepseek-ai/cordis'
7
+ import WebSocket from 'ws'
8
+
9
+ const TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken'
10
+ const API_BASE = 'https://api.bot.qq.com'
11
+ // 官方 WebSocket 网关地址(2026-08-10 起域名统一为 api.bot.qq.com)
12
+ // 参考:https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/event-emit/websocket.html
13
+ const DEFAULT_GATEWAY = 'wss://api.bot.qq.com/websocket/'
14
+ const MAX_MESSAGE_CHARS = 2000
15
+ const TOKEN_MARGIN_MS = 5 * 60_000
16
+ const REQUEST_TIMEOUT_MS = 15_000
17
+ const RECONNECT_DELAY_MS = 3000
18
+
19
+ export const QQ_INTENTS = {
20
+ // 官方 Intent 表:C2C_MESSAGE_CREATE 与 GROUP_AT_MESSAGE_CREATE 同属 GROUP_AND_C2C_EVENT (1<<25)
21
+ // 参考:https://bot.q.qq.com/wiki/develop/api-v2/autogen/event/c2c_message_create.html
22
+ // https://bot.q.qq.com/wiki/develop/api-v2/autogen/event/group_at_message_create.html
23
+ GROUP_AND_C2C_EVENT: 1 << 25,
24
+ C2C_MESSAGE_CREATE: 1 << 25,
25
+ GROUP_AT_MESSAGE_CREATE: 1 << 25, // 之前误标 1<<30(无效值)
26
+ INTERACTION_CREATE: 1 << 26,
27
+ PUBLIC_GUILD_MESSAGES: 1 << 9,
28
+ DIRECT_MESSAGE: 1 << 12,
29
+ }
30
+
31
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
32
+ const stringValue = (value) => value == null ? '' : String(value)
33
+
34
+ async function requestJson(url, { method = 'GET', token, body, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
35
+ const controller = new AbortController()
36
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
37
+ try {
38
+ const response = await fetch(url, {
39
+ method,
40
+ headers: {
41
+ accept: 'application/json',
42
+ 'content-type': 'application/json',
43
+ ...(token ? { authorization: `QQBot ${token}` } : {}),
44
+ },
45
+ body: body === undefined ? undefined : JSON.stringify(body),
46
+ signal: controller.signal,
47
+ })
48
+ const raw = await response.text()
49
+ let value = {}
50
+ try { value = raw ? JSON.parse(raw) : {} } catch { value = { message: raw } }
51
+ if (!response.ok) {
52
+ const error = new Error(`QQ API ${response.status}: ${value?.message || value?.msg || value?.code || response.statusText}`)
53
+ error.status = response.status
54
+ error.payload = value
55
+ throw error
56
+ }
57
+ return value
58
+ } finally { clearTimeout(timer) }
59
+ }
60
+
61
+ function normalizeEvent(payload) {
62
+ const data = payload?.d || {}
63
+ const event = payload?.t || ''
64
+ if (event === 'C2C_MESSAGE_CREATE') {
65
+ return {
66
+ type: 'message', scope: 'c2c', event,
67
+ id: data.id || data.msg_id,
68
+ senderId: data.author?.user_openid || data.user_openid,
69
+ peerId: data.author?.user_openid || data.user_openid,
70
+ text: stringValue(data.content).trim(),
71
+ attachments: Array.isArray(data.attachments) ? data.attachments : [],
72
+ message: data,
73
+ messageReference: data.message_reference,
74
+ msgSeq: data.msg_seq, // 用于避免去重
75
+ }
76
+ }
77
+ if (event === 'GROUP_AT_MESSAGE_CREATE' || event === 'GROUP_MESSAGE_CREATE') {
78
+ // GROUP_AT_MESSAGE_CREATE:用户@机器人触发;GROUP_MESSAGE_CREATE:开启"接收所有消息"后每条群消息
79
+ // 两者字段结构完全一致(官方文档),content 已去除 @机器人 前缀
80
+ return {
81
+ type: 'message', scope: 'group', event,
82
+ id: data.id || data.msg_id,
83
+ senderId: data.author?.member_openid || data.author?.id || data.member_openid,
84
+ peerId: data.group_openid || data.group_id,
85
+ groupId: data.group_openid || data.group_id,
86
+ text: stringValue(data.content).trim(),
87
+ attachments: Array.isArray(data.attachments) ? data.attachments : [],
88
+ message: data,
89
+ messageReference: data.message_reference,
90
+ msgSeq: data.msg_seq, // 用于避免去重
91
+ }
92
+ }
93
+ if (event === 'AT_MESSAGE_CREATE') {
94
+ return {
95
+ type: 'message', scope: 'guild', event,
96
+ id: data.id || data.msg_id,
97
+ senderId: data.author?.id,
98
+ peerId: data.channel_id,
99
+ guildId: data.guild_id,
100
+ text: stringValue(data.content).trim(),
101
+ message: data,
102
+ messageReference: data.message_reference,
103
+ }
104
+ }
105
+ if (event === 'INTERACTION_CREATE') {
106
+ return {
107
+ type: 'interaction', scope: data.group_openid ? 'group' : 'c2c', event,
108
+ id: data.id,
109
+ interactionId: data.id,
110
+ interactionType: Number(data.type), // 11=消息按钮回调, 12=快捷菜单回调, 13=消息反馈...
111
+ senderId: data.group_member_openid || data.user_openid,
112
+ peerId: data.group_openid || data.user_openid,
113
+ groupId: data.group_openid,
114
+ data: data.data,
115
+ message: data,
116
+ }
117
+ }
118
+ return { type: 'event', event, message: data }
119
+ }
120
+
121
+ export class QqGateway extends Service {
122
+ constructor({ ctx, logger, config = {}, onPersist } = {}) {
123
+ super(ctx, 'qq')
124
+ this.logger = logger
125
+ this.onPersist = onPersist || (() => {})
126
+ this.config = {
127
+ appId: config.appId || '',
128
+ clientSecret: config.clientSecret || '',
129
+ accessToken: config.accessToken || '',
130
+ accessTokenExpiresAt: Number(config.accessTokenExpiresAt || 0),
131
+ gatewayUrl: config.gatewayUrl || '',
132
+ intents: Number(config.intents ?? (QQ_INTENTS.C2C_MESSAGE_CREATE | QQ_INTENTS.GROUP_AT_MESSAGE_CREATE | QQ_INTENTS.INTERACTION_CREATE)),
133
+ reconnectDelayMs: Number(config.reconnectDelayMs ?? RECONNECT_DELAY_MS),
134
+ apiTimeoutMs: Number(config.apiTimeoutMs ?? REQUEST_TIMEOUT_MS),
135
+ }
136
+ this.statusValue = 'idle'
137
+ this.accountId = config.accountId || ''
138
+ this.ws = null
139
+ this.loopTask = null
140
+ this.stopRequested = false
141
+ this.heartbeatTimer = null
142
+ this.heartbeatInterval = 30_000
143
+ this.sequence = null
144
+ this.sessionId = null
145
+ this.unackedHeartbeats = 0
146
+ this.tokenPromise = null
147
+ this.dedup = new Map()
148
+ }
149
+
150
+ get status() { return this.statusValue }
151
+ get configured() { return Boolean(this.config.appId && this.config.clientSecret) }
152
+ get capabilities() {
153
+ // QQ 支持输入状态(msg_type=6 + input_notify)、群聊、富媒体
154
+ return { supportsGroup: true, supportsMedia: true, supportsVoice: true, supportsTyping: true, maxMessageChars: MAX_MESSAGE_CHARS }
155
+ }
156
+
157
+ setStatus(status) {
158
+ this.statusValue = status
159
+ try { this.ctx.emit?.('qq/status', status) } catch {}
160
+ }
161
+
162
+ async dispose() { await this.stop(); super.dispose?.() }
163
+
164
+ async stop() {
165
+ this.stopRequested = true
166
+ this.clearHeartbeat()
167
+ const finish = this._finishConnect
168
+ this._finishConnect = null
169
+ if (finish) {
170
+ try { finish(new Error('Stopped by user')) } catch {}
171
+ }
172
+ const ws = this.ws
173
+ this.ws = null
174
+ if (ws) {
175
+ try { ws.close(); ws.terminate() } catch {}
176
+ }
177
+ const task = this.loopTask
178
+ this.loopTask = null
179
+ if (task) await task.catch(() => {})
180
+ this.setStatus('idle')
181
+ }
182
+
183
+ async start() {
184
+ if (this._startingPromise) return this._startingPromise
185
+ if (!this.configured) { this.setStatus('idle'); return }
186
+ if (this.loopTask) return
187
+ this._startingPromise = (async () => {
188
+ this.stopRequested = false
189
+ this.loopTask = this.runLoop().finally(() => { this.loopTask = null })
190
+ })().finally(() => { this._startingPromise = null })
191
+ return this._startingPromise
192
+ }
193
+
194
+ async persist(patch) {
195
+ try { await this.onPersist(patch) } catch (error) {
196
+ this.logger?.warn?.('[dsh-bridge qq] persist failed: %s', error?.message ?? error)
197
+ }
198
+ }
199
+
200
+ async refreshAccessToken() {
201
+ if (this.config.accessToken && this.config.accessTokenExpiresAt > Date.now() + TOKEN_MARGIN_MS) {
202
+ return this.config.accessToken
203
+ }
204
+ if (this.tokenPromise) return this.tokenPromise
205
+ if (!this.configured) throw new Error('QQ Bot 缺少 AppID 或 ClientSecret')
206
+ this.tokenPromise = (async () => {
207
+ const result = await requestJson(TOKEN_URL, {
208
+ method: 'POST',
209
+ body: { appId: this.config.appId, clientSecret: this.config.clientSecret },
210
+ timeoutMs: this.config.apiTimeoutMs,
211
+ })
212
+ const token = result?.access_token || result?.accessToken
213
+ if (!token) throw new Error('QQ Bot 未返回 access_token')
214
+ const expires = Math.max(60, Number(result?.expires_in || 7200))
215
+ this.config.accessToken = token
216
+ this.config.accessTokenExpiresAt = Date.now() + expires * 1000
217
+ await this.persist({ accessToken: token, accessTokenExpiresAt: this.config.accessTokenExpiresAt })
218
+ return token
219
+ })().finally(() => { this.tokenPromise = null })
220
+ return this.tokenPromise
221
+ }
222
+
223
+ async api(path, options = {}) {
224
+ return requestJson(`${API_BASE}${path}`, {
225
+ ...options,
226
+ token: await this.refreshAccessToken(),
227
+ timeoutMs: this.config.apiTimeoutMs,
228
+ })
229
+ }
230
+
231
+ async runLoop() {
232
+ let backoffMs = this.config.reconnectDelayMs
233
+ while (!this.stopRequested) {
234
+ try {
235
+ this.setStatus('starting')
236
+ const token = await this.refreshAccessToken()
237
+ if (this.stopRequested) break
238
+ // 官方「获取带分片 WSS 接入点」接口,返回网关地址与建议分片数
239
+ // 参考:https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/event-emit/websocket.html
240
+ const gateway = this.config.gatewayUrl
241
+ || ((await requestJson(`${API_BASE}/gateway/bot`, { token, timeoutMs: this.config.apiTimeoutMs })).url)
242
+ || DEFAULT_GATEWAY
243
+ if (this.stopRequested) break
244
+ await this.connect(gateway, token)
245
+ backoffMs = this.config.reconnectDelayMs
246
+ } catch (error) {
247
+ if (this.stopRequested) break
248
+ this.setStatus('reconnecting')
249
+ this.logger?.warn?.('[dsh-bridge qq] gateway disconnected: %s, retrying in %dms...', error?.message ?? error, backoffMs)
250
+ await sleep(backoffMs)
251
+ backoffMs = Math.min(Math.round(backoffMs * 1.5), 30_000)
252
+ }
253
+ }
254
+ this.setStatus('idle')
255
+ }
256
+
257
+ connect(url, token) {
258
+ return new Promise((resolve, reject) => {
259
+ if (this.stopRequested) {
260
+ resolve()
261
+ return
262
+ }
263
+ const ws = new WebSocket(url)
264
+ this.ws = ws
265
+ let settled = false
266
+ const finish = (error) => {
267
+ if (settled) return
268
+ settled = true
269
+ this._finishConnect = null
270
+ this.clearHeartbeat()
271
+ if (this.ws === ws) this.ws = null
272
+ error ? reject(error) : resolve()
273
+ }
274
+ this._finishConnect = finish
275
+
276
+ ws.on('open', () => {
277
+ if (this.stopRequested) {
278
+ finish()
279
+ return
280
+ }
281
+ this.logger?.info?.('[dsh-bridge qq] WebSocket connected to QQ Open Platform')
282
+ })
283
+ ws.on('message', (raw) => {
284
+ if (this.stopRequested) return
285
+ let payload
286
+ try { payload = JSON.parse(String(raw)) } catch { return }
287
+ void this.handlePayload(payload, token, ws).catch(finish)
288
+ })
289
+ ws.on('error', finish)
290
+ ws.on('close', (code, reason) => finish(new Error(`QQ gateway closed: ${code} ${reason || ''}`)))
291
+ })
292
+ }
293
+
294
+ async handlePayload(payload, token, ws) {
295
+ if (this.stopRequested) return
296
+ const op = Number(payload?.op)
297
+ if (payload?.s != null) this.sequence = payload.s
298
+ if (op === 10) {
299
+ this.heartbeatInterval = Number(payload?.d?.heartbeat_interval || 30_000)
300
+ this.startHeartbeat(ws)
301
+ if (this.sessionId && this.sequence != null) {
302
+ // 快速恢复模式(Resume):携带 sessionId 与 sequence 避免重新全量鉴权与丢失消息
303
+ this.logger?.info?.('[dsh-bridge qq] attempting session resume (sessionId=%s, seq=%s)', this.sessionId, this.sequence)
304
+ ws.send(JSON.stringify({
305
+ op: 6,
306
+ d: {
307
+ token: `QQBot ${token}`,
308
+ session_id: this.sessionId,
309
+ seq: this.sequence,
310
+ },
311
+ }))
312
+ } else {
313
+ // 全量鉴权模式(Identify)
314
+ ws.send(JSON.stringify({
315
+ op: 2,
316
+ d: {
317
+ token: `QQBot ${token}`,
318
+ intents: this.config.intents,
319
+ shard: [0, 1],
320
+ properties: { $os: process.platform, $browser: 'dsh-bridge', $device: 'dsh-bridge' },
321
+ },
322
+ }))
323
+ }
324
+ return
325
+ }
326
+ if (op === 11) {
327
+ // 心跳确认(Heartbeat ACK):重置未确认计数器
328
+ this.unackedHeartbeats = 0
329
+ return
330
+ }
331
+ if (op === 0) {
332
+ if (payload.t === 'READY') {
333
+ this.sessionId = payload.d?.session_id || this.sessionId
334
+ this.accountId = payload.d?.user?.id || payload.d?.user?.username || this.accountId
335
+ this.unackedHeartbeats = 0
336
+ await this.persist({ accountId: this.accountId })
337
+ this.setStatus('connected')
338
+ this.logger?.info?.('[dsh-bridge qq] gateway READY (session_id=%s)', this.sessionId)
339
+ } else if (payload.t === 'RESUMED') {
340
+ this.unackedHeartbeats = 0
341
+ this.setStatus('connected')
342
+ this.logger?.info?.('[dsh-bridge qq] gateway RESUMED successfully')
343
+ }
344
+ const event = normalizeEvent(payload)
345
+ if (event.type === 'message' && event.id && !this.seen(event.id)) {
346
+ this.ctx.emit?.('qq/message', event)
347
+ }
348
+ if (event.type === 'interaction' && event.interactionId) {
349
+ this.ctx.emit?.('qq/interaction', event)
350
+ }
351
+ return
352
+ }
353
+ if (op === 7) {
354
+ this.logger?.info?.('[dsh-bridge qq] gateway requested reconnect (OpCode 7)')
355
+ try { ws.close() } catch {}
356
+ return
357
+ }
358
+ if (op === 9) {
359
+ this.sessionId = null
360
+ this.sequence = null
361
+ throw new Error('QQ gateway invalid session (OpCode 9)')
362
+ }
363
+ }
364
+
365
+ startHeartbeat(ws) {
366
+ this.clearHeartbeat()
367
+ this.unackedHeartbeats = 0
368
+ const beat = () => {
369
+ if (ws.readyState === WebSocket.OPEN) {
370
+ if (this.unackedHeartbeats >= 2) {
371
+ this.logger?.warn?.('[dsh-bridge qq] heartbeat ACK missed (count=%d), dead link detected, reconnecting...', this.unackedHeartbeats)
372
+ try { ws.terminate() } catch {}
373
+ return
374
+ }
375
+ this.unackedHeartbeats += 1
376
+ ws.send(JSON.stringify({ op: 1, d: this.sequence }))
377
+ }
378
+ }
379
+ beat()
380
+ this.heartbeatTimer = setInterval(beat, this.heartbeatInterval)
381
+ }
382
+
383
+ clearHeartbeat() {
384
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
385
+ this.heartbeatTimer = null
386
+ this.unackedHeartbeats = 0
387
+ }
388
+
389
+ seen(id) {
390
+ const now = Date.now()
391
+ for (const [key, at] of this.dedup) if (now - at > 300_000) this.dedup.delete(key)
392
+ if (this.dedup.has(id)) return true
393
+ this.dedup.set(id, now)
394
+ return false
395
+ }
396
+
397
+ endpoint(peerId, scope, kind = 'messages') {
398
+ return scope === 'group'
399
+ ? `/v2/groups/${encodeURIComponent(peerId)}/${kind}`
400
+ : `/v2/users/${encodeURIComponent(peerId)}/${kind}`
401
+ }
402
+
403
+ async sendText(peerId, content, opts = {}) {
404
+ const body = {
405
+ content: stringValue(content).slice(0, MAX_MESSAGE_CHARS),
406
+ msg_type: 0,
407
+ }
408
+ // 只添加有值的可选字段
409
+ if (opts.msgId !== undefined) body.msg_id = opts.msgId
410
+ if (opts.eventId !== undefined) body.event_id = opts.eventId
411
+ if (opts.msgSeq !== undefined) body.msg_seq = opts.msgSeq
412
+ return this.api(this.endpoint(peerId, opts.scope), { method: 'POST', body })
413
+ }
414
+
415
+ async sendMarkdown(peerId, markdown, opts = {}) {
416
+ const body = {
417
+ markdown: typeof markdown === 'string' ? { content: markdown } : markdown,
418
+ msg_type: 2,
419
+ }
420
+ // 只添加有值的可选字段
421
+ if (opts.keyboard !== undefined) body.keyboard = opts.keyboard
422
+ if (opts.msgId !== undefined) body.msg_id = opts.msgId
423
+ if (opts.eventId !== undefined) body.event_id = opts.eventId
424
+ if (opts.msgSeq !== undefined) body.msg_seq = opts.msgSeq
425
+ return this.api(this.endpoint(peerId, opts.scope), { method: 'POST', body })
426
+ }
427
+
428
+ async sendKeyboard(peerId, content, keyboard, opts = {}) {
429
+ // 官方文档:keyboard 为附加字段,配合 msg_type=0(content) 或 msg_type=2(markdown) 使用
430
+ // 纯文本 + 键盘时使用 msg_type=0
431
+ const body = {
432
+ content: stringValue(content),
433
+ keyboard,
434
+ msg_type: 0,
435
+ }
436
+ // 只添加有值的可选字段
437
+ if (opts.msgId !== undefined) body.msg_id = opts.msgId
438
+ if (opts.eventId !== undefined) body.event_id = opts.eventId
439
+ if (opts.msgSeq !== undefined) body.msg_seq = opts.msgSeq
440
+ return this.api(this.endpoint(peerId, opts.scope), { method: 'POST', body })
441
+ }
442
+
443
+ async sendMedia(peerId, media, opts = {}) {
444
+ return this.api(this.endpoint(peerId, opts.scope, 'files'), {
445
+ method: 'POST',
446
+ body: {
447
+ file_type: Number(media.fileType || 1),
448
+ url: media.url,
449
+ srv_send_msg: media.sendMessage !== false,
450
+ },
451
+ })
452
+ }
453
+
454
+ async sendStream(peerId, content, opts = {}) {
455
+ const endpoint = this.endpoint(peerId, opts.scope, 'stream_messages')
456
+ const body = {
457
+ content_type: opts.contentType || 'text',
458
+ content_raw: stringValue(content), // 流式 replace 模式每片是全量前缀,不截断
459
+ input_mode: opts.inputMode || 'replace',
460
+ input_state: opts.inputState, // 1=生成中, 10=生成结束
461
+ index: opts.index, // 分片序号,从0递增
462
+ }
463
+ // 只添加有值的可选字段,避免 undefined 被序列化
464
+ if (opts.streamMsgId !== undefined) body.stream_msg_id = opts.streamMsgId
465
+ if (opts.msgId !== undefined) body.msg_id = opts.msgId
466
+ if (opts.eventId !== undefined) body.event_id = opts.eventId
467
+ if (opts.msgSeq !== undefined) body.msg_seq = opts.msgSeq
468
+ return this.api(endpoint, { method: 'POST', body })
469
+ }
470
+
471
+ async respondInteraction(interactionId, response) {
472
+ return this.api(`/interactions/${encodeURIComponent(interactionId)}`, {
473
+ method: 'PUT',
474
+ body: response,
475
+ })
476
+ }
477
+
478
+ async sendTyping(peerId, opts = {}) {
479
+ // QQ Bot API v2 使用 msg_type: 6 发送输入状态通知
480
+ // 参考:https://bot.q.qq.com/wiki/develop/api-v2/autogen/api/v2_users_user_openid_messages.post.html
481
+ const endpoint = this.endpoint(peerId, opts.scope)
482
+ const body = {
483
+ msg_type: 6,
484
+ input_notify: {
485
+ input_type: 1,
486
+ input_second: Math.min(opts.durationSeconds || 5, 60), // 最长60秒
487
+ },
488
+ }
489
+ // 只添加有值的可选字段
490
+ if (opts.msgId !== undefined) body.msg_id = opts.msgId
491
+ if (opts.eventId !== undefined) body.event_id = opts.eventId
492
+ if (opts.msgSeq !== undefined) body.msg_seq = opts.msgSeq
493
+ return this.api(endpoint, { method: 'POST', body })
494
+ }
495
+
496
+ /**
497
+ * 撤回机器人发送的消息(发送超过 2 分钟不可撤回)。
498
+ * 参考:https://bot.q.qq.com/wiki/develop/api-v2/autogen/api/v2_users_user_openid_messages_message_id.delete.html
499
+ * @param {string} peerId 用户/群 OpenID(u_xxx / g_xxx)
500
+ * @param {string} messageId 要撤回的消息 ID
501
+ * @param {object} [opts] { scope }
502
+ */
503
+ async withdrawMessage(peerId, messageId, opts = {}) {
504
+ const endpoint = `${this.endpoint(peerId, opts.scope)}/${encodeURIComponent(messageId)}`
505
+ return this.api(endpoint, { method: 'DELETE' })
506
+ }
507
+
508
+ // ---- 自定义菜单(单聊底部菜单,全局生效)----
509
+ // 参考:https://bot.q.qq.com/wiki/develop/api-v2/autogen/api/v2_menu.get.html
510
+ // https://bot.q.qq.com/wiki/develop/api-v2/autogen/api/v2_menu.put.html
511
+
512
+ /** 查询全局自定义菜单 */
513
+ async getMenu() {
514
+ return this.api('/v2/menu', { method: 'GET' })
515
+ }
516
+
517
+ /**
518
+ * 修改全局自定义菜单(覆盖式)
519
+ * @param {Array} items 菜单项,最多 10 个
520
+ * { name, type: 'switch'|'send_message'|'link'|'menu', sub_menu_items?, send_message?, link?, switch? }
521
+ */
522
+ async setMenu(items) {
523
+ return this.api('/v2/menu', {
524
+ method: 'PUT',
525
+ body: { menu: { items } },
526
+ })
527
+ }
528
+
529
+ // ---- 指令面板(c2c/group/channel/dm 场景)----
530
+ // 参考:https://bot.q.qq.com/wiki/develop/api-v2/server-inter/menu-panel/
531
+ // https://bot.q.qq.com/wiki/develop/api-v2/autogen/api/v2_panels.post.html
532
+
533
+ /** 查询指令面板列表(按场景筛选) */
534
+ async listPanels(scope) {
535
+ const query = scope ? `?scope=${encodeURIComponent(scope)}` : ''
536
+ return this.api(`/v2/panels${query}`, { method: 'GET' })
537
+ }
538
+
539
+ /**
540
+ * 创建指令面板
541
+ * @param {object} body { scope, target_type?, user_openids?, group_openids?, panel: { items, remark?, version? } }
542
+ * @returns {Promise<{panel_id: string}>}
543
+ */
544
+ async createPanel(body) {
545
+ return this.api('/v2/panels', { method: 'POST', body })
546
+ }
547
+
548
+ /** 查询指令面板详情 */
549
+ async getPanel(panelId) {
550
+ return this.api(`/v2/panels/${encodeURIComponent(panelId)}`, { method: 'GET' })
551
+ }
552
+
553
+ /** 修改指令面板 */
554
+ async updatePanel(panelId, body) {
555
+ return this.api(`/v2/panels/${encodeURIComponent(panelId)}`, { method: 'PUT', body })
556
+ }
557
+
558
+ /** 删除指令面板 */
559
+ async deletePanel(panelId) {
560
+ return this.api(`/v2/panels/${encodeURIComponent(panelId)}`, { method: 'DELETE' })
561
+ }
562
+
563
+ /** 修改指令面板关联对象(增删指定用户/群) */
564
+ async updatePanelTarget(panelId, body) {
565
+ return this.api(`/v2/panels/${encodeURIComponent(panelId)}/target`, { method: 'PUT', body })
566
+ }
567
+
568
+ /**
569
+ * 上传并发送本地媒体文件(图片等)
570
+ */
571
+ async sendMediaFile(peerId, filePath, opts = {}) {
572
+ if (!fs.existsSync(filePath)) return null
573
+ const ext = path.extname(filePath).toLowerCase()
574
+ const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(ext)
575
+ const isVideo = ['.mp4', '.mov', '.webm', '.avi'].includes(ext)
576
+ const isVoice = ['.silk', '.amr', '.wav', '.mp3'].includes(ext)
577
+ let fileType = 4 // 默认文件类型(txt, pdf, zip, docx, etc.)
578
+ if (isImage) fileType = 1
579
+ else if (isVideo) fileType = 2
580
+ else if (isVoice) fileType = 3
581
+
582
+ const scope = opts.scope || (peerId.startsWith('g_') || peerId.startsWith('group_') ? 'group' : 'user')
583
+ try {
584
+ const fileName = path.basename(filePath)
585
+ const buf = await fs.promises.readFile(filePath)
586
+ const base64Data = buf.toString('base64')
587
+ const ep = this.endpoint(peerId, scope, 'files')
588
+ const body = {
589
+ file_type: fileType,
590
+ file_data: base64Data,
591
+ file_name: fileName,
592
+ srv_send_msg: true,
593
+ }
594
+ if (opts.msgId !== undefined) body.msg_id = opts.msgId
595
+ if (opts.eventId !== undefined) body.event_id = opts.eventId
596
+ if (opts.msgSeq !== undefined) body.msg_seq = opts.msgSeq
597
+ const res = await this.api(ep, {
598
+ method: 'POST',
599
+ body,
600
+ })
601
+ return res
602
+ } catch (err) {
603
+ this.logger?.warn?.('[dsh-bridge qq] sendMediaFile error: %s', err?.message ?? err)
604
+ return null
605
+ }
606
+ }
607
+
608
+ setCredentials(values = {}) {
609
+ for (const key of ['appId', 'clientSecret', 'accessToken', 'accessTokenExpiresAt', 'gatewayUrl', 'intents']) {
610
+ if (values[key] !== undefined) this.config[key] = values[key]
611
+ }
612
+ if (values.accountId !== undefined) this.accountId = values.accountId
613
+ }
614
+ }
615
+
616
+ export const gatewayConstants = { API_BASE, TOKEN_URL, DEFAULT_GATEWAY, MAX_MESSAGE_CHARS, QQ_INTENTS }