From 7e7a8a31ad96623d68b4347b9b29759c3efee003 Mon Sep 17 00:00:00 2001 From: Miku Date: Fri, 7 Aug 2026 12:08:14 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(plugin):=20qq-direct-notify=20v2=20?= =?UTF-8?q?=E8=A2=AB=E5=8A=A8=E6=B6=88=E6=81=AF=E5=85=8D=E9=A2=9D=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 QQ 事件监听(WebSocket 连接官方 gateway,shard [0,2] 与 Hermes [0,1] 共存) - 订阅 C2C_MESSAGE_CREATE 事件,记录用户最近 msg_id - 发送时带 msg_id → 被动消息(回复)→ 不受主动消息额度限制(11255) - 48h 内用户有互动即生效;超时退化为主动消息 - 修复:READY 后清除建连超时(此前误判超时无限重连) - 修复:错误日志打印完整响应(code/err_code/trace_id) - 修复:msg_seq 0-65535 范围递增(超范围返回 40011000) - 修复:shard 冲突([0,1] 与 Hermes 冲突改为 [0,2]) - 事件名修正 C2C_MSG_RECEIVE → C2C_MESSAGE_CREATE --- plugins/available/qq-direct-notify.js | 277 ++++++++++++++++++++++++-- 1 file changed, 257 insertions(+), 20 deletions(-) diff --git a/plugins/available/qq-direct-notify.js b/plugins/available/qq-direct-notify.js index e19be24..ba22fbd 100644 --- a/plugins/available/qq-direct-notify.js +++ b/plugins/available/qq-direct-notify.js @@ -1,4 +1,5 @@ const https = require('https'); +const WebSocket = require('ws'); /** * QQ 官方 Bot API 直连通知插件 @@ -7,6 +8,11 @@ const https = require('https'); * 不依赖任何中间服务(NapCat / Hermes webhook / 反代): * Miotify → QQ 官方 Bot API → 主人 QQ * + * 被动消息免额度: + * - 插件启动时通过 WebSocket 订阅 QQ 事件(C2C_MSG_RECEIVE),记录主人最近一条消息的 msg_id + * - 发送时带上 msg_id → 视为"回复"(被动消息)→ 不受主动消息额度限制(11255) + * - 用户 48h 内与机器人有过互动时生效;超时退化为主动消息(额度内) + * * 配置(插件管理界面填写,或环境变量注入,环境变量优先): * appId QQ 开放平台机器人的 AppID * clientSecret QQ 开放平台机器人的 ClientSecret @@ -21,17 +27,18 @@ const https = require('https'); * QQ_APP_ID / QQ_CLIENT_SECRET / QQ_TARGET_OPENID * * QQ API: - * 获取 token: POST https://bots.qq.com/app/getAppAccessToken {appId, clientSecret} - * 发 C2C 消息: POST https://api.sgroup.qq.com/v2/users/{openid}/messages - * Authorization: QQBot {access_token} - * Body: {content, msg_type: 0(文本), msg_seq} + * 获取 token: POST https://bots.qq.com/app/getAppAccessToken {appId, clientSecret} + * 获取 gateway: GET https://api.sgroup.qq.com/gateway (Authorization: QQBot {token}) + * 发 C2C 消息: POST https://api.sgroup.qq.com/v2/users/{openid}/messages + * Authorization: QQBot {access_token} + * Body: {content, msg_type: 0(文本), msg_seq, msg_id(可选,被动消息)} */ module.exports = { meta: { id: 'qq-direct-notify', name: 'QQ Direct Notify', - version: '1.0.0', - description: '收到消息后直接调用 QQ 官方 Bot API 推送到主人 QQ(无中间依赖)', + version: '2.0.0', + description: '收到消息后直接调用 QQ 官方 Bot API 推送到主人 QQ(无中间依赖,被动消息免额度)', author: 'Miotify', license: 'MIT', homepage: 'https://github.com/mikus-loli/Miotify', @@ -111,17 +118,20 @@ module.exports = { init: (ctx) => { const { config, log } = ctx; - const ok = (process.env.QQ_APP_ID || config.appId) - && (process.env.QQ_CLIENT_SECRET || config.clientSecret) - && (process.env.QQ_TARGET_OPENID || config.targetOpenId); - if (!ok) { + const appId = process.env.QQ_APP_ID || config.appId; + const clientSecret = process.env.QQ_CLIENT_SECRET || config.clientSecret; + if (!appId || !clientSecret) { log('warn', 'QQ 凭据未配置(环境变量或插件配置),QQ 通知不会生效'); - } else { - log('info', 'QQ Direct Notify 就绪'); + return; } + log('info', 'QQ Direct Notify 就绪'); + // 启动事件监听(记录主人最近 msg_id,发送时走被动消息免额度) + startListener(appId, clientSecret, log); }, - destroy: () => {}, + destroy: () => { + stopListener(); + }, }; // SQLite UTC 时间 → 本地时区显示 @@ -140,6 +150,9 @@ function formatLocalTime(utcStr) { const TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken'; const API_BASE = 'https://api.sgroup.qq.com'; +const API_GATEWAY_PATH = '/gateway'; +// intents: 1<<25 = C2C_GROUP_AT_MESSAGES(含 C2C 单聊 C2C_MSG_RECEIVE) +const INTENTS_C2C = 1 << 25; let cachedToken = null; let cachedExpiresAt = 0; @@ -194,18 +207,23 @@ function getAccessToken(appId, clientSecret, log) { } // 发送 C2C 文本消息;token 失效时自动刷新重试一次 -function sendC2CText(token, openid, content, log) { +function sendC2CText(token, openid, content, msgId, log) { return new Promise((resolve, reject) => { - const body = JSON.stringify({ + const body = { content, msg_type: 0, msg_seq: nextMsgSeq(), - }); + }; + // 带 msg_id = 被动消息(回复用户最近一条消息),不受主动消息额度限制 + if (msgId) { + body.msg_id = msgId; + } + const bodyStr = JSON.stringify(body); const req = https.request(`${API_BASE}/v2/users/${encodeURIComponent(openid)}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body), + 'Content-Length': Buffer.byteLength(bodyStr), 'Authorization': `QQBot ${token}`, }, timeout: 10000, @@ -227,7 +245,7 @@ function sendC2CText(token, openid, content, log) { }); req.on('error', reject); req.on('timeout', () => { req.destroy(new Error('发送超时')); }); - req.write(body); + req.write(bodyStr); req.end(); }); } @@ -244,8 +262,9 @@ async function sendWithRetry(appId, clientSecret, openid, content, retries, log) for (let attempt = 0; attempt <= (retries || 0); attempt++) { try { - await sendC2CText(token, openid, content, log); - log('info', `QQ API 发送成功(第 ${attempt + 1} 次尝试)`); + const msgId = getLastMsgId(openid); + await sendC2CText(token, openid, content, msgId, log); + log('info', `QQ API 发送成功(第 ${attempt + 1} 次尝试${msgId ? ',被动消息' : ',主动消息'})`); return true; } catch (err) { const msg = err.message || ''; @@ -271,3 +290,221 @@ async function sendWithRetry(appId, clientSecret, openid, content, retries, log) } return false; } + +// --------------------------------------------------------------------------- +// 事件监听(WebSocket):记录用户最近 msg_id,发送时走被动消息免额度 +// 参考 https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html +// --------------------------------------------------------------------------- + +const lastMsgIdByUser = new Map(); // openid → 最近一条消息的 msg_id +let ws = null; +let heartbeatTimer = null; +let reconnectTimer = null; +let lastS = null; +let heartbeatIntervalMs = 45000; +let stopRequested = false; +let reconnectAttempts = 0; +let connectTimeoutId = null; // 建连阶段超时(收到 READY 后清除) + +function getLastMsgId(openid) { + return lastMsgIdByUser.get(openid) || null; +} + +// 获取 WSS 接入点 +function getGatewayUrl(token) { + return new Promise((resolve, reject) => { + const req = https.request(`${API_BASE}${API_GATEWAY_PATH}`, { + method: 'GET', + headers: { + 'Authorization': `QQBot ${token}`, + 'Content-Length': 0, + }, + timeout: 10000, + }, (res) => { + let raw = ''; + res.on('data', (c) => { raw += c; }); + res.on('end', () => { + try { + const data = JSON.parse(raw); + if (data.url) return resolve(data.url); + reject(new Error(`gateway 响应异常: ${raw.slice(0, 150)}`)); + } catch (err) { + reject(new Error(`gateway 解析失败: ${err.message}`)); + } + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(new Error('gateway 请求超时')); }); + req.end(); + }); +} + +async function startListener(appId, clientSecret, log) { + stopRequested = false; + reconnectAttempts = 0; + log('info', 'QQ 事件监听启动中(订阅 C2C 消息,用于被动消息免额度)...'); + try { + await connectLoop(appId, clientSecret, log); + } catch (err) { + log('error', `QQ 事件监听启动失败: ${err.message}(不影响主动消息发送)`); + } +} + +async function connectLoop(appId, clientSecret, log) { + while (!stopRequested) { + try { + await connectOnce(appId, clientSecret, log); + } catch (err) { + log('warn', `QQ 事件监听断开: ${err.message}`); + } + if (stopRequested) break; + // 指数退避重连 + reconnectAttempts = Math.min(reconnectAttempts + 1, 6); + const delay = Math.min(5000 * Math.pow(2, reconnectAttempts), 300000); + log('info', `QQ 事件监听 ${delay / 1000}s 后重连(第 ${reconnectAttempts} 次)`); + await sleep(delay); + } +} + +function connectOnce(appId, clientSecret, log) { + return new Promise((resolve, reject) => { + let settled = false; + let timeoutId = null; + + getAccessToken(appId, clientSecret, log).then((token) => + getGatewayUrl(token) + ).then((url) => { + log('info', `QQ 事件 gateway: ${url}`); + ws = new WebSocket(url, { handshakeTimeout: 10000 }); + + ws.on('open', () => { + reconnectAttempts = 0; + log('info', 'QQ 事件 WebSocket 已连接'); + }); + + ws.on('message', (data) => { + try { + const msg = JSON.parse(data.toString()); + handleGatewayMessage(msg, appId, clientSecret, log); + } catch (err) { + log('warn', `QQ 事件解析失败: ${err.message}`); + } + }); + + ws.on('close', () => { + clearHeartbeat(); + if (connectTimeoutId) { clearTimeout(connectTimeoutId); connectTimeoutId = null; } + if (!settled) { + settled = true; + resolve(); + } + }); + + ws.on('error', (err) => { + if (connectTimeoutId) { clearTimeout(connectTimeoutId); connectTimeoutId = null; } + if (!settled) { + settled = true; + clearTimeout(timeoutId); + reject(new Error(err.message)); + } + }); + }).catch((err) => { + if (!settled) { + settled = true; + reject(err); + } + }); + + timeoutId = setTimeout(() => { + if (!settled) { + settled = true; + reject(new Error('事件连接超时')); + } + }, 15000); + connectTimeoutId = timeoutId; + }); +} + +function handleGatewayMessage(msg, appId, clientSecret, log) { + const op = msg.op; + if (op === 10) { + // Hello:拿到心跳间隔,发送 Identify + heartbeatIntervalMs = (msg.d && msg.d.heartbeat_interval) || 45000; + sendIdentify(appId, clientSecret, log); + } else if (op === 0) { + // Dispatch:事件推送 + if (msg.s !== undefined) lastS = msg.s; + if (msg.t === 'C2C_MESSAGE_CREATE' && msg.d) { + const msgId = msg.d.id; + const userOpenid = msg.d.author && msg.d.author.user_openid; + if (msgId && userOpenid) { + lastMsgIdByUser.set(userOpenid, msgId); + log('info', `QQ C2C 事件已记录 msg_id(openid ${userOpenid.slice(0, 8)}...)`); + } + } + // 首次 READY 后启动心跳,并清除建连超时(连接已就绪) + if (msg.t === 'READY') { + if (connectTimeoutId) { clearTimeout(connectTimeoutId); connectTimeoutId = null; } + startHeartbeat(); + reconnectAttempts = 0; + // 记录目标用户(如已有)——实际 msg_id 在 C2C_MSG_RECEIVE 时记录 + log('info', 'QQ 事件监听就绪(READY,订阅 C2C 消息)'); + } + } else if (op === 11) { + // Heartbeat ACK + } +} + +function sendIdentify(appId, clientSecret, log) { + getAccessToken(appId, clientSecret, log).then((token) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + op: 2, + d: { + token: `QQBot ${token}`, + intents: INTENTS_C2C, + // shard 用 [0,2]:与 Hermes gateway 的 [0,1] 区分,避免同 shard 冲突(QQ 同一机器人多连接需不同 shard) + shard: [0, 2], + properties: { $os: 'linux', $browser: 'miotify', $device: 'miotify' }, + }, + })); + log('info', 'QQ 事件 Identify 已发送'); + } + }).catch((err) => { + log('warn', `Identify 获取 token 失败: ${err.message}`); + }); +} + +function startHeartbeat() { + clearHeartbeat(); + heartbeatTimer = setInterval(() => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ op: 1, d: lastS })); + } + }, heartbeatIntervalMs); + if (heartbeatTimer.unref) heartbeatTimer.unref(); +} + +function clearHeartbeat() { + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } +} + +function stopListener() { + stopRequested = true; + clearHeartbeat(); + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (ws) { + try { ws.close(); } catch { /* 忽略 */ } + ws = null; + } +} + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} From 899ef09a09623821e7ea8288a0cf6ba9c8d7e645 Mon Sep 17 00:00:00 2001 From: Miku Date: Fri, 7 Aug 2026 12:09:10 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs(plugin):=20=E4=BF=AE=E6=AD=A3=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E4=BA=8B=E4=BB=B6=E5=90=8D=20C2C=5FMSG=5FRECEIVE=20?= =?UTF-8?q?=E2=86=92=20C2C=5FMESSAGE=5FCREATE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/available/qq-direct-notify.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/available/qq-direct-notify.js b/plugins/available/qq-direct-notify.js index ba22fbd..bb86099 100644 --- a/plugins/available/qq-direct-notify.js +++ b/plugins/available/qq-direct-notify.js @@ -9,7 +9,7 @@ const WebSocket = require('ws'); * Miotify → QQ 官方 Bot API → 主人 QQ * * 被动消息免额度: - * - 插件启动时通过 WebSocket 订阅 QQ 事件(C2C_MSG_RECEIVE),记录主人最近一条消息的 msg_id + * - 插件启动时通过 WebSocket 订阅 QQ 事件(C2C_MESSAGE_CREATE),记录主人最近一条消息的 msg_id * - 发送时带上 msg_id → 视为"回复"(被动消息)→ 不受主动消息额度限制(11255) * - 用户 48h 内与机器人有过互动时生效;超时退化为主动消息(额度内) *