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.
- package/CHANGELOG.md +250 -0
- package/LICENSE +21 -0
- package/README.en.md +408 -0
- package/README.md +430 -0
- package/client/build.mjs +43 -0
- package/client/client.js +5209 -0
- package/client/index.js +4967 -0
- package/cordis.patch.yml +4 -0
- package/docs/banner.jpg +0 -0
- package/docs/custom-tunnel.md +220 -0
- package/docs/feishu-usage.md +116 -0
- package/docs/fix-tunnel-sse-and-session-list.md +134 -0
- package/docs/platform-abstraction-design.md +473 -0
- package/docs/qq-usage.md +387 -0
- package/docs/screenshots/admin-lock-screen.jpg +0 -0
- package/docs/screenshots/feishu-bot-config.jpg +0 -0
- package/docs/screenshots/feishu-chat.jpg +0 -0
- package/docs/screenshots/lan-access.jpg +0 -0
- package/docs/screenshots/mobile-chat.jpg +0 -0
- package/docs/screenshots/mobile-drawer.jpg +0 -0
- package/docs/screenshots/mobile-remote-settings.jpg +0 -0
- package/docs/screenshots/mobile-settings-im.jpg +0 -0
- package/docs/screenshots/mobile-settings-lan.jpg +0 -0
- package/docs/screenshots/mobile-settings-security.jpg +0 -0
- package/docs/screenshots/mobile-settings-tunnel.jpg +0 -0
- package/docs/screenshots/mobile-workspace-picker.jpg +0 -0
- package/docs/screenshots/qq-bot-config.jpg +0 -0
- package/docs/screenshots/qq-chat.jpg +0 -0
- package/docs/screenshots/qq-group.jpg +0 -0
- package/docs/screenshots/qr-scan.jpg +0 -0
- package/docs/screenshots/remote-auth-login.jpg +0 -0
- package/docs/screenshots/remote-web-mobile.jpg +0 -0
- package/docs/screenshots/security-auth-config.jpg +0 -0
- package/docs/screenshots/telegram-bot-config.jpg +0 -0
- package/docs/screenshots/tunnel-access.jpg +0 -0
- package/docs/screenshots/wechat-bot-config.jpg +0 -0
- package/docs/screenshots/wechat-chat.jpg +0 -0
- package/docs/telegram-usage.md +93 -0
- package/docs/wechat-usage.md +104 -0
- package/lib/auth/login-template.js +382 -0
- package/lib/auth/manager.js +470 -0
- package/lib/bridge-rpc-constants.js +55 -0
- package/lib/bridge-rpc.js +553 -0
- package/lib/cloudflared-manager.mjs +345 -0
- package/lib/feishu/gateway.js +550 -0
- package/lib/feishu/index.js +222 -0
- package/lib/feishu/lark-bundled.mjs +125547 -0
- package/lib/feishu/node.js +409 -0
- package/lib/gateway/cert.js +214 -0
- package/lib/index.js +1963 -0
- package/lib/platform/base.js +156 -0
- package/lib/platform/conversation-bridge.js +1570 -0
- package/lib/platform/index.js +10 -0
- package/lib/platform/manager.js +74 -0
- package/lib/qq/gateway.js +616 -0
- package/lib/qq/index.js +309 -0
- package/lib/qq/node.js +533 -0
- package/lib/security/path-validator.js +208 -0
- package/lib/security/rate-limiter.js +93 -0
- package/lib/telegram/gateway.js +630 -0
- package/lib/telegram/index.js +213 -0
- package/lib/telegram/node.js +351 -0
- package/lib/tunnel-client.mjs +402 -0
- package/lib/wechat/gateway.js +960 -0
- package/lib/wechat/index.js +241 -0
- package/lib/wechat/media.js +281 -0
- package/lib/wechat/node.js +350 -0
- 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 }
|