Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 257 additions & 20 deletions plugins/available/qq-direct-notify.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const https = require('https');
const WebSocket = require('ws');

/**
* QQ 官方 Bot API 直连通知插件
Expand All @@ -7,6 +8,11 @@ const https = require('https');
* 不依赖任何中间服务(NapCat / Hermes webhook / 反代):
* Miotify → QQ 官方 Bot API → 主人 QQ
*
* 被动消息免额度:
* - 插件启动时通过 WebSocket 订阅 QQ 事件(C2C_MESSAGE_CREATE),记录主人最近一条消息的 msg_id
* - 发送时带上 msg_id → 视为"回复"(被动消息)→ 不受主动消息额度限制(11255)
* - 用户 48h 内与机器人有过互动时生效;超时退化为主动消息(额度内)
*
* 配置(插件管理界面填写,或环境变量注入,环境变量优先):
* appId QQ 开放平台机器人的 AppID
* clientSecret QQ 开放平台机器人的 ClientSecret
Expand All @@ -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',
Expand Down Expand Up @@ -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 时间 → 本地时区显示
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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();
});
}
Expand All @@ -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 || '';
Expand All @@ -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));
}
Loading