@qzsy/dsh-bridge 0.0.0-stage → 2.18.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 +1107 -0
- package/LICENSE +21 -0
- package/README.en.md +488 -0
- package/README.md +509 -2
- package/client/build.mjs +43 -0
- package/client/client.js +9864 -0
- package/client/host-families.js +442 -0
- package/client/index.js +6883 -0
- package/client/locales.mjs +242 -0
- package/client/mobile-styles.js +1299 -0
- package/client/mobile-yield.js +219 -0
- package/client/panel-locales.mjs +777 -0
- package/client/picker-yield.js +77 -0
- package/client/resource-url-compat.js +90 -0
- package/client/settings-nav-icon.js +105 -0
- package/client/theme-tokens.js +49 -0
- package/client/tunnel-guard.js +46 -0
- package/client/unlock-manager.js +144 -0
- package/cordis.patch.yml +4 -0
- package/lib/auth/dsh-native-cookie.js +148 -0
- package/lib/auth/login-template.js +516 -0
- package/lib/auth/manager.js +596 -0
- package/lib/bridge-rpc-constants.js +66 -0
- package/lib/bridge-rpc.js +722 -0
- package/lib/cloudflared-manager.mjs +1277 -0
- package/lib/cloudflared-network-args.js +99 -0
- package/lib/cloudflared-process-scan.js +153 -0
- package/lib/compat.js +172 -0
- package/lib/connection-compat.js +115 -0
- package/lib/feishu/comment.js +50 -0
- package/lib/feishu/gateway.js +644 -0
- package/lib/feishu/index.js +432 -0
- package/lib/feishu/lark-bundled.mjs +131929 -0
- package/lib/feishu/markdown.js +148 -0
- package/lib/feishu/node.js +1152 -0
- package/lib/feishu/slash-commands.js +255 -0
- package/lib/index.js +3554 -0
- package/lib/platform/account-ops.js +248 -0
- package/lib/platform/activity.js +169 -0
- package/lib/platform/ask-user.js +293 -0
- package/lib/platform/base.js +147 -0
- package/lib/platform/changes-digest.js +93 -0
- package/lib/platform/commands.js +267 -0
- package/lib/platform/config-restore.js +78 -0
- package/lib/platform/conversation-bridge.js +1544 -0
- package/lib/platform/dsh-storage.js +222 -0
- package/lib/platform/index.js +10 -0
- package/lib/platform/instances.js +458 -0
- package/lib/platform/manager.js +130 -0
- package/lib/platform/message-split.js +229 -0
- package/lib/platform/session-catalog.js +405 -0
- package/lib/platform/session-config.js +52 -0
- package/lib/platform/stream-slices.js +21 -0
- package/lib/qq/gateway.js +1107 -0
- package/lib/qq/index.js +561 -0
- package/lib/qq/node.js +1153 -0
- package/lib/qq/qqbot-bundled.mjs +8273 -0
- package/lib/restart-helper.mjs +133 -0
- package/lib/saas/device-auth.mjs +366 -0
- package/lib/saas/tunnel-client.mjs +695 -0
- package/lib/security/log-sanitize.js +50 -0
- package/lib/security/path-validator.js +195 -0
- package/lib/security/rate-limiter.js +93 -0
- package/lib/session-strip.js +100 -0
- package/lib/sse-keepalive.js +115 -0
- package/lib/telegram/gateway.js +643 -0
- package/lib/telegram/index.js +255 -0
- package/lib/telegram/node.js +410 -0
- package/lib/tunnel-client.mjs +537 -0
- package/lib/wechat/gateway.js +1000 -0
- package/lib/wechat/index.js +282 -0
- package/lib/wechat/media.js +302 -0
- package/lib/wechat/node.js +360 -0
- package/llms.txt +36 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +135 -4
|
@@ -0,0 +1,1107 @@
|
|
|
1
|
+
// dsh-bridge QQ 网关(官方 @tencent-connect/qqbot-nodejs 适配层)
|
|
2
|
+
//
|
|
3
|
+
// QQ 侧的协议细节全部交给腾讯官方 SDK 实现(与本仓飞书走官方 Channel SDK 的做法一致):
|
|
4
|
+
// - 连接:WebSocket 长连接、HELLO/IDENTIFY/RESUME/心跳、按关闭码分类重连、退避策略
|
|
5
|
+
// - 鉴权:AppID + AppSecret 换 access_token(缓存 + 提前刷新 + 失效重取)
|
|
6
|
+
// - 入站:事件归一化(C2C / 群 / 频道 / 私信 / 互动)、@提及标记清洗、引用解析、
|
|
7
|
+
// 去重、限流、按会话串行、错误兜底(Koa 式中间件链,全部用 SDK 内置实现)
|
|
8
|
+
// 注意:SDK 的高层 facade 不给频道(guild)/频道私信(dm)派生 replyTarget,因此
|
|
9
|
+
// `bot.on('message')` 只覆盖单聊与群聊(本插件的能力范围);频道场景需要时
|
|
10
|
+
// 可走 `bot.messageApi.sendChannelMessage`(见 SDK USAGE.md §15.2)。
|
|
11
|
+
// - 出站:文本 / Markdown / 按钮键盘 / Ark / Embed / 富媒体(含 ≥5MB 自动分片上传)/
|
|
12
|
+
// 输入状态 / 被动回复 msg_id / 互动事件 event_id / 撤回
|
|
13
|
+
// - 流式:C2C `stream_messages`(replace 语义 + 节流 + msg_seq/index 序列)
|
|
14
|
+
//
|
|
15
|
+
// 本文件只做「会话桥 ↔ SDK」之间的映射,不自行实现任何协议逻辑。
|
|
16
|
+
// 官方 SDK 由 scripts/bundle-qq.mjs 预打包为 lib/qq/qqbot-bundled.mjs(发布物不含 node_modules)。
|
|
17
|
+
//
|
|
18
|
+
// 平台文档:https://bot.q.qq.com/wiki/develop/api-v2/
|
|
19
|
+
// SDK 文档:https://github.com/tencent-connect/qqbot-nodejs
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs'
|
|
22
|
+
import path from 'node:path'
|
|
23
|
+
import { Service } from '@deepseek-ai/cordis'
|
|
24
|
+
import {
|
|
25
|
+
QQBot,
|
|
26
|
+
MsgType,
|
|
27
|
+
MediaFileType,
|
|
28
|
+
ApiError,
|
|
29
|
+
GatewayErrorCode,
|
|
30
|
+
errorHandler,
|
|
31
|
+
messageFilter,
|
|
32
|
+
contentSanitizer,
|
|
33
|
+
rateLimiter,
|
|
34
|
+
concurrencyGuard,
|
|
35
|
+
mentionGate,
|
|
36
|
+
quoteRef,
|
|
37
|
+
historyBuffer,
|
|
38
|
+
typingIndicator,
|
|
39
|
+
FileKVStore,
|
|
40
|
+
kvSessionPersistence,
|
|
41
|
+
ReplyLimiter,
|
|
42
|
+
} from './qqbot-bundled.mjs'
|
|
43
|
+
|
|
44
|
+
/** 单条消息文本上限(QQ 平台限制 ~5000 字符,插件默认按 2000 分块以适配手机端阅读) */
|
|
45
|
+
const MAX_MESSAGE_CHARS = 2000
|
|
46
|
+
|
|
47
|
+
/** 流式消息节流(平台建议 ≥300ms;SDK 内部会把更小的值抬到 300) */
|
|
48
|
+
const STREAM_THROTTLE_MS = 500
|
|
49
|
+
|
|
50
|
+
/** 被动回复限额:单聊每条消息最多 4 次、群聊 5 次(官方 FAQ),按 scope 分别取值 */
|
|
51
|
+
const PASSIVE_REPLY_LIMIT_C2C = 4
|
|
52
|
+
const PASSIVE_REPLY_LIMIT_GROUP = 5
|
|
53
|
+
|
|
54
|
+
/** 断流标记的存活时间(一轮对话足够长;到点自动清理,避免长跑内存增长) */
|
|
55
|
+
const STREAM_BROKEN_TTL_MS = 10 * 60_000
|
|
56
|
+
|
|
57
|
+
/** stop() 等待 SDK start() 收尾的上限:超时后继续收尾,不无限阻塞调用方 */
|
|
58
|
+
const STOP_WAIT_TIMEOUT_MS = 5_000
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 给 Promise 加超时(超时 resolve 而非 reject,调用方自己决定语义)。
|
|
62
|
+
* 定时器 unref,避免阻止进程退出。
|
|
63
|
+
* @template T
|
|
64
|
+
* @param {Promise<T>} promise
|
|
65
|
+
* @param {number} ms
|
|
66
|
+
* @returns {Promise<void>}
|
|
67
|
+
*/
|
|
68
|
+
function withTimeout(promise, ms) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const timer = setTimeout(() => resolve(), ms)
|
|
71
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
72
|
+
Promise.resolve(promise).then(
|
|
73
|
+
() => { clearTimeout(timer); resolve() },
|
|
74
|
+
(err) => { clearTimeout(timer); reject(err) },
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 媒体类型按扩展名映射(SDK 要求显式指定 fileType) */
|
|
80
|
+
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'])
|
|
81
|
+
const VIDEO_EXTS = new Set(['.mp4', '.mov', '.webm', '.avi', '.mkv'])
|
|
82
|
+
const VOICE_EXTS = new Set(['.silk', '.amr', '.wav', '.mp3'])
|
|
83
|
+
|
|
84
|
+
/** 连接状态值(供 UI 与桥的离线守卫使用) */
|
|
85
|
+
const STATUS = {
|
|
86
|
+
IDLE: 'idle',
|
|
87
|
+
STARTING: 'starting',
|
|
88
|
+
CONNECTED: 'connected',
|
|
89
|
+
RECONNECTING: 'reconnecting',
|
|
90
|
+
ERROR: 'error',
|
|
91
|
+
OFFLINE: 'offline',
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* SDK GatewayError 的终态错误码。
|
|
96
|
+
*
|
|
97
|
+
* 两者语义不同,处理也要区分:
|
|
98
|
+
* - `FATAL_CLOSE`(4914/4915):机器人被下线/封禁,**需人工处理**,重连必然再失败 → error(保留原因);
|
|
99
|
+
* - `RETRY_EXHAUSTED`:仅是本插件给 SDK 设了重连上限(maxAttempts: 10),
|
|
100
|
+
* 网络恢复后重连即可 → error(可重启)。
|
|
101
|
+
*/
|
|
102
|
+
const FATAL_CLOSE_CODE = GatewayErrorCode?.FATAL_CLOSE
|
|
103
|
+
const RETRY_EXHAUSTED_CODE = GatewayErrorCode?.RETRY_EXHAUSTED
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 按扩展名推断 QQ 媒体类型。
|
|
107
|
+
* @param {string} filePath
|
|
108
|
+
* @returns {number} MediaFileType
|
|
109
|
+
*/
|
|
110
|
+
export function mediaFileTypeOf(filePath) {
|
|
111
|
+
const ext = path.extname(String(filePath || '')).toLowerCase()
|
|
112
|
+
if (IMAGE_EXTS.has(ext)) return MediaFileType.IMAGE
|
|
113
|
+
if (VIDEO_EXTS.has(ext)) return MediaFileType.VIDEO
|
|
114
|
+
if (VOICE_EXTS.has(ext)) return MediaFileType.VOICE
|
|
115
|
+
return MediaFileType.FILE
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 把 SDK 的 Logger 接口适配到 cordis 日志器(info/error 必填,warn/debug 可选)。 */
|
|
119
|
+
function makeSdkLogger(logger) {
|
|
120
|
+
const call = (level) => (msg, meta) => {
|
|
121
|
+
const fn = logger?.[level] ?? logger?.info
|
|
122
|
+
if (typeof fn !== 'function') return
|
|
123
|
+
try {
|
|
124
|
+
if (meta === undefined) fn.call(logger, '[qq] %s', msg)
|
|
125
|
+
else fn.call(logger, '[qq] %s %o', msg, meta)
|
|
126
|
+
} catch { /* 日志失败不致命 */ }
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
info: call('info'),
|
|
130
|
+
error: call('error'),
|
|
131
|
+
warn: call('warn'),
|
|
132
|
+
debug: call('debug'),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* QQ 网关:持有官方 SDK 的 QQBot 实例,向会话桥暴露 sendText/sendMedia/typing/流式能力,
|
|
138
|
+
* 并把 SDK 事件归一化为 cordis 事件(`qq/message` / `qq/action` / `qq/status`)。
|
|
139
|
+
*
|
|
140
|
+
* 作为 cordis Service 注册(服务名按实例唯一,支持同平台多账号)。
|
|
141
|
+
*/
|
|
142
|
+
export class QqGateway extends Service {
|
|
143
|
+
/**
|
|
144
|
+
* @param {object} opts
|
|
145
|
+
* @param {object} opts.ctx Cordis 上下文
|
|
146
|
+
* @param {object} opts.logger 插件日志器
|
|
147
|
+
* @param {object} [opts.config] 凭证与运行参数(appId/appSecret/...)
|
|
148
|
+
* @param {(patch: object) => (void|Promise<void>)} [opts.onPersist] 持久化回调
|
|
149
|
+
* @param {string} [opts.instanceId] 账号实例标识(事件隔离)
|
|
150
|
+
* @param {string} [opts.serviceName] 唯一 cordis 服务名
|
|
151
|
+
* @param {string} [opts.dataDir] SDK 会话持久化目录(RESUME 跨重启)
|
|
152
|
+
*/
|
|
153
|
+
constructor({ ctx, logger, config = {}, onPersist, instanceId, serviceName, dataDir } = {}) {
|
|
154
|
+
const resolvedInstanceId = instanceId || 'qq'
|
|
155
|
+
super(ctx, serviceName || config.serviceName || resolvedInstanceId)
|
|
156
|
+
this.ctx = ctx
|
|
157
|
+
this.instanceId = resolvedInstanceId
|
|
158
|
+
this.logger = logger
|
|
159
|
+
this.onPersist = onPersist || (() => {})
|
|
160
|
+
this.config = {
|
|
161
|
+
appId: String(config.appId || ''),
|
|
162
|
+
appSecret: String(config.appSecret || ''),
|
|
163
|
+
// 是否拥有 Markdown 权限:未审核的机器人发 msg_type=2 会被拒,需退回纯文本
|
|
164
|
+
markdownSupport: config.markdownSupport !== false,
|
|
165
|
+
// 群聊是否需要 @机器人(官方 GROUP_AT_MESSAGE_CREATE 已保证,仅在开启全量群消息时生效)
|
|
166
|
+
requireMentionInGroup: config.requireMentionInGroup !== false,
|
|
167
|
+
// 数据目录(SDK 会话持久化)
|
|
168
|
+
dataDir: String(dataDir || config.dataDir || ''),
|
|
169
|
+
// 单条消息上限
|
|
170
|
+
maxMessageChars: Number(config.maxMessageChars) >= 200 ? Number(config.maxMessageChars) : MAX_MESSAGE_CHARS,
|
|
171
|
+
// 流式节流
|
|
172
|
+
streamThrottleMs: Number(config.streamThrottleMs) > 0 ? Number(config.streamThrottleMs) : STREAM_THROTTLE_MS,
|
|
173
|
+
// 自定义 API 基址(仅测试/私有部署)
|
|
174
|
+
baseUrl: String(config.baseUrl || ''),
|
|
175
|
+
tokenBaseUrl: String(config.tokenBaseUrl || ''),
|
|
176
|
+
// 自定义 intents(不传则用 SDK 的 FULL_INTENTS)
|
|
177
|
+
intents: Number.isFinite(Number(config.intents)) ? Number(config.intents) : undefined,
|
|
178
|
+
// 同一条入站消息允许的被动回复次数上限(单聊 / 群聊分别按官方上限)
|
|
179
|
+
passiveReplyLimit: Number(config.passiveReplyLimit) > 0 ? Number(config.passiveReplyLimit) : PASSIVE_REPLY_LIMIT_C2C,
|
|
180
|
+
passiveReplyLimitGroup: Number(config.passiveReplyLimitGroup) > 0 ? Number(config.passiveReplyLimitGroup) : PASSIVE_REPLY_LIMIT_GROUP,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this.statusValue = STATUS.IDLE
|
|
184
|
+
this.error = null
|
|
185
|
+
/** @type {import('./qqbot-bundled.mjs').QQBot|null} */
|
|
186
|
+
this.bot = null
|
|
187
|
+
this.botInfo = null
|
|
188
|
+
/** @type {import('./qqbot-bundled.mjs').FileKVStore|null} */
|
|
189
|
+
this._sessionStore = null
|
|
190
|
+
/** 被动回复限额(每条入站消息最多回复 N 次,超限降级主动消息;单聊/群聊上限不同) */
|
|
191
|
+
this._replyLimiters = {
|
|
192
|
+
c2c: new ReplyLimiter({ limit: this.config.passiveReplyLimit }),
|
|
193
|
+
group: new ReplyLimiter({ limit: this.config.passiveReplyLimitGroup }),
|
|
194
|
+
}
|
|
195
|
+
/** 本轮流式已放弃的标记(P0-5):`${scope}:${peerId}` */
|
|
196
|
+
this._streamBroken = new Set()
|
|
197
|
+
/** 已被平台判定过期的被动回复锚点(msg_id/event_id),不再复用(P0-7) */
|
|
198
|
+
this._expiredReplyIds = new Set()
|
|
199
|
+
/** 最近的被动回复候选(msg_id / event_id),按 `${scope}:${targetId}` 归档 */
|
|
200
|
+
this._replyIds = new Map()
|
|
201
|
+
this._stopRequested = false
|
|
202
|
+
this._startingPromise = null
|
|
203
|
+
/** 启动代际:用数字标记"当前是谁的启动流程",避免比较 promise 对象(js/missing-await) */
|
|
204
|
+
this._startGeneration = 0
|
|
205
|
+
this._runPromise = null
|
|
206
|
+
|
|
207
|
+
/** @type {Map<string, { stream: object, text: string }>} 进行中的流式会话 */
|
|
208
|
+
this._streams = new Map()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---- 只读属性 ----
|
|
212
|
+
|
|
213
|
+
get status() { return this.statusValue }
|
|
214
|
+
get configured() { return Boolean(this.config.appId && this.config.appSecret) }
|
|
215
|
+
get accountId() { return this.botInfo?.id || this.botInfo?.username || '' }
|
|
216
|
+
|
|
217
|
+
get capabilities() {
|
|
218
|
+
return {
|
|
219
|
+
supportsGroup: true,
|
|
220
|
+
supportsMedia: true,
|
|
221
|
+
supportsVoice: true,
|
|
222
|
+
supportsTyping: true,
|
|
223
|
+
supportsStreaming: true,
|
|
224
|
+
supportsButtons: true,
|
|
225
|
+
supportsQuote: true,
|
|
226
|
+
maxMessageChars: this.config.maxMessageChars,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
setStatus(status, error = null) {
|
|
231
|
+
this.statusValue = status
|
|
232
|
+
this.error = error ? String(error?.message || error) : null
|
|
233
|
+
try {
|
|
234
|
+
this.ctx.emit?.('qq/status', { instanceId: this.instanceId, status, error: this.error })
|
|
235
|
+
} catch { /* 事件订阅失败不致命 */ }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---- 配置 ----
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 更新凭证/配置。返回值表示凭证是否发生变化(调用方可据此决定是否重连)。
|
|
242
|
+
* @param {object} values
|
|
243
|
+
*/
|
|
244
|
+
setCredentials(values = {}) {
|
|
245
|
+
const before = { appId: this.config.appId, appSecret: this.config.appSecret }
|
|
246
|
+
if (values.appId !== undefined) this.config.appId = String(values.appId ?? '')
|
|
247
|
+
if (values.appSecret !== undefined) this.config.appSecret = String(values.appSecret ?? '')
|
|
248
|
+
if (values.clientSecret !== undefined) this.config.appSecret = String(values.clientSecret ?? '')
|
|
249
|
+
if (values.markdownSupport !== undefined) this.config.markdownSupport = values.markdownSupport !== false
|
|
250
|
+
if (values.requireMentionInGroup !== undefined) {
|
|
251
|
+
this.config.requireMentionInGroup = values.requireMentionInGroup !== false
|
|
252
|
+
}
|
|
253
|
+
if (values.maxMessageChars !== undefined) {
|
|
254
|
+
const val = Number(values.maxMessageChars)
|
|
255
|
+
if (val >= 200) this.config.maxMessageChars = val
|
|
256
|
+
}
|
|
257
|
+
if (values.baseUrl !== undefined) this.config.baseUrl = String(values.baseUrl ?? '')
|
|
258
|
+
return this.config.appId !== before.appId || this.config.appSecret !== before.appSecret
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 凭证是否与给定值一致(避免无谓重连)。 */
|
|
262
|
+
credentialsMatch({ appId, appSecret, clientSecret } = {}) {
|
|
263
|
+
const secret = appSecret !== undefined ? appSecret : clientSecret
|
|
264
|
+
if (appId !== undefined && String(appId ?? '') !== this.config.appId) return false
|
|
265
|
+
if (secret !== undefined && String(secret ?? '') !== this.config.appSecret) return false
|
|
266
|
+
return true
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---- 生命周期 ----
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 启动长连接(幂等:重复调用复用同一次启动流程)。
|
|
273
|
+
*
|
|
274
|
+
* 竞态防护(P0-1):`_stopRequested` 必须在 `await _teardownBot()` **之后**再判一次。
|
|
275
|
+
* 否则用户在"启动中"窗口点断开时,stop() 置位的标志会被忽略,start() 继续建连,
|
|
276
|
+
* 最终 status=connected 但 `_stopRequested=true` —— 此后所有入站消息被
|
|
277
|
+
* `_bindBotEvents` 首行的守卫**静默丢弃**,且因 connected 短路再也无法自愈。
|
|
278
|
+
*
|
|
279
|
+
* 凭证变更(P0-4):已连接时不能直接短路返回 true——必须比较运行中 bot 的凭证,
|
|
280
|
+
* 不一致就重建连接,否则"保存并连接"会谎报成功(旧连接照跑,新凭证永不生效)。
|
|
281
|
+
*/
|
|
282
|
+
async start() {
|
|
283
|
+
if (!this.configured) {
|
|
284
|
+
this.setStatus(STATUS.IDLE)
|
|
285
|
+
return false
|
|
286
|
+
}
|
|
287
|
+
// 已在建连流程中:复用同一次启动(不要并发开第二条连接)
|
|
288
|
+
if (this._startingPromise) return this._startingPromise
|
|
289
|
+
// 已连接:仅当凭证未变才视为"无需动作",否则落到下面重建
|
|
290
|
+
if (this.statusValue === STATUS.CONNECTED && !this._credentialsStale()) return true
|
|
291
|
+
|
|
292
|
+
this._stopRequested = false
|
|
293
|
+
this.setStatus(STATUS.STARTING)
|
|
294
|
+
|
|
295
|
+
const starting = (async () => {
|
|
296
|
+
// 旧实例(断线重连 / 凭证变更)先释放,避免双连接分流事件
|
|
297
|
+
await this._teardownBot()
|
|
298
|
+
// 释放期间用户可能点了「断开」:此时不得继续建连,也不能把 stopRequested 留在 true
|
|
299
|
+
if (this._stopRequested) {
|
|
300
|
+
this.setStatus(STATUS.IDLE)
|
|
301
|
+
return false
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
const bot = this._createBot()
|
|
305
|
+
this.bot = bot
|
|
306
|
+
this._bindBotEvents(bot)
|
|
307
|
+
// 有意不 await:SDK 的 start() 会一直 resolve 到连接结束(长连接生命周期),
|
|
308
|
+
// 这里只需挂上错误处理并保存 run 句柄,start()(本方法)应立即返回。
|
|
309
|
+
// 显式 void 标注意图,避免被误读为漏写 await(js/missing-await)。
|
|
310
|
+
this._runPromise = void bot.start().catch((err) => this._onStartRejected(err))
|
|
311
|
+
return true
|
|
312
|
+
} catch (err) {
|
|
313
|
+
this.logger?.error?.('[dsh-bridge qq] 启动失败:%s', err?.message ?? err)
|
|
314
|
+
this.setStatus(STATUS.ERROR, err)
|
|
315
|
+
return false
|
|
316
|
+
}
|
|
317
|
+
})()
|
|
318
|
+
|
|
319
|
+
// 用局部标记代替"比较 promise 本身":CodeQL 的 js/missing-await 会把
|
|
320
|
+
// `this._startingPromise === starting`(拿 promise 做值比较)判为漏 await。
|
|
321
|
+
const generation = ++this._startGeneration
|
|
322
|
+
this._startingPromise = starting
|
|
323
|
+
try {
|
|
324
|
+
const result = await starting
|
|
325
|
+
return result
|
|
326
|
+
} finally {
|
|
327
|
+
// 仅当仍是自己这一代时才清句柄(避免清掉后来者的)
|
|
328
|
+
if (this._startGeneration === generation) this._startingPromise = null
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 运行中的 bot 凭证是否已与当前 config 不一致(P0-4)。
|
|
334
|
+
*
|
|
335
|
+
* 真实 SDK 的 QQBot 提供 `getCredentials()`;若替身/未来 transport 没有该方法,
|
|
336
|
+
* 这里**宁可重建**(返回 true)也不静默跳过——凭证变更后不重建会沿用旧连接,
|
|
337
|
+
* 用户看到"已连接"却始终连的是旧账号,比多一次重连昂贵得多。
|
|
338
|
+
*/
|
|
339
|
+
_credentialsStale() {
|
|
340
|
+
const bot = this.bot
|
|
341
|
+
if (!bot) return true
|
|
342
|
+
if (typeof bot.getCredentials !== 'function') return true
|
|
343
|
+
try {
|
|
344
|
+
const live = bot.getCredentials()
|
|
345
|
+
if (!live) return true
|
|
346
|
+
return String(live.appId ?? '') !== this.config.appId
|
|
347
|
+
|| String(live.clientSecret ?? live.appSecret ?? '') !== this.config.appSecret
|
|
348
|
+
} catch {
|
|
349
|
+
return true
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** 解绑后置 offline:凭证已清除,与 idle(已配置但未启动)语义区分。 */
|
|
354
|
+
markUnbound() {
|
|
355
|
+
this._stopRequested = true
|
|
356
|
+
this.setStatus(STATUS.OFFLINE)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** 停止长连接(保留凭证与配置)。 */
|
|
360
|
+
async stop() {
|
|
361
|
+
this._stopRequested = true
|
|
362
|
+
// 若正处在启动流程中,等它收尾——否则 stop() 返回后那次 start() 仍会把 bot 建起来
|
|
363
|
+
const starting = this._startingPromise
|
|
364
|
+
if (starting) {
|
|
365
|
+
try { await starting } catch { /* 启动失败已记日志 */ }
|
|
366
|
+
}
|
|
367
|
+
await this._teardownBot()
|
|
368
|
+
this.setStatus(STATUS.IDLE)
|
|
369
|
+
return true
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
dispose() {
|
|
373
|
+
void this.stop()
|
|
374
|
+
super.dispose?.()
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** SDK 工厂(测试注入点)。 */
|
|
378
|
+
_createBot() {
|
|
379
|
+
const logger = makeSdkLogger(this.logger)
|
|
380
|
+
const options = {
|
|
381
|
+
appId: this.config.appId,
|
|
382
|
+
appSecret: this.config.appSecret,
|
|
383
|
+
accountId: this.instanceId,
|
|
384
|
+
markdownSupport: this.config.markdownSupport,
|
|
385
|
+
logger,
|
|
386
|
+
transport: 'websocket',
|
|
387
|
+
// 外层由 PlatformManager 管生命周期,这里给重连设上限让失败能及时上抛,
|
|
388
|
+
// 由 start() 的 catch 更新状态(而不是无声地无限重连)。
|
|
389
|
+
reconnect: { maxAttempts: 10 },
|
|
390
|
+
}
|
|
391
|
+
if (this.config.baseUrl) options.baseUrl = this.config.baseUrl
|
|
392
|
+
if (this.config.tokenBaseUrl) options.tokenBaseUrl = this.config.tokenBaseUrl
|
|
393
|
+
if (this.config.intents !== undefined) options.intents = this.config.intents
|
|
394
|
+
const persistence = this._sessionPersistence()
|
|
395
|
+
if (persistence) options.sessionPersistence = persistence
|
|
396
|
+
|
|
397
|
+
const bot = new QQBot(options)
|
|
398
|
+
this._installMiddlewares(bot)
|
|
399
|
+
return bot
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* RESUME 会话持久化:把 sessionId / lastSeq 落到 `$DSH_HOME/dsh-bridge/qq-session-<instance>.json`。
|
|
404
|
+
* 进程重启后可跳过 IDENTIFY 直接 RESUME,缩短恢复时间并减少漏消息。
|
|
405
|
+
*/
|
|
406
|
+
_sessionPersistence() {
|
|
407
|
+
const dir = this.config.dataDir
|
|
408
|
+
if (!dir) return undefined
|
|
409
|
+
try {
|
|
410
|
+
this._sessionStore = new FileKVStore({
|
|
411
|
+
dir,
|
|
412
|
+
fileName: `qq-session-${this.instanceId.replace(/[^\w.-]/g, '_')}.json`,
|
|
413
|
+
})
|
|
414
|
+
return kvSessionPersistence({ store: this._sessionStore, accountId: this.instanceId })
|
|
415
|
+
} catch (err) {
|
|
416
|
+
this.logger?.warn?.('[dsh-bridge qq] 会话持久化初始化失败(忽略):%s', err?.message ?? err)
|
|
417
|
+
return undefined
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* 中间件链:全部使用官方 SDK 内置实现(顺序即洋葱皮顺序)。
|
|
423
|
+
*
|
|
424
|
+
* 访问控制的分工说明——白名单的**最终裁决**仍在会话桥
|
|
425
|
+
* (lib/platform/conversation-bridge.js),因为白名单是插件的持久化配置、且群聊有
|
|
426
|
+
* "首条消息自动授权"语义。这里只做"回声过滤 / 去重 / 内容清洗 / 引用解析 / 限流 / 串行"
|
|
427
|
+
* 这类与业务无关的协议级保护。
|
|
428
|
+
*/
|
|
429
|
+
_installMiddlewares(bot) {
|
|
430
|
+
// 1. 错误兜底(最外层):下游抛错时回一句可读提示,避免用户侧静默无响应
|
|
431
|
+
bot.use(errorHandler())
|
|
432
|
+
// 2. 回声 + 去重:QQ 在群聊里会把机器人自己的消息回推,且可能重复推送同一事件
|
|
433
|
+
bot.use(messageFilter({ skipSelfEcho: true }))
|
|
434
|
+
// 3. 内容清洗:去掉 `<@!appId>` 提及标记与表情标签
|
|
435
|
+
bot.use(contentSanitizer({ stripBotMention: true, parseFaceTags: true }))
|
|
436
|
+
// 4. 限流:防止单个用户/群刷爆(三层滑动窗口)
|
|
437
|
+
bot.use(rateLimiter({
|
|
438
|
+
perSender: { max: 20, windowMs: 60_000 },
|
|
439
|
+
perGroup: { max: 60, windowMs: 60_000 },
|
|
440
|
+
global: { max: 200, windowMs: 60_000 },
|
|
441
|
+
// 注意:这里的 mCtx 是 SDK 的 MiddlewareContext(非 cordis ctx),不访问 cordis 服务
|
|
442
|
+
onLimit: (mCtx, tier) => {
|
|
443
|
+
this.logger?.warn?.('[dsh-bridge qq] 入站限流(%s):sender=%s', tier, mCtx?.message?.senderId ?? '?')
|
|
444
|
+
},
|
|
445
|
+
}))
|
|
446
|
+
// 5. 引用解析:QQ 的引用消息原文只在 msg_elements[0] 里,SDK 负责还原
|
|
447
|
+
bot.use(quoteRef({ maxSize: 500, preferMsgElements: true }))
|
|
448
|
+
// 6. 群历史缓冲:群内未被 @ 的消息也进上下文,让模型知道"刚才聊了什么"
|
|
449
|
+
bot.use(historyBuffer({ limit: 10, recordOnSkip: true }))
|
|
450
|
+
// 7. 群聊 @门控(requireMentionInGroup=false 时放行所有群消息)
|
|
451
|
+
bot.use(mentionGate({
|
|
452
|
+
requireMentionInGroup: this.config.requireMentionInGroup,
|
|
453
|
+
passthrough: !this.config.requireMentionInGroup,
|
|
454
|
+
}))
|
|
455
|
+
// 8. 同会话串行:避免同一用户/群的并发处理(QQ 流式 replace 共享 msg_seq 会冲突)
|
|
456
|
+
bot.use(concurrencyGuard({ strategy: 'queue', maxQueue: 20 }))
|
|
457
|
+
// 9. C2C 自动输入状态(SDK 内部会跳过群聊)
|
|
458
|
+
bot.use(typingIndicator())
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** 绑定 SDK 事件 → cordis 事件。 */
|
|
462
|
+
_bindBotEvents(bot) {
|
|
463
|
+
bot.on('ready', (data) => {
|
|
464
|
+
const user = data?.user ?? data?.d?.user ?? null
|
|
465
|
+
if (user) {
|
|
466
|
+
this.botInfo = { id: String(user.id ?? ''), username: String(user.username ?? '') }
|
|
467
|
+
void this.persist({ accountId: this.botInfo.id })
|
|
468
|
+
}
|
|
469
|
+
this.setStatus(STATUS.CONNECTED)
|
|
470
|
+
this.logger?.info?.('[dsh-bridge qq] 已连接 QQ 开放平台:%s', this.botInfo?.id || this.config.appId)
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
bot.on('resumed', () => {
|
|
474
|
+
this.setStatus(STATUS.CONNECTED)
|
|
475
|
+
this.logger?.info?.('[dsh-bridge qq] 会话已恢复(RESUMED)')
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
bot.on('disconnected', (event) => {
|
|
479
|
+
const willReconnect = event?.willReconnect !== false
|
|
480
|
+
this.logger?.warn?.('[dsh-bridge qq] 连接断开 code=%s reason=%s willReconnect=%s',
|
|
481
|
+
event?.code ?? '?', event?.reason ?? '', willReconnect)
|
|
482
|
+
this.setStatus(willReconnect ? STATUS.RECONNECTING : STATUS.ERROR, willReconnect ? null : event?.reason)
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
bot.on('error', (err) => {
|
|
486
|
+
// 可恢复错误:只记日志,不改变连接状态(SDK 会自行重连)
|
|
487
|
+
this.logger?.warn?.('[dsh-bridge qq] SDK 错误:%s', err?.message ?? err)
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
bot.on('message', async (mCtx, msg) => {
|
|
491
|
+
if (this._stopRequested) return
|
|
492
|
+
await this._handleInbound(mCtx, msg)
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
bot.on('interaction', async (_iCtx, event) => {
|
|
496
|
+
if (this._stopRequested) return
|
|
497
|
+
this._handleInteraction(event)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
bot.on('rawEvent', (rawCtx) => {
|
|
501
|
+
// 平台新增事件无需 SDK 升级即可观测(群成员变动、好友增删、入群申请等)
|
|
502
|
+
this.logger?.debug?.('[dsh-bridge qq] raw event: %s', rawCtx?.eventType ?? '?')
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** start() 被拒(终态错误):区分"需人工处理"与"可重启"。 */
|
|
507
|
+
_onStartRejected(err) {
|
|
508
|
+
if (this._stopRequested) return
|
|
509
|
+
const code = err?.code
|
|
510
|
+
const fatalClose = code === FATAL_CLOSE_CODE
|
|
511
|
+
const retryExhausted = code === RETRY_EXHAUSTED_CODE
|
|
512
|
+
const detail = fatalClose
|
|
513
|
+
? `机器人被下线或封禁(关闭码 ${err?.closeCode ?? '?'}),需到 QQ 开放平台处理`
|
|
514
|
+
: retryExhausted
|
|
515
|
+
? `重连次数已用尽(${err?.message ?? 'max attempts'}),网络恢复后可点「重新连接」`
|
|
516
|
+
: (err?.message ?? String(err))
|
|
517
|
+
this.logger?.error?.('[dsh-bridge qq] 连接终止(%s):%s%s', fatalClose ? '终态' : '可重启', detail,
|
|
518
|
+
fatalClose ? '' : ';可点「重新连接」重试')
|
|
519
|
+
// 都用 error:UI 的 statusLabel 对 'offline' 显示为"未连接"(会丢掉错误原因),
|
|
520
|
+
// 对 'error' 显示"错误"并保留 login.error 文案,用户才能自诊。
|
|
521
|
+
// 区别只在日志文案(终态 / 可重启)与是否提示重连。
|
|
522
|
+
this.setStatus(STATUS.ERROR, detail)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async _teardownBot() {
|
|
526
|
+
const bot = this.bot
|
|
527
|
+
this.bot = null
|
|
528
|
+
this.botInfo = null
|
|
529
|
+
// 关闭所有在途流(避免悬挂)
|
|
530
|
+
for (const entry of this._streams.values()) {
|
|
531
|
+
try { entry.stream?.cancel?.() } catch { /* 忽略 */ }
|
|
532
|
+
}
|
|
533
|
+
this._streams.clear()
|
|
534
|
+
this._streamBroken.clear()
|
|
535
|
+
this._expiredReplyIds.clear()
|
|
536
|
+
// 重建连接后旧 bot 的被动回复锚点必然失效(msg_id 归属于旧连接上下文),必须清空
|
|
537
|
+
this._replyIds.clear()
|
|
538
|
+
if (!bot) {
|
|
539
|
+
this._runPromise = null
|
|
540
|
+
return
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
bot.stop()
|
|
544
|
+
} catch (err) {
|
|
545
|
+
this.logger?.warn?.('[dsh-bridge qq] bot.stop 异常:%s', err?.message ?? err)
|
|
546
|
+
}
|
|
547
|
+
const run = this._runPromise
|
|
548
|
+
this._runPromise = null
|
|
549
|
+
if (run) {
|
|
550
|
+
// 带上限等待:SDK 的 start() 在极端情况(token 请求挂起/自定义 transport 永不 settle)
|
|
551
|
+
// 可能长时间不返回,不能让 stop() 无限阻塞(复核实测可阻塞 9.7s、自定义 transport 可无上限)。
|
|
552
|
+
try { await withTimeout(run, STOP_WAIT_TIMEOUT_MS) } catch { /* 拒绝已由 _onStartRejected 处理;超时也继续收尾 */ }
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async persist(patch) {
|
|
557
|
+
try {
|
|
558
|
+
await this.onPersist(patch)
|
|
559
|
+
} catch (err) {
|
|
560
|
+
this.logger?.warn?.('[dsh-bridge qq] persist failed: %s', err?.message ?? err)
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ---- 入站 ----
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* SDK 入站消息 → cordis `qq/message`(供会话桥消费)。
|
|
568
|
+
*
|
|
569
|
+
* SDK 已完成事件归一化(kind/senderId/groupOpenid/attachments/mentions),
|
|
570
|
+
* 这里只做"业务语义"映射:scope、授权主体、被动回复候选、引用上下文。
|
|
571
|
+
*/
|
|
572
|
+
async _handleInbound(mCtx, msg) {
|
|
573
|
+
const scope = msg.kind === 'group' ? 'group' : 'c2c'
|
|
574
|
+
const isGroup = scope === 'group'
|
|
575
|
+
// 出站目标:群聊用群 openid,单聊用用户 openid
|
|
576
|
+
const peerId = isGroup ? (msg.groupOpenid ?? msg.senderId) : msg.senderId
|
|
577
|
+
if (!peerId) return
|
|
578
|
+
|
|
579
|
+
// 被动回复候选:普通消息记 msg_id(供无上下文的出站使用)
|
|
580
|
+
this._rememberReplyId(scope, peerId, { kind: 'msg', id: msg.messageId })
|
|
581
|
+
|
|
582
|
+
// 引用上下文(SDK quoteRef 中间件解析结果;群里模型要知道"他在回哪句")
|
|
583
|
+
const quote = mCtx?.state?.quote
|
|
584
|
+
const quotedText = typeof quote?.text === 'string' && quote.text.trim() ? quote.text.trim() : ''
|
|
585
|
+
const quotedAttachments = Array.isArray(quote?.attachments)
|
|
586
|
+
? quote.attachments.map((a) => ({
|
|
587
|
+
contentType: a.contentType,
|
|
588
|
+
url: a.url,
|
|
589
|
+
filename: a.filename,
|
|
590
|
+
asrText: a.asrText,
|
|
591
|
+
}))
|
|
592
|
+
: []
|
|
593
|
+
|
|
594
|
+
// 群历史缓冲(SDK historyBuffer 中间件):把「未被 @ 的群聊」作为上下文交给模型,
|
|
595
|
+
// 否则模型只看到被 @ 的那一句,接不上前面在聊什么。
|
|
596
|
+
const history = isGroup && Array.isArray(mCtx?.state?.history)
|
|
597
|
+
? mCtx.state.history
|
|
598
|
+
.filter((entry) => entry && typeof entry.content === 'string' && entry.content.trim())
|
|
599
|
+
.map((entry) => ({
|
|
600
|
+
senderName: entry.senderName ?? '',
|
|
601
|
+
senderId: entry.senderId ?? '',
|
|
602
|
+
content: entry.content,
|
|
603
|
+
}))
|
|
604
|
+
: []
|
|
605
|
+
|
|
606
|
+
this.ctx.emit?.('qq/message', {
|
|
607
|
+
instanceId: this.instanceId,
|
|
608
|
+
type: 'message',
|
|
609
|
+
scope,
|
|
610
|
+
isGroup,
|
|
611
|
+
senderId: msg.senderId,
|
|
612
|
+
senderName: msg.senderName ?? '',
|
|
613
|
+
peerId: String(peerId),
|
|
614
|
+
groupId: isGroup ? String(msg.groupOpenid ?? peerId) : '',
|
|
615
|
+
text: msg.content ?? '',
|
|
616
|
+
messageId: msg.messageId,
|
|
617
|
+
msgSeq: undefined,
|
|
618
|
+
timestamp: msg.timestamp,
|
|
619
|
+
attachments: Array.isArray(msg.attachments) ? msg.attachments : [],
|
|
620
|
+
msgType: msg.msgType,
|
|
621
|
+
mentions: msg.mentions,
|
|
622
|
+
quotedText,
|
|
623
|
+
quotedAttachments,
|
|
624
|
+
history,
|
|
625
|
+
raw: msg,
|
|
626
|
+
})
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** 互动事件(按钮点击)→ cordis `qq/action`。 */
|
|
630
|
+
_handleInteraction(event) {
|
|
631
|
+
const scope = event?.group_openid ? 'group' : 'c2c'
|
|
632
|
+
const peerId = event?.group_openid || event?.user_openid || ''
|
|
633
|
+
const senderId = event?.group_member_openid || event?.user_openid || ''
|
|
634
|
+
if (!peerId) return
|
|
635
|
+
this._rememberReplyId(scope, peerId, { kind: 'event', id: event.id })
|
|
636
|
+
|
|
637
|
+
const buttonData = event?.data?.resolved?.button_data
|
|
638
|
+
let data
|
|
639
|
+
if (typeof buttonData === 'string') {
|
|
640
|
+
try { data = JSON.parse(buttonData) } catch { data = buttonData }
|
|
641
|
+
}
|
|
642
|
+
this.ctx.emit?.('qq/action', {
|
|
643
|
+
instanceId: this.instanceId,
|
|
644
|
+
type: 'interaction',
|
|
645
|
+
scope,
|
|
646
|
+
isGroup: scope === 'group',
|
|
647
|
+
peerId: String(peerId),
|
|
648
|
+
groupId: scope === 'group' ? String(peerId) : '',
|
|
649
|
+
senderId: String(senderId),
|
|
650
|
+
interactionId: event.id,
|
|
651
|
+
buttonId: event?.data?.resolved?.button_id ?? '',
|
|
652
|
+
buttonData: data,
|
|
653
|
+
messageId: event?.data?.resolved?.message_id ?? '',
|
|
654
|
+
raw: event,
|
|
655
|
+
})
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** 记录被动回复候选(有界,避免长跑内存增长)。 */
|
|
659
|
+
_rememberReplyId(scope, targetId, entry) {
|
|
660
|
+
if (!targetId || !entry?.id) return
|
|
661
|
+
const key = `${scope}:${targetId}`
|
|
662
|
+
const list = this._replyIds.get(key) ?? []
|
|
663
|
+
list.push({ ...entry, at: Date.now() })
|
|
664
|
+
// 被动回复窗口:群聊 5 分钟、单聊 60 分钟
|
|
665
|
+
const ttl = scope === 'group' ? 5 * 60_000 : 60 * 60_000
|
|
666
|
+
const now = Date.now()
|
|
667
|
+
const fresh = list.filter((item) => now - item.at <= ttl).slice(-10)
|
|
668
|
+
this._replyIds.set(key, fresh)
|
|
669
|
+
if (this._replyIds.size > 200) {
|
|
670
|
+
// 简单淘汰最早插入的键(Map 保序)
|
|
671
|
+
const oldest = this._replyIds.keys().next().value
|
|
672
|
+
if (oldest !== undefined && oldest !== key) this._replyIds.delete(oldest)
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* 解析本次出站应使用的被动回复目标。
|
|
678
|
+
*
|
|
679
|
+
* QQ 限制:同一条入站消息最多被被动回复 N 次(单聊 4 次 / 群聊 5 次),超限或过期后
|
|
680
|
+
* 必须降级主动消息(无 msg_id),否则发送会失败。这里按 ReplyLimiter 记账。
|
|
681
|
+
*
|
|
682
|
+
* @param {'c2c'|'group'} scope
|
|
683
|
+
* @param {string} targetId
|
|
684
|
+
* @param {string} [preferredMsgId] 显式指定的 msg_id(通常来自本轮入站)
|
|
685
|
+
* @returns {{ kind: 'msg'|'proactive', id?: string }}
|
|
686
|
+
*/
|
|
687
|
+
resolvePassiveTarget(scope, targetId, preferredMsgId) {
|
|
688
|
+
const limiter = this._replyLimiters[scope === 'group' ? 'group' : 'c2c']
|
|
689
|
+
const candidates = []
|
|
690
|
+
if (preferredMsgId) candidates.push({ kind: 'msg', id: preferredMsgId })
|
|
691
|
+
for (const item of [...(this._replyIds.get(`${scope}:${targetId}`) ?? [])].reverse()) {
|
|
692
|
+
candidates.push({ kind: item.kind, id: item.id })
|
|
693
|
+
}
|
|
694
|
+
for (const candidate of candidates) {
|
|
695
|
+
if (this._expiredReplyIds.has(candidate.id)) continue
|
|
696
|
+
if (limiter.checkLimit(candidate.id).allowed) {
|
|
697
|
+
limiter.record(candidate.id)
|
|
698
|
+
return candidate
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return { kind: 'proactive' }
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ---- 出站 ----
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* 把 scope/peerId 组装成 SDK 的 ReplyTarget(附带被动回复候选)。
|
|
708
|
+
*
|
|
709
|
+
* 被动回复有两种锚点:
|
|
710
|
+
* - 普通消息 → `msg_id`(ReplyTarget.msgId)
|
|
711
|
+
* - 互动事件(按钮点击)→ `event_id`(SDK 的 sendMarkdown 不支持,需走 `send({extra})`)
|
|
712
|
+
* 候选来自 `_replyIds`(含两类),按 ReplyLimiter 记账,超限/过期后降级主动消息。
|
|
713
|
+
*/
|
|
714
|
+
_replyTarget(peerId, opts = {}) {
|
|
715
|
+
const scope = opts.scope === 'group' ? 'group' : 'c2c'
|
|
716
|
+
const passive = this.resolvePassiveTarget(scope, String(peerId), opts.msgId)
|
|
717
|
+
const target = { scope, targetId: String(peerId) }
|
|
718
|
+
const out = { target, passive }
|
|
719
|
+
if (passive.kind === 'msg') target.msgId = passive.id
|
|
720
|
+
if (passive.kind === 'event') out.eventId = passive.id
|
|
721
|
+
return out
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* 统一的被动回复发送:有 event_id 时走 `bot.send({ extra: { event_id } })`,
|
|
726
|
+
* 否则走 SDK 的便捷方法。msg_id 过期(40034005)时自动去掉锚点重试一次。
|
|
727
|
+
*
|
|
728
|
+
* @param {(opts: {passive: object, eventId?: string}) => Promise<any>} exec
|
|
729
|
+
* 实际发送闭包,入参是本次解析出的被动回复候选
|
|
730
|
+
*/
|
|
731
|
+
async _sendWithPassiveReply(peerId, opts, exec) {
|
|
732
|
+
let plan = this._replyTarget(peerId, opts)
|
|
733
|
+
try {
|
|
734
|
+
return await exec(plan)
|
|
735
|
+
} catch (err) {
|
|
736
|
+
if (!this._isExpiredMsgId(err) || plan.passive.kind === 'proactive') throw err
|
|
737
|
+
// P0-7:msg_id/event_id 过期 → 去掉锚点重试一次(降级主动消息)。
|
|
738
|
+
// 否则用户侧表现为"消息直接丢失"(文档此前承诺的降级行为实际没实现)。
|
|
739
|
+
this.logger?.warn?.('[dsh-bridge qq] 被动回复锚点已过期(%s),降级为主动消息重试',
|
|
740
|
+
plan.passive.kind === 'event' ? 'event_id' : 'msg_id')
|
|
741
|
+
// 把该锚点记为"已耗尽",后续不再选它(不再依赖 ReplyLimiter 的私有字段)
|
|
742
|
+
this._expiredReplyIds.add(plan.passive.id)
|
|
743
|
+
if (this._expiredReplyIds.size > 500) {
|
|
744
|
+
const oldest = this._expiredReplyIds.values().next().value
|
|
745
|
+
if (oldest !== undefined) this._expiredReplyIds.delete(oldest)
|
|
746
|
+
}
|
|
747
|
+
plan = { target: { scope: plan.target.scope, targetId: plan.target.targetId }, passive: { kind: 'proactive' } }
|
|
748
|
+
return exec(plan)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** 发送纯文本(msg_type=0)。 */
|
|
753
|
+
async sendText(peerId, content, opts = {}) {
|
|
754
|
+
const bot = this._requireBot()
|
|
755
|
+
const text = String(content ?? '')
|
|
756
|
+
if (!bot || !text.trim()) return { success: false, error: 'bot 未连接或内容为空' }
|
|
757
|
+
try {
|
|
758
|
+
const res = await this._sendWithPassiveReply(peerId, opts, ({ target, eventId }) => bot.send({
|
|
759
|
+
target,
|
|
760
|
+
msgType: MsgType.TEXT,
|
|
761
|
+
content: text,
|
|
762
|
+
...(eventId ? { extra: { event_id: eventId } } : {}),
|
|
763
|
+
}))
|
|
764
|
+
return { success: true, id: res?.id ?? null }
|
|
765
|
+
} catch (err) {
|
|
766
|
+
return this._sendError('sendText', err)
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** 发送 Markdown(msg_type=2),可选挂按钮键盘。 */
|
|
771
|
+
async sendMarkdown(peerId, markdown, opts = {}) {
|
|
772
|
+
const bot = this._requireBot()
|
|
773
|
+
const content = String(markdown ?? '')
|
|
774
|
+
if (!bot || !content.trim()) return { success: false, error: 'bot 未连接或内容为空' }
|
|
775
|
+
try {
|
|
776
|
+
const res = await this._sendWithPassiveReply(peerId, opts, ({ target, eventId }) => bot.send({
|
|
777
|
+
target,
|
|
778
|
+
msgType: MsgType.MARKDOWN,
|
|
779
|
+
markdown: { content },
|
|
780
|
+
keyboard: opts.keyboard,
|
|
781
|
+
...(eventId ? { extra: { event_id: eventId } } : {}),
|
|
782
|
+
}))
|
|
783
|
+
return { success: true, id: res?.id ?? null }
|
|
784
|
+
} catch (err) {
|
|
785
|
+
// 只有 50056("不允许发送 markdown content")才是权限类错误,降级纯文本。
|
|
786
|
+
// 50055/50057 是**内容/参数非法**,降级会把真正的语法 bug 掩盖掉,必须暴露。
|
|
787
|
+
if (this._isMarkdownForbidden(err)) {
|
|
788
|
+
this.logger?.warn?.('[dsh-bridge qq] Markdown 权限不足,降级纯文本')
|
|
789
|
+
return this.sendText(peerId, stripMarkdown(content), opts)
|
|
790
|
+
}
|
|
791
|
+
return this._sendError('sendMarkdown', err)
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** 发送带按钮的文本消息(msg_type=0 + keyboard)。 */
|
|
796
|
+
async sendKeyboard(peerId, content, keyboard, opts = {}) {
|
|
797
|
+
const bot = this._requireBot()
|
|
798
|
+
if (!bot) return { success: false, error: 'bot 未连接' }
|
|
799
|
+
try {
|
|
800
|
+
const res = await this._sendWithPassiveReply(peerId, opts, ({ target, eventId }) => bot.send({
|
|
801
|
+
target,
|
|
802
|
+
msgType: MsgType.TEXT,
|
|
803
|
+
content: String(content ?? ''),
|
|
804
|
+
keyboard,
|
|
805
|
+
...(eventId ? { extra: { event_id: eventId } } : {}),
|
|
806
|
+
}))
|
|
807
|
+
return { success: true, id: res?.id ?? null }
|
|
808
|
+
} catch (err) {
|
|
809
|
+
return this._sendError('sendKeyboard', err)
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* 发送富媒体(图片/视频/语音/文件):SDK 负责上传(≥5MB 自动分片)后再发消息。
|
|
815
|
+
* @param {string} peerId
|
|
816
|
+
* @param {{ filePath?: string, buffer?: Buffer, url?: string, fileType?: number, fileName?: string }} media
|
|
817
|
+
*/
|
|
818
|
+
async sendMedia(peerId, media = {}, opts = {}) {
|
|
819
|
+
const bot = this._requireBot()
|
|
820
|
+
if (!bot) return { success: false, error: 'bot 未连接' }
|
|
821
|
+
const fileType = Number(media.fileType) || mediaFileTypeOf(media.filePath || media.fileName || '')
|
|
822
|
+
try {
|
|
823
|
+
const res = await this._sendWithPassiveReply(peerId, opts, ({ target }) => bot.sendMedia({
|
|
824
|
+
target,
|
|
825
|
+
fileType,
|
|
826
|
+
localPath: media.filePath,
|
|
827
|
+
buffer: media.buffer,
|
|
828
|
+
url: media.url,
|
|
829
|
+
fileName: media.fileName,
|
|
830
|
+
}))
|
|
831
|
+
return { success: true, id: res?.message?.id ?? res?.upload?.file_uuid ?? null }
|
|
832
|
+
} catch (err) {
|
|
833
|
+
return this._sendError('sendMedia', err)
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** 发送本地媒体文件(会话桥的 sendMediaFile 入口)。 */
|
|
838
|
+
async sendMediaFile(peerId, filePath, opts = {}) {
|
|
839
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
840
|
+
return { success: false, error: `文件不存在:${filePath}` }
|
|
841
|
+
}
|
|
842
|
+
return this.sendMedia(peerId, {
|
|
843
|
+
filePath,
|
|
844
|
+
fileName: path.basename(filePath),
|
|
845
|
+
fileType: mediaFileTypeOf(filePath),
|
|
846
|
+
}, opts)
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** 发送输入状态(仅 C2C 支持;群聊直接跳过)。 */
|
|
850
|
+
async sendTyping(peerId, opts = {}) {
|
|
851
|
+
const bot = this.bot
|
|
852
|
+
if (!bot) return { ok: false }
|
|
853
|
+
const scope = opts?.scope === 'group' ? 'group' : 'c2c'
|
|
854
|
+
if (scope === 'group') return { ok: true }
|
|
855
|
+
const duration = Math.min(Number(opts?.durationSeconds) || 8, 60)
|
|
856
|
+
try {
|
|
857
|
+
await bot.sendTyping({ scope: 'c2c', targetId: String(peerId) }, duration)
|
|
858
|
+
return { ok: true }
|
|
859
|
+
} catch (err) {
|
|
860
|
+
this.logger?.debug?.('[dsh-bridge qq] typing 失败:%s', err?.message ?? err)
|
|
861
|
+
return { ok: false }
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** 撤回机器人自己发送的消息(发送超过 2 分钟不可撤回)。 */
|
|
866
|
+
async recallMessage(peerId, messageId, opts = {}) {
|
|
867
|
+
const bot = this._requireBot()
|
|
868
|
+
if (!bot) return { success: false, error: 'bot 未连接' }
|
|
869
|
+
const scope = opts.scope === 'group' ? 'group' : 'c2c'
|
|
870
|
+
try {
|
|
871
|
+
await bot.recallMessage({ scope, targetId: String(peerId) }, String(messageId))
|
|
872
|
+
return { success: true }
|
|
873
|
+
} catch (err) {
|
|
874
|
+
return this._sendError('recallMessage', err)
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** 确认互动事件(QQ 要求 5 秒内 ACK;超过会被客户端判定为失败)。 */
|
|
879
|
+
async acknowledgeInteraction(interactionId, code = 0) {
|
|
880
|
+
const bot = this.bot
|
|
881
|
+
if (!bot || !interactionId) return { success: false }
|
|
882
|
+
try {
|
|
883
|
+
await bot.acknowledgeInteraction(String(interactionId), code)
|
|
884
|
+
return { success: true }
|
|
885
|
+
} catch (err) {
|
|
886
|
+
this.logger?.warn?.('[dsh-bridge qq] 互动回应失败:%s', err?.message ?? err)
|
|
887
|
+
return { success: false, error: err?.message }
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// ---- 流式(C2C only)----
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* 更新一条 C2C 流式消息(QQ `stream_messages`,replace 语义)。
|
|
895
|
+
*
|
|
896
|
+
* 会话桥按轮次调用:同一 streamKey 复用同一个 StreamSession,
|
|
897
|
+
* `done: true` 时收尾(input_state=DONE)。
|
|
898
|
+
*
|
|
899
|
+
* @param {string} peerId
|
|
900
|
+
* @param {string} fullText 截至目前的**全文**(不是增量)
|
|
901
|
+
* @param {{ streamKey?: string, msgId?: string, scope?: string, done?: boolean }} [opts]
|
|
902
|
+
*/
|
|
903
|
+
async sendStream(peerId, fullText, opts = {}) {
|
|
904
|
+
const bot = this._requireBot()
|
|
905
|
+
if (!bot) return { success: false, error: 'bot 未连接' }
|
|
906
|
+
const scope = opts.scope === 'group' ? 'group' : 'c2c'
|
|
907
|
+
// QQ 流式仅支持单聊;群聊直接走普通 Markdown
|
|
908
|
+
if (scope === 'group') return this.sendMarkdown(peerId, fullText, opts)
|
|
909
|
+
|
|
910
|
+
const key = opts.streamKey || `${scope}:${peerId}`
|
|
911
|
+
const text = String(fullText ?? '')
|
|
912
|
+
|
|
913
|
+
// P0-5:本轮流式已放弃(额度耗尽 / 开流失败 / update 抛错)。
|
|
914
|
+
// - 非 done 片段:直接丢弃。否则每片都发一条**递增全文**,用户被刷屏
|
|
915
|
+
// (实测 3 片段 → 4 条重复全文);
|
|
916
|
+
// - done:把**最终全文**发一次,保证正文完整送达且只发一条。
|
|
917
|
+
if (this._streamBroken.has(key)) {
|
|
918
|
+
if (!opts.done) return { success: true, skipped: true }
|
|
919
|
+
this._streamBroken.delete(key)
|
|
920
|
+
return this.sendMarkdown(peerId, text, { ...opts, scope: 'c2c' })
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// P0-2:没有在途流却收到 done —— 说明前一片已经失败并降级过。
|
|
924
|
+
// 此时**不能**新建一个空 StreamSession 再 complete(那会发出一条 content_raw="" 的
|
|
925
|
+
// 空白流式消息,正文彻底丢失),而是直接以单条 Markdown 发出本轮全文。
|
|
926
|
+
if (!this._streams.has(key) && opts.done) {
|
|
927
|
+
return this.sendMarkdown(peerId, text, { ...opts, scope: 'c2c' })
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
let entry = this._streams.get(key)
|
|
931
|
+
if (!entry) {
|
|
932
|
+
const passive = this.resolvePassiveTarget('c2c', String(peerId), opts.msgId)
|
|
933
|
+
if (passive.kind !== 'msg') {
|
|
934
|
+
// 没有可用的 msg_id:不能开流(SDK 会抛)。标记断流后本片丢弃,
|
|
935
|
+
// 由 turn/end 的 done 把最终全文发一次(避免"首片就发、后续又发"的重复)。
|
|
936
|
+
this.logger?.warn?.('[dsh-bridge qq] 无可用 msg_id,本轮改为单条 Markdown 收尾')
|
|
937
|
+
this._markStreamBroken(key)
|
|
938
|
+
return { success: true, skipped: true }
|
|
939
|
+
}
|
|
940
|
+
try {
|
|
941
|
+
const stream = bot.openStream({
|
|
942
|
+
target: { scope: 'c2c', targetId: String(peerId), msgId: passive.id },
|
|
943
|
+
throttleMs: this.config.streamThrottleMs,
|
|
944
|
+
})
|
|
945
|
+
entry = { stream, text: '' }
|
|
946
|
+
this._streams.set(key, entry)
|
|
947
|
+
} catch (err) {
|
|
948
|
+
this.logger?.warn?.('[dsh-bridge qq] 开启流式失败,本轮改为单条 Markdown 收尾:%s', err?.message ?? err)
|
|
949
|
+
if (opts.done) {
|
|
950
|
+
// 首帧就是 done:直接发一条普通 Markdown,不能只标记断流(否则正文丢失)
|
|
951
|
+
return this.sendMarkdown(peerId, text, { ...opts, scope: 'c2c' })
|
|
952
|
+
}
|
|
953
|
+
this._markStreamBroken(key)
|
|
954
|
+
return { success: true, skipped: true }
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
try {
|
|
958
|
+
entry.text = text
|
|
959
|
+
// P0-2:`StreamSession.complete()` 用的是它内部的 pendingText(上一次 update 的内容),
|
|
960
|
+
// **不是**本次入参。若不先 update,DONE 帧会落后一版、最终正文丢失。
|
|
961
|
+
await entry.stream.update(entry.text)
|
|
962
|
+
if (opts.done) {
|
|
963
|
+
await entry.stream.complete()
|
|
964
|
+
this._streams.delete(key)
|
|
965
|
+
}
|
|
966
|
+
return { success: true }
|
|
967
|
+
} catch (err) {
|
|
968
|
+
this._streams.delete(key)
|
|
969
|
+
// 先取消 SDK 侧的节流定时器:否则降级发出的整条 Markdown 之后,
|
|
970
|
+
// 挂起的 flush 还会再推一次旧内容(用户看到重复/回退)。
|
|
971
|
+
try { entry.stream.cancel?.() } catch { /* 取消失败不致命 */ }
|
|
972
|
+
// D 项(P0):**done 帧失败必须兜底发一次普通 Markdown**。
|
|
973
|
+
// 此前无论哪一帧失败都只标记断流并返回 skipped,而 node 侧在调用 done 前
|
|
974
|
+
// 已清空 `_turnText`(turn/end 已消费),此后没有任何路径补发 → 整轮正文丢失
|
|
975
|
+
// (实测:前面帧成功显示"第一段",done 被拒后最终全文从未送达)。
|
|
976
|
+
if (opts.done) {
|
|
977
|
+
this.logger?.warn?.('[dsh-bridge qq] 流式收尾失败,降级单条 Markdown 补发全文:%s', err?.message ?? err)
|
|
978
|
+
this._streamBroken.delete(key)
|
|
979
|
+
return this.sendMarkdown(peerId, text, { ...opts, scope: 'c2c' })
|
|
980
|
+
}
|
|
981
|
+
this.logger?.warn?.('[dsh-bridge qq] 流式更新失败,本轮改为单条 Markdown 收尾:%s', err?.message ?? err)
|
|
982
|
+
this._markStreamBroken(key)
|
|
983
|
+
return { success: true, skipped: true }
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* 标记本轮流式已放弃(P0-5):后续片段丢弃,done 时只发一次最终全文。
|
|
989
|
+
* 带 TTL 自动清理,避免长跑内存增长。
|
|
990
|
+
* @param {string} key
|
|
991
|
+
*/
|
|
992
|
+
_markStreamBroken(key) {
|
|
993
|
+
this._streamBroken.add(key)
|
|
994
|
+
const timer = setTimeout(() => this._streamBroken.delete(key), STREAM_BROKEN_TTL_MS)
|
|
995
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* 收尾指定流(幂等)。
|
|
1000
|
+
* @param {object} [opts]
|
|
1001
|
+
* @param {boolean} [opts.abort] true = 不发 DONE 帧、直接取消挂起的节流推送
|
|
1002
|
+
* (连接已断开时用:向断开的 socket 写数据只会产生无意义报错与重试)
|
|
1003
|
+
*/
|
|
1004
|
+
async finishStream(peerId, opts = {}) {
|
|
1005
|
+
const scope = opts.scope === 'group' ? 'group' : 'c2c'
|
|
1006
|
+
const key = opts.streamKey || `${scope}:${peerId}`
|
|
1007
|
+
const entry = this._streams.get(key)
|
|
1008
|
+
if (!entry) return { success: true }
|
|
1009
|
+
this._streams.delete(key)
|
|
1010
|
+
if (opts.abort) {
|
|
1011
|
+
try { entry.stream.cancel?.() } catch { /* 取消失败不致命 */ }
|
|
1012
|
+
return { success: true }
|
|
1013
|
+
}
|
|
1014
|
+
try {
|
|
1015
|
+
await entry.stream.complete()
|
|
1016
|
+
return { success: true }
|
|
1017
|
+
} catch (err) {
|
|
1018
|
+
this.logger?.warn?.('[dsh-bridge qq] 流式收尾失败:%s', err?.message ?? err)
|
|
1019
|
+
return { success: false, error: err?.message }
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// ---- 平台管理(escape hatch:SDK 未封装的面板/菜单 REST)----
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* 调用任意 QQ OpenAPI(自动带 access_token 与刷新)。
|
|
1027
|
+
* SDK 只封装了消息/媒体/流式,自定义菜单与指令面板仍需走原始 REST。
|
|
1028
|
+
*
|
|
1029
|
+
* @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} method
|
|
1030
|
+
* @param {string} apiPath 形如 `/v2/menu`
|
|
1031
|
+
* @param {object} [body]
|
|
1032
|
+
*/
|
|
1033
|
+
async apiCall(method, apiPath, body) {
|
|
1034
|
+
const bot = this._requireBot()
|
|
1035
|
+
if (!bot) throw new Error('QQ bot 未连接')
|
|
1036
|
+
const verb = String(method || 'GET').toUpperCase()
|
|
1037
|
+
if (verb === 'GET') return bot.api.get(apiPath)
|
|
1038
|
+
if (verb === 'DELETE') return bot.api.delete(apiPath)
|
|
1039
|
+
if (verb === 'PUT') return bot.api.put(apiPath, body)
|
|
1040
|
+
if (verb === 'PATCH') return bot.api.patch(apiPath, body)
|
|
1041
|
+
return bot.api.post(apiPath, body)
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// ---- 内部工具 ----
|
|
1045
|
+
|
|
1046
|
+
_requireBot() {
|
|
1047
|
+
if (!this.bot) {
|
|
1048
|
+
this.logger?.debug?.('[dsh-bridge qq] bot 未连接,本条出站已跳过')
|
|
1049
|
+
return null
|
|
1050
|
+
}
|
|
1051
|
+
return this.bot
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* markdown 权限不足的业务错误码。
|
|
1056
|
+
*
|
|
1057
|
+
* 依据官方错误码表(bot-docs `openapi/error/error.md`):
|
|
1058
|
+
* - 50056 「不允许发送 markdown content」→ 权限类,降级纯文本是正确行为;
|
|
1059
|
+
* - 50055 「无效的 markdown content」→ **内容**非法,降级会把真正的语法 bug 掩盖掉;
|
|
1060
|
+
* - 50057「markdown 参数只支持原生语法或者模版二选一」→ **参数组合**错误,同理;
|
|
1061
|
+
* - 304036「没有 markdown 模板的权限」→ 只与 `custom_template_id` 模板相关,
|
|
1062
|
+
* 本插件只用原生 markdown 语法,不属于该场景。
|
|
1063
|
+
* 故只把 50056 当作"无权限",其余照常报错暴露。
|
|
1064
|
+
*/
|
|
1065
|
+
_isMarkdownForbidden(err) {
|
|
1066
|
+
if (!(err instanceof ApiError)) return false
|
|
1067
|
+
return Number(err.bizCode) === 50056
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* 被动回复锚点已过期(平台错误码 40034005「回复消息 msg_id 已过期」)。
|
|
1072
|
+
* 命中后应去掉锚点、以降级主动消息重试一次(P0-7)。
|
|
1073
|
+
*/
|
|
1074
|
+
_isExpiredMsgId(err) {
|
|
1075
|
+
if (!(err instanceof ApiError)) return false
|
|
1076
|
+
return Number(err.bizCode) === 40034005
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
_sendError(op, err) {
|
|
1080
|
+
const detail = err instanceof ApiError
|
|
1081
|
+
? `QQ API ${err.httpStatus} code=${err.bizCode ?? '-'}: ${err.bizMessage ?? err.message}`
|
|
1082
|
+
: (err?.message ?? String(err))
|
|
1083
|
+
this.logger?.warn?.('[dsh-bridge qq] %s 失败:%s', op, detail)
|
|
1084
|
+
return { success: false, error: detail }
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/** 去掉 Markdown 语法(无 markdown 权限时的纯文本降级)。 */
|
|
1089
|
+
export function stripMarkdown(text) {
|
|
1090
|
+
return String(text ?? '')
|
|
1091
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
1092
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
1093
|
+
.replace(/\*([^*]+)\*/g, '$1')
|
|
1094
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
1095
|
+
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1 $2')
|
|
1096
|
+
.replace(/\[([^\]]*)\]\(([^)]+)\)/g, '$1 $2')
|
|
1097
|
+
.trim()
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
export const gatewayConstants = {
|
|
1101
|
+
MAX_MESSAGE_CHARS,
|
|
1102
|
+
STREAM_THROTTLE_MS,
|
|
1103
|
+
PASSIVE_REPLY_LIMIT_C2C,
|
|
1104
|
+
PASSIVE_REPLY_LIMIT_GROUP,
|
|
1105
|
+
STREAM_BROKEN_TTL_MS,
|
|
1106
|
+
STATUS,
|
|
1107
|
+
}
|