@dnalec/dsh-auto-approve 0.1.3 → 0.2.1

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/src/util.mjs CHANGED
@@ -1,36 +1,76 @@
1
1
  /**
2
2
  * 路径、JSON、审计、事件日志。无副作用:调用方传入目录。
3
3
  *
4
- * 规则在 ~/.dsh/auto-approve/;凭据/插件配置在 ~/.dsh/approval-bridge/(旧目录名,不要挪)。
4
+ * 规则、审计、插件配置在 ~/.dsh/auto-approve/。
5
+ * 0.1.x 把插件配置放在 ~/.dsh/approval-bridge/config.json;缺失新文件时只迁移判定字段。
5
6
  * tryLoadJson 区分缺失与损坏:损坏时调用方不得用默认值覆写磁盘。
6
7
  */
7
8
  import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
8
9
  import { homedir } from 'node:os'
9
10
  import { dirname, join } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
10
12
 
11
13
  export const NAME = '@dnalec/dsh-auto-approve'
12
14
 
15
+ /** DSH 的用户 patch 层文件名(profile 目录下)。 */
16
+ export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml'
17
+
13
18
  export function dshHome() {
14
19
  return process.env.DSH_HOME || join(homedir(), '.dsh')
15
20
  }
16
21
 
17
- /** auto-approve = 规则/审计;approval-bridge = QQ 凭据与插件配置。 */
18
- export function pathsFor(home = dshHome()) {
22
+ /**
23
+ * cordis `baseUrl` 推导当前 profile 的 patch 文件路径。
24
+ * app-boot 把 root include 的 baseUrl 锚在 profile 目录
25
+ * (`packages/boot/app-boot/src/index.ts`:`ctx.baseUrl = pathToFileURL(dirname(configPath)).href + '/'`),
26
+ * 所以这里能拿到真实 profile,而不是写死 `profiles/web`。
27
+ * 拿不到(测试、非 file: URL)时返回 '',由调用方回落到 `pathsFor()` 的默认位置。
28
+ * @param {string} baseUrl - `ctx.baseUrl`
29
+ * @returns {string} 绝对路径或 ''
30
+ */
31
+ export function profilePatchFromBaseUrl(baseUrl) {
32
+ const s = String(baseUrl || '')
33
+ if (!s.startsWith('file:')) return ''
34
+ try {
35
+ const url = new URL(s)
36
+ // 目录判定看 pathname:`file:///a/b/?x=1` 的字符串不以 / 结尾,但路径是目录。
37
+ let dir = fileURLToPath(url)
38
+ if (!url.pathname.endsWith('/')) dir = dirname(dir)
39
+ if (!dir || dir === '/' || dir === '\\') return ''
40
+ return join(dir, PROFILE_PATCH_FILENAME)
41
+ } catch {
42
+ return ''
43
+ }
44
+ }
45
+
46
+ /**
47
+ * patch 文件路径的优先级:显式配置 → 当前 profile 目录 → 默认 `profiles/web`。
48
+ * @param {object} ctx - 插件 ctx(只用 baseUrl)。
49
+ * @param {object} rawConfig - 插件行配置,可含 `profilePatch` 绝对路径。
50
+ * @param {string} fallback - `pathsFor()` 给出的默认位置。
51
+ * @returns {string}
52
+ */
53
+ export function resolveProfilePatchPath(ctx, rawConfig, fallback) {
54
+ const explicit = rawConfig && typeof rawConfig.profilePatch === 'string' ? rawConfig.profilePatch.trim() : ''
55
+ if (explicit) return explicit
56
+ return profilePatchFromBaseUrl(ctx && ctx.baseUrl) || String(fallback || '')
57
+ }
58
+
59
+ /** auto-approve = 规则/审计/插件配置;legacyPluginConfig 仅作 0.1.x 迁移源。 */
60
+ export function pathsFor(home = dshHome(), profileName = 'web') {
19
61
  const auto = join(home, 'auto-approve')
20
62
  const bridge = join(home, 'approval-bridge')
21
63
  return {
22
64
  home,
23
65
  auto,
24
- bridge,
25
66
  allowlist: join(auto, 'allowlist.json'),
26
67
  audit: join(auto, 'audit.log'),
27
68
  events: join(auto, 'events.jsonl'),
28
- pluginConfig: join(bridge, 'config.json'),
29
- qqbot: join(bridge, 'qqbot.json'),
30
- profilePatch: join(home, 'profiles', 'web', 'cordis.patch.yml'),
69
+ pluginConfig: join(auto, 'config.json'),
70
+ legacyPluginConfig: join(bridge, 'config.json'),
71
+ profilePatch: join(home, 'profiles', profileName, PROFILE_PATCH_FILENAME),
31
72
  }
32
73
  }
33
-
34
74
  export function ensureDir(dir) {
35
75
  try { mkdirSync(dir, { recursive: true }) } catch { /* ignore */ }
36
76
  }
@@ -83,16 +123,6 @@ export function saveJson(path, data) {
83
123
  }
84
124
  }
85
125
 
86
- /** 凭据文件:0600。目录也尽量收紧。 */
87
- export function saveSecretJson(path, data) {
88
- try {
89
- writeAtomic(path, JSON.stringify(data, null, 2) + '\n', 0o600)
90
- } catch (error) {
91
- console.error(`[${NAME}] 写入凭据 ${path} 失败`, error)
92
- throw error
93
- }
94
- }
95
-
96
126
  export function appendLine(path, line) {
97
127
  try {
98
128
  ensureDir(dirname(path))
@@ -187,56 +217,3 @@ export function maxEventId(eventsPath) {
187
217
  } catch { /* missing */ }
188
218
  return max
189
219
  }
190
-
191
- export function maskSecret(value) {
192
- const s = String(value || '')
193
- if (!s) return ''
194
- if (s.length <= 4) return '****'
195
- return s.slice(0, 2) + '****' + s.slice(-2)
196
- }
197
-
198
- /**
199
- * 网页审批框用的独立 signal:父 signal 中止时跟着中止;
200
- * 主动 abort() 只关网页,不碰父 signal(避免整单变成 cancelled)。
201
- * @param {AbortSignal | undefined} parent
202
- */
203
- export function forkAbortSignal(parent) {
204
- const child = new AbortController()
205
- const forward = () => {
206
- if (child.signal.aborted) return
207
- try { child.abort(parent && parent.reason) } catch { /* ignore */ }
208
- }
209
- if (parent) {
210
- if (parent.aborted) forward()
211
- else parent.addEventListener('abort', forward, { once: true })
212
- }
213
- return {
214
- signal: child.signal,
215
- abort() {
216
- if (parent) {
217
- try { parent.removeEventListener('abort', forward) } catch { /* ignore */ }
218
- }
219
- forward()
220
- },
221
- }
222
- }
223
-
224
- /** 把 req.signal 换成网页专用 signal;失败则 false(网页框可能关不掉)。 */
225
- export function replaceRequestSignal(req, signal) {
226
- if (!req || typeof req !== 'object') return false
227
- try {
228
- req.signal = signal
229
- if (req.signal === signal) return true
230
- } catch { /* readonly */ }
231
- try {
232
- Object.defineProperty(req, 'signal', {
233
- configurable: true,
234
- enumerable: true,
235
- writable: true,
236
- value: signal,
237
- })
238
- return req.signal === signal
239
- } catch {
240
- return false
241
- }
242
- }
@@ -1,131 +0,0 @@
1
- /**
2
- * QQ 官方扫码创建机器人:生成本机二维码,等待平台返回 AppID / AppSecret。
3
- * 长连接仍由 qqbot.mjs 负责。扫码成功可预填扫码者 openid 为 chatId。
4
- */
5
-
6
- export function cleanString(value) {
7
- return typeof value === 'string' && value.trim() ? value.trim() : ''
8
- }
9
-
10
- /**
11
- * @param {unknown} credentials
12
- * @returns {{ appId: string, appSecret: string, userOpenid: string } | null}
13
- */
14
- export function pickQqCredentials(credentials) {
15
- const list = Array.isArray(credentials) ? credentials : (credentials ? [credentials] : [])
16
- for (const item of list) {
17
- if (!item || typeof item !== 'object') continue
18
- const appId = cleanString(item.appId)
19
- const appSecret = cleanString(item.appSecret)
20
- if (appId && appSecret) {
21
- return { appId, appSecret, userOpenid: cleanString(item.userOpenid) }
22
- }
23
- }
24
- return null
25
- }
26
-
27
- export async function qrDataUrl(value) {
28
- const url = String(value || '')
29
- if (!url) throw new Error('扫码服务未返回二维码 URL')
30
- let mod
31
- try {
32
- mod = await import('qrcode')
33
- } catch (error) {
34
- const msg = error instanceof Error ? error.message : String(error)
35
- if (/Cannot find|ERR_MODULE_NOT_FOUND|MODULE_NOT_FOUND/i.test(msg)) {
36
- throw new Error('未安装 qrcode,请在插件目录执行 npm install')
37
- }
38
- throw error
39
- }
40
- const toDataURL = typeof mod.toDataURL === 'function'
41
- ? mod.toDataURL
42
- : (mod.default && typeof mod.default.toDataURL === 'function' ? mod.default.toDataURL : null)
43
- if (!toDataURL) throw new Error('qrcode.toDataURL 不可用')
44
- return toDataURL.call(mod.default || mod, url, {
45
- type: 'image/png',
46
- margin: 2,
47
- width: 320,
48
- errorCorrectionLevel: 'M',
49
- })
50
- }
51
-
52
- async function defaultImportConnector() {
53
- try {
54
- return await import('@tencent-connect/qqbot-connector')
55
- } catch (error) {
56
- const msg = error instanceof Error ? error.message : String(error)
57
- if (/Cannot find|ERR_MODULE_NOT_FOUND|MODULE_NOT_FOUND/i.test(msg)) {
58
- throw new Error('未安装 @tencent-connect/qqbot-connector,请在插件目录执行 npm install')
59
- }
60
- throw error
61
- }
62
- }
63
-
64
- /**
65
- * @param {{
66
- * onQr: (qr: { dataUrl: string, expiresAt: number }) => void,
67
- * onStatus: (status: string) => void,
68
- * onCredentials: (creds: { appId: string, appSecret: string, ownerUserOpenid: string }) => void | Promise<void>,
69
- * onFailure: (error: unknown) => void,
70
- * }} callbacks
71
- * @param {AbortSignal} [signal]
72
- * @param {{ importConnector?: () => Promise<any>, qrDataUrl?: (url: string) => Promise<string> }} [deps]
73
- * @returns {Promise<{ cancel: () => void }>}
74
- */
75
- export async function startQqProvisioning(callbacks, signal, deps) {
76
- const loadConnector = (deps && deps.importConnector) || defaultImportConnector
77
- const toQr = (deps && deps.qrDataUrl) || qrDataUrl
78
- const mod = await loadConnector()
79
- const startQrConnect = typeof mod.startQrConnect === 'function'
80
- ? mod.startQrConnect
81
- : (mod.default && typeof mod.default.startQrConnect === 'function' ? mod.default.startQrConnect : null)
82
- if (typeof startQrConnect !== 'function') {
83
- throw new Error('@tencent-connect/qqbot-connector 缺少 startQrConnect')
84
- }
85
-
86
- let disposed = false
87
- const started = startQrConnect({
88
- onQrDisplayed(url) {
89
- if (disposed) return
90
- void Promise.resolve(toQr(String(url || ''))).then((dataUrl) => {
91
- if (disposed) return
92
- callbacks.onQr({ dataUrl, expiresAt: Date.now() + 5 * 60_000 })
93
- callbacks.onStatus('等待扫码')
94
- }).catch((error) => {
95
- if (!disposed) callbacks.onFailure(error)
96
- })
97
- },
98
- onQrExpired() {
99
- if (!disposed) callbacks.onStatus('二维码已过期')
100
- },
101
- onSuccess(credentials) {
102
- if (disposed) return
103
- const first = pickQqCredentials(credentials)
104
- if (!first) {
105
- callbacks.onFailure(new Error('QQ 扫码未返回完整机器人凭据'))
106
- return
107
- }
108
- void Promise.resolve(callbacks.onCredentials({
109
- appId: first.appId,
110
- appSecret: first.appSecret,
111
- ownerUserOpenid: first.userOpenid,
112
- })).catch((error) => {
113
- if (!disposed) callbacks.onFailure(error)
114
- })
115
- },
116
- onFailure(error) {
117
- if (!disposed) callbacks.onFailure(error)
118
- },
119
- }, {
120
- displayQrCodeToConsole: false,
121
- source: 'deepseek-harness',
122
- signal,
123
- })
124
- const dispose = (started && typeof started.then === 'function') ? await started : started
125
- return {
126
- cancel() {
127
- disposed = true
128
- if (typeof dispose === 'function') dispose()
129
- },
130
- }
131
- }
package/src/qqbot.mjs DELETED
@@ -1,313 +0,0 @@
1
- /**
2
- * QQ 开放平台官方 Bot:token / WS / 收发。
3
- *
4
- * 意图:C2C + 群 @ + INTERACTION_CREATE(按钮回调)。
5
- * 发消息优先 markdown+keyboard;纯文本会成功但按钮没了。
6
- * op=7 必须关 socket 走重连,只改状态会卡死。
7
- */
8
-
9
- const TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken'
10
- const API = 'https://api.sgroup.qq.com'
11
- const GATEWAY_PATH = '/gateway'
12
- const GROUP_AND_C2C_INTENT = 1 << 25
13
- const INTERACTION_INTENT = 1 << 26
14
-
15
- export function isGatewayForceReconnect(op) {
16
- return op === 7 || op === 9
17
- }
18
-
19
- /**
20
- * @param {{ appId: string, appSecret: string }} creds
21
- * @param {(line: string) => void} log
22
- */
23
- export function createQQBot(creds, log = () => {}) {
24
- const appId = String(creds.appId || '')
25
- const appSecret = String(creds.appSecret || '')
26
- if (!appId || !appSecret) {
27
- return {
28
- start() {},
29
- stop() {},
30
- async send() { throw new Error('qqbot: missing credentials') },
31
- setMessageHandler() {},
32
- status() { return '未配置凭据' },
33
- connected() { return false },
34
- }
35
- }
36
-
37
- let handler
38
- let ws
39
- let heartbeat
40
- let reconnectTimer
41
- let stableTimer
42
- let reconnectAttempts = 0
43
- let stopped = true
44
- let seq = null
45
- let accessToken = ''
46
- let tokenExpiresAt = 0
47
- let statusText = '未连接'
48
-
49
- function clearTimers() {
50
- if (heartbeat) { clearInterval(heartbeat); heartbeat = undefined }
51
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = undefined }
52
- if (stableTimer) { clearTimeout(stableTimer); stableTimer = undefined }
53
- }
54
-
55
- function scheduleReconnect() {
56
- if (stopped || reconnectTimer) return
57
- const delay = Math.min(3000 * (2 ** reconnectAttempts), 60_000)
58
- reconnectAttempts += 1
59
- log(`[qqbot] ${Math.ceil(delay / 1000)}s 后重连`)
60
- reconnectTimer = setTimeout(() => {
61
- reconnectTimer = undefined
62
- void connect().catch((err) => {
63
- statusText = '重连失败'
64
- log(`[qqbot] 重连失败: ${err instanceof Error ? err.message : String(err)}`)
65
- scheduleReconnect()
66
- })
67
- }, delay)
68
- }
69
-
70
- async function getToken() {
71
- const res = await fetch(TOKEN_URL, {
72
- method: 'POST',
73
- headers: { 'content-type': 'application/json' },
74
- body: JSON.stringify({ appId, clientSecret: appSecret }),
75
- })
76
- const body = await res.text()
77
- let data
78
- try { data = JSON.parse(body) } catch {
79
- throw new Error(`qq getAppAccessToken: HTTP ${res.status} ${body.slice(0, 200)}`)
80
- }
81
- if (!res.ok || !data.access_token) {
82
- throw new Error(`qq getAppAccessToken: HTTP ${res.status} ${data.message ?? 'no token'}`)
83
- }
84
- const expiresIn = Number(data.expires_in) > 0 ? Number(data.expires_in) : 7200
85
- tokenExpiresAt = Date.now() + Math.max(30, expiresIn - 60) * 1000
86
- accessToken = data.access_token
87
- return accessToken
88
- }
89
-
90
- async function ensureToken() {
91
- if (!accessToken || Date.now() >= tokenExpiresAt) await getToken()
92
- }
93
-
94
- async function qqFetch(path, init, retried = false) {
95
- await ensureToken()
96
- const res = await fetch(`${API}${path}`, {
97
- ...init,
98
- headers: {
99
- Authorization: `QQBot ${accessToken}`,
100
- 'content-type': 'application/json',
101
- ...(init && init.headers ? init.headers : {}),
102
- },
103
- })
104
- if (res.status === 401 && !retried) {
105
- await res.text().catch(() => '')
106
- accessToken = ''
107
- tokenExpiresAt = 0
108
- return qqFetch(path, init, true)
109
- }
110
- if (!res.ok) {
111
- const body = await res.text().catch(() => '')
112
- throw new Error(`qq ${path}: HTTP ${res.status} ${body.slice(0, 200)}`)
113
- }
114
- if (res.status === 204) return null
115
- const text = await res.text()
116
- if (!text) return null
117
- try { return JSON.parse(text) } catch { return text }
118
- }
119
-
120
- async function connect() {
121
- if (stopped) return
122
- if (typeof WebSocket === 'undefined') {
123
- statusText = '环境无 WebSocket'
124
- throw new Error('qqbot: WebSocket is not available')
125
- }
126
- await getToken()
127
- if (stopped) return
128
- const gw = await qqFetch(GATEWAY_PATH)
129
- const url = gw && gw.url
130
- if (!url) throw new Error('qq gateway: missing websocket url')
131
- if (stopped) return
132
-
133
- const socket = new WebSocket(url)
134
- ws = socket
135
- statusText = '连接中'
136
- socket.onopen = () => {
137
- if (ws === socket) statusText = '等待网关握手'
138
- }
139
- socket.onmessage = (ev) => {
140
- let payload
141
- try { payload = JSON.parse(String(ev.data)) } catch {
142
- log('[qqbot] 收到无法解析的网关消息')
143
- return
144
- }
145
- if (payload.s !== undefined) seq = payload.s
146
- switch (payload.op) {
147
- case 10: {
148
- const hello = payload.d || {}
149
- socket.send(JSON.stringify({
150
- op: 2,
151
- d: {
152
- token: `QQBot ${accessToken}`,
153
- intents: GROUP_AND_C2C_INTENT | INTERACTION_INTENT,
154
- shard: [0, 1],
155
- },
156
- }))
157
- if (heartbeat) clearInterval(heartbeat)
158
- heartbeat = setInterval(() => {
159
- if (ws === socket) socket.send(JSON.stringify({ op: 1, d: seq }))
160
- }, hello.heartbeat_interval || 41250)
161
- statusText = '鉴权中'
162
- log('[qqbot] 已收到 Hello,正在鉴权')
163
- break
164
- }
165
- case 0: {
166
- const t = payload.t
167
- if (t === 'READY') {
168
- if (stableTimer) clearTimeout(stableTimer)
169
- stableTimer = setTimeout(() => { reconnectAttempts = 0 }, 60_000)
170
- statusText = '已连接'
171
- log('[qqbot] 网关就绪')
172
- break
173
- }
174
- if (t === 'INTERACTION_CREATE') {
175
- const d = payload.d || {}
176
- if (d.id && (d.type === 11 || d.type === 12)) {
177
- void qqFetch(`/interactions/${encodeURIComponent(d.id)}`, {
178
- method: 'PUT',
179
- body: JSON.stringify({ code: 0 }),
180
- }).catch((err) => {
181
- log(`[qqbot] 互动回应失败: ${err instanceof Error ? err.message : String(err)}`)
182
- })
183
- }
184
- if (d.type !== 11) break
185
- const resolved = (d.data && d.data.resolved) || {}
186
- const text = String(resolved.button_data || '').trim()
187
- const isGroup = d.scene === 'group' || d.chat_type === 1
188
- const userId = isGroup
189
- ? (d.group_member_openid || '')
190
- : (d.user_openid || '')
191
- const chatId = isGroup
192
- ? (d.group_openid ? `g:${d.group_openid}` : '')
193
- : (d.user_openid || '')
194
- if (!chatId || !userId || !text) break
195
- void handler?.({ chatId, userId, text, isGroup, username: '' })
196
- break
197
- }
198
- if (t === 'C2C_MESSAGE_CREATE' || t === 'GROUP_AT_MESSAGE_CREATE') {
199
- const msg = payload.d || {}
200
- if (!msg.content || !msg.author) return
201
- const isGroup = t === 'GROUP_AT_MESSAGE_CREATE'
202
- const userId = isGroup
203
- ? (msg.author.member_openid || msg.author.id)
204
- : (msg.author.user_openid || msg.author.id)
205
- const chatId = isGroup
206
- ? (msg.group_openid ? `g:${msg.group_openid}` : '')
207
- : (msg.author.user_openid || msg.author.id || '')
208
- if (!chatId || !userId) return
209
- void handler?.({
210
- chatId,
211
- userId,
212
- username: msg.author.username,
213
- text: String(msg.content || '').replace(/^<@!\d+>\s*/, '').trim(),
214
- isGroup,
215
- })
216
- }
217
- break
218
- }
219
- case 7:
220
- case 9:
221
- statusText = '重连中'
222
- log(`[qqbot] 网关要求重连 (op=${payload.op})`)
223
- if (payload.op === 9) seq = null
224
- try { socket.close() } catch { /* onclose 会 scheduleReconnect */ }
225
- break
226
- }
227
- }
228
- socket.onclose = (ev) => {
229
- if (ws !== socket) return
230
- if (heartbeat) { clearInterval(heartbeat); heartbeat = undefined }
231
- if (stableTimer) { clearTimeout(stableTimer); stableTimer = undefined }
232
- ws = undefined
233
- statusText = `已断开(code ${ev.code})`
234
- if (!stopped) {
235
- const detail = ev.code === 4004 ? ':鉴权失败,将刷新 AccessToken' : ''
236
- log(`[qqbot] 连接断开(${ev.code}${detail})`)
237
- scheduleReconnect()
238
- }
239
- }
240
- socket.onerror = () => {
241
- if (ws === socket) statusText = '连接错误'
242
- }
243
- }
244
-
245
- return {
246
- async start() {
247
- if (!stopped && (ws || reconnectTimer)) return
248
- stopped = false
249
- reconnectAttempts = 0
250
- try {
251
- await connect()
252
- } catch (err) {
253
- statusText = '连接失败'
254
- log(`[qqbot] 连接失败: ${err instanceof Error ? err.message : String(err)}`)
255
- scheduleReconnect()
256
- }
257
- },
258
- async stop() {
259
- stopped = true
260
- clearTimers()
261
- try { ws?.close(1000, 'shutdown') } catch { /* ignore */ }
262
- ws = undefined
263
- statusText = '已停止'
264
- },
265
- async send(chatId, text, extra = {}) {
266
- const content = String(text || '')
267
- const path = String(chatId).startsWith('g:')
268
- ? `/v2/groups/${String(chatId).slice(2)}/messages`
269
- : `/v2/users/${chatId}/messages`
270
- const keyboard = extra && extra.keyboard
271
- const kb = keyboard ? { content: keyboard } : null
272
- const payloads = []
273
- if (kb) {
274
- // 按钮挂在 markdown 上;纯文本会 200 但丢掉 keyboard。
275
- payloads.push({
276
- msg_type: 2,
277
- markdown: { content },
278
- keyboard: kb,
279
- })
280
- payloads.push({
281
- content,
282
- msg_type: 2,
283
- markdown: { content },
284
- keyboard: kb,
285
- })
286
- payloads.push({
287
- content,
288
- msg_type: 0,
289
- keyboard: kb,
290
- })
291
- }
292
- payloads.push({ content, msg_type: 0 })
293
- let lastErr
294
- for (const body of payloads) {
295
- try {
296
- await qqFetch(path, {
297
- method: 'POST',
298
- body: JSON.stringify(body),
299
- })
300
- log(`[qqbot] 已发送 msg_type=${body.msg_type}${body.keyboard ? ' keyboard' : ''}`)
301
- return
302
- } catch (err) {
303
- lastErr = err
304
- log(`[qqbot] 发送失败 msg_type=${body.msg_type}${body.keyboard ? ' keyboard' : ''}: ${err instanceof Error ? err.message : String(err)}`)
305
- }
306
- }
307
- throw lastErr || new Error('qqbot: send failed')
308
- },
309
- setMessageHandler(h) { handler = h },
310
- status() { return statusText },
311
- connected() { return statusText === '已连接' },
312
- }
313
- }