@dnalec/dsh-auto-approve 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/LICENSE +21 -0
- package/README.md +152 -0
- package/README.zh.md +152 -0
- package/client.js +1312 -0
- package/cordis.patch.yml +5 -0
- package/locales.mjs +398 -0
- package/package.json +65 -0
- package/src/index.mjs +1143 -0
- package/src/preset-patch.mjs +164 -0
- package/src/provisioning.mjs +131 -0
- package/src/qqbot.mjs +313 -0
- package/src/rules.mjs +900 -0
- package/src/tickets.mjs +277 -0
- package/src/util.mjs +242 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把 auto-approve 预设写入 profile 的 cordis.patch.yml。
|
|
3
|
+
* 权限表冻结,不能运行时扩展 presets。改 sandbox 后必须重启并重新选预设。
|
|
4
|
+
* patch 若是 `[]`,整段替换,不要拼成 `[]\n- id: permission`。
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { autoApprovePresetYaml, FULL_PERMISSION_BLOCK, normalizePresetSandbox } from './rules.mjs'
|
|
8
|
+
|
|
9
|
+
export function getSetupState(patchPath) {
|
|
10
|
+
try {
|
|
11
|
+
const text = readFileSync(patchPath, 'utf8')
|
|
12
|
+
return {
|
|
13
|
+
configured: text.includes('auto-approve:'),
|
|
14
|
+
patchPath,
|
|
15
|
+
sandbox: readAutoApproveSandboxFromText(text),
|
|
16
|
+
}
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return { configured: false, patchPath, sandbox: '', error: String((e && e.message) || e) }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function readAutoApproveSandbox(patchPath) {
|
|
23
|
+
try {
|
|
24
|
+
return readAutoApproveSandboxFromText(readFileSync(patchPath, 'utf8'))
|
|
25
|
+
} catch {
|
|
26
|
+
return ''
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readAutoApproveSandboxFromText(text) {
|
|
31
|
+
const lines = String(text || '').split('\n')
|
|
32
|
+
let inAuto = false
|
|
33
|
+
let autoIndent = 0
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
const start = line.match(/^([ \t]*)auto-approve:\s*$/)
|
|
36
|
+
if (start) {
|
|
37
|
+
inAuto = true
|
|
38
|
+
autoIndent = start[1].length
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (!inAuto) continue
|
|
42
|
+
const indent = (line.match(/^[ \t]*/) || [''])[0].length
|
|
43
|
+
if (line.trim() !== '' && indent <= autoIndent) {
|
|
44
|
+
inAuto = false
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
const sandbox = line.match(/^[ \t]+sandbox:\s*(workspace-write|read-only)\s*$/)
|
|
48
|
+
if (sandbox) return sandbox[1]
|
|
49
|
+
}
|
|
50
|
+
return ''
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function replaceAutoApproveSandbox(text, sandbox) {
|
|
54
|
+
const mode = normalizePresetSandbox(sandbox)
|
|
55
|
+
const lines = String(text || '').split('\n')
|
|
56
|
+
let inAuto = false
|
|
57
|
+
let autoIndent = 0
|
|
58
|
+
let changed = false
|
|
59
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
|
+
const line = lines[i]
|
|
61
|
+
const start = line.match(/^([ \t]*)auto-approve:\s*$/)
|
|
62
|
+
if (start) {
|
|
63
|
+
inAuto = true
|
|
64
|
+
autoIndent = start[1].length
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
if (!inAuto) continue
|
|
68
|
+
const indent = (line.match(/^[ \t]*/) || [''])[0].length
|
|
69
|
+
if (line.trim() !== '' && indent <= autoIndent) {
|
|
70
|
+
inAuto = false
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (/^[ \t]+sandbox:\s*(workspace-write|read-only)\s*$/.test(line)) {
|
|
74
|
+
const next = line.replace(/workspace-write|read-only/, mode)
|
|
75
|
+
if (next !== line) {
|
|
76
|
+
lines[i] = next
|
|
77
|
+
changed = true
|
|
78
|
+
}
|
|
79
|
+
inAuto = false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { text: lines.join('\n'), changed }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function ensureAutoApprovePreset(patchPath, sandbox = 'workspace-write') {
|
|
86
|
+
const yaml = autoApprovePresetYaml(sandbox)
|
|
87
|
+
try {
|
|
88
|
+
const text = readFileSync(patchPath, 'utf8')
|
|
89
|
+
if (text.includes('auto-approve:')) return { ok: true, status: 'already', needRestart: false }
|
|
90
|
+
|
|
91
|
+
const lines = text.split('\n')
|
|
92
|
+
let permIdx = -1
|
|
93
|
+
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
if (/^- id:\s*permission\s*$/.test(lines[i])) { permIdx = i; break }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (permIdx === -1) {
|
|
98
|
+
const trimmed = String(text || '').replace(/^\s+|\s+$/g, '')
|
|
99
|
+
const block = (FULL_PERMISSION_BLOCK + yaml).replace(/^\n/, '')
|
|
100
|
+
const next = (trimmed === '' || trimmed === '[]')
|
|
101
|
+
? block
|
|
102
|
+
: (text.replace(/\s*$/, '') + '\n' + block)
|
|
103
|
+
writeFileSync(patchPath, next.endsWith('\n') ? next : next + '\n', 'utf8')
|
|
104
|
+
return { ok: true, status: 'added-entry', needRestart: true }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let presetsIdx = -1
|
|
108
|
+
for (let i = permIdx; i < lines.length; i++) {
|
|
109
|
+
if (/^ {4}presets:\s*$/.test(lines[i])) { presetsIdx = i; break }
|
|
110
|
+
if (i > permIdx && /^- /.test(lines[i]) && !/^ {2,}- /.test(lines[i])) break
|
|
111
|
+
}
|
|
112
|
+
if (presetsIdx === -1) {
|
|
113
|
+
return { ok: false, status: 'no-presets-key', needRestart: false, error: 'permission 条目缺少 presets 键,请手动添加' }
|
|
114
|
+
}
|
|
115
|
+
let insertAt = presetsIdx
|
|
116
|
+
for (let i = presetsIdx + 1; i < lines.length; i++) {
|
|
117
|
+
const line = lines[i]
|
|
118
|
+
if (/^ {6}\S/.test(line) || /^ {8}\S/.test(line)) { insertAt = i; continue }
|
|
119
|
+
if (/^ {0,4}\S/.test(line) && !/^ {6,}\S/.test(line)) break
|
|
120
|
+
if (/^\s*$/.test(line)) continue
|
|
121
|
+
}
|
|
122
|
+
lines.splice(insertAt + 1, 0, yaml.replace(/\n$/, ''))
|
|
123
|
+
writeFileSync(patchPath, lines.join('\n'), 'utf8')
|
|
124
|
+
return { ok: true, status: 'added-preset', needRestart: true }
|
|
125
|
+
} catch (e) {
|
|
126
|
+
return { ok: false, status: 'error', needRestart: false, error: String((e && e.message) || e) }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 把配置里的 presetSandbox 写进 auto-approve.sandbox。已有预设则只改这一行。 */
|
|
131
|
+
export function setAutoApproveSandbox(patchPath, sandbox) {
|
|
132
|
+
const mode = normalizePresetSandbox(sandbox)
|
|
133
|
+
const ensured = ensureAutoApprovePreset(patchPath, mode)
|
|
134
|
+
if (!ensured.ok) return { ...ensured, sandbox: mode }
|
|
135
|
+
try {
|
|
136
|
+
const text = readFileSync(patchPath, 'utf8')
|
|
137
|
+
const replaced = replaceAutoApproveSandbox(text, mode)
|
|
138
|
+
if (!replaced.changed) {
|
|
139
|
+
return { ok: true, status: ensured.status === 'already' ? 'unchanged' : ensured.status, needRestart: Boolean(ensured.needRestart), sandbox: mode }
|
|
140
|
+
}
|
|
141
|
+
writeFileSync(patchPath, replaced.text, 'utf8')
|
|
142
|
+
return { ok: true, status: 'updated', needRestart: true, sandbox: mode }
|
|
143
|
+
} catch (e) {
|
|
144
|
+
return { ok: false, status: 'error', needRestart: false, sandbox: mode, error: String((e && e.message) || e) }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 把旧预设显示名「自动审批(Flash)」改成「自动审批」。 */
|
|
149
|
+
export function migratePresetCopy(patchPath) {
|
|
150
|
+
try {
|
|
151
|
+
const text = readFileSync(patchPath, 'utf8')
|
|
152
|
+
const next = text
|
|
153
|
+
.replace(/name:\s*自动审批(Flash)/g, 'name: 自动审批')
|
|
154
|
+
.replace(
|
|
155
|
+
/description:\s*Flash 预判写入\/命令是否不可回补:安全自动批准,有风险转人工审批。/g,
|
|
156
|
+
'description: 审核模型预判写入/命令是否不可回补:安全自动批准,有风险转人工审批。',
|
|
157
|
+
)
|
|
158
|
+
.replace(
|
|
159
|
+
/description:\s*判定模型预判写入\/命令是否不可回补:安全自动批准,有风险转人工审批。/g,
|
|
160
|
+
'description: 审核模型预判写入/命令是否不可回补:安全自动批准,有风险转人工审批。',
|
|
161
|
+
)
|
|
162
|
+
if (next !== text) writeFileSync(patchPath, next, 'utf8')
|
|
163
|
+
} catch { /* ignore */ }
|
|
164
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
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
|
+
}
|