@dsh-plugin/dsh-synapse 0.1.0-dev.41044228975 → 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 +661 -661
- package/README.md +0 -14
- package/package.json +1 -1
- package/vendor/gugle-chat-bot/LICENSE +661 -661
- package/vendor/gugle-chat-bot/package.json +57 -57
- package/vendor/gugle-chat-bot/src/allow-rule.yml +9345 -9345
- package/vendor/gugle-chat-bot/src/api.json +4284 -4284
- package/vendor/gugle-chat-bot/src/command/index.ts +141 -141
- package/vendor/gugle-chat-bot/src/config/features.ts +547 -547
- package/vendor/gugle-chat-bot/src/config/index.ts +184 -184
- package/vendor/gugle-chat-bot/src/config/manager.ts +356 -356
- package/vendor/gugle-chat-bot/src/constants/index.ts +13 -13
- package/vendor/gugle-chat-bot/src/custom.ts +548 -548
- package/vendor/gugle-chat-bot/src/event/index.ts +118 -118
- package/vendor/gugle-chat-bot/src/features/admin/index.ts +414 -414
- package/vendor/gugle-chat-bot/src/features/bili/image/impl/video.ts +39 -39
- package/vendor/gugle-chat-bot/src/features/bili/image/index.ts +37 -37
- package/vendor/gugle-chat-bot/src/features/bili/index.ts +65 -65
- package/vendor/gugle-chat-bot/src/features/bili/template/style/video.css +97 -97
- package/vendor/gugle-chat-bot/src/features/bili/template/video.html +56 -56
- package/vendor/gugle-chat-bot/src/features/discord-bridge/index.ts +1128 -1128
- package/vendor/gugle-chat-bot/src/features/discord-bridge/markdown.ts +105 -105
- package/vendor/gugle-chat-bot/src/features/discord-bridge/slash.ts +355 -355
- package/vendor/gugle-chat-bot/src/features/discord-bridge/source.ts +96 -96
- package/vendor/gugle-chat-bot/src/features/discord-bridge/template/message.html +14 -14
- package/vendor/gugle-chat-bot/src/features/discord-bridge/template/style/message.css +144 -144
- package/vendor/gugle-chat-bot/src/features/github/api.ts +93 -93
- package/vendor/gugle-chat-bot/src/features/github/binding.ts +161 -161
- package/vendor/gugle-chat-bot/src/features/github/image/impl/issue.ts +132 -132
- package/vendor/gugle-chat-bot/src/features/github/image/impl/pr.ts +144 -144
- package/vendor/gugle-chat-bot/src/features/github/image/impl/release.ts +160 -160
- package/vendor/gugle-chat-bot/src/features/github/image/index.ts +310 -310
- package/vendor/gugle-chat-bot/src/features/github/index.ts +302 -302
- package/vendor/gugle-chat-bot/src/features/github/template/issue.html +118 -118
- package/vendor/gugle-chat-bot/src/features/github/template/pull_request.html +125 -125
- package/vendor/gugle-chat-bot/src/features/github/template/release.html +53 -53
- package/vendor/gugle-chat-bot/src/features/github/template/style/issue.css +468 -468
- package/vendor/gugle-chat-bot/src/features/github/template/style/pull_request.css +468 -468
- package/vendor/gugle-chat-bot/src/features/github/template/style/release.css +412 -412
- package/vendor/gugle-chat-bot/src/features/hermes/client.ts +184 -184
- package/vendor/gugle-chat-bot/src/features/hermes/config.ts +210 -210
- package/vendor/gugle-chat-bot/src/features/hermes/context.ts +67 -67
- package/vendor/gugle-chat-bot/src/features/hermes/history.ts +86 -86
- package/vendor/gugle-chat-bot/src/features/hermes/index.ts +1533 -1533
- package/vendor/gugle-chat-bot/src/features/hermes/message.ts +319 -319
- package/vendor/gugle-chat-bot/src/features/hermes/reaction.ts +181 -181
- package/vendor/gugle-chat-bot/src/features/hermes/render.ts +118 -118
- package/vendor/gugle-chat-bot/src/features/hermes/reply-units.ts +331 -331
- package/vendor/gugle-chat-bot/src/features/hermes/skills.ts +406 -406
- package/vendor/gugle-chat-bot/src/features/hermes/template/approval.html +30 -30
- package/vendor/gugle-chat-bot/src/features/hermes/template/progress.html +21 -21
- package/vendor/gugle-chat-bot/src/features/hermes/template/style/approval.css +81 -81
- package/vendor/gugle-chat-bot/src/features/hermes/template/style/progress.css +120 -120
- package/vendor/gugle-chat-bot/src/features/hermes/trigger.ts +86 -86
- package/vendor/gugle-chat-bot/src/features/hermes/types.ts +327 -327
- package/vendor/gugle-chat-bot/src/features/management/index.ts +156 -156
- package/vendor/gugle-chat-bot/src/features/minecraft/index.ts +127 -127
- package/vendor/gugle-chat-bot/src/features/modrinth/index.ts +71 -71
- package/vendor/gugle-chat-bot/src/features/parentheses/index.ts +117 -117
- package/vendor/gugle-chat-bot/src/features/peak-valley-timer/index.ts +451 -451
- package/vendor/gugle-chat-bot/src/features/poke/index.ts +24 -24
- package/vendor/gugle-chat-bot/src/features/version-tracker/index.ts +77 -77
- package/vendor/gugle-chat-bot/src/features/welcome/index.ts +57 -57
- package/vendor/gugle-chat-bot/src/github-ip.json +5814 -5814
- package/vendor/gugle-chat-bot/src/image/index.ts +278 -278
- package/vendor/gugle-chat-bot/src/index.ts +695 -695
- package/vendor/gugle-chat-bot/src/logger/index.ts +54 -54
- package/vendor/gugle-chat-bot/src/result.json +359 -359
- package/vendor/gugle-chat-bot/src/type/bili.d.ts +825 -825
- package/vendor/gugle-chat-bot/src/type/event.d.ts +146 -146
- package/vendor/gugle-chat-bot/src/type/github.d.ts +446 -446
- package/vendor/gugle-chat-bot/src/type/index.d.ts +254 -254
- package/vendor/gugle-chat-bot/tsconfig.json +46 -46
- package/scripts/dump-npm-latest.mjs +0 -30
|
@@ -1,695 +1,695 @@
|
|
|
1
|
-
import process from 'node:process';
|
|
2
|
-
import { Logger } from 'winston';
|
|
3
|
-
import { RawData, WebSocket } from 'ws';
|
|
4
|
-
import { botConfig, BotConfig } from '@/config';
|
|
5
|
-
import { getFeatureGroups, isOperator } from '@/config/features';
|
|
6
|
-
import fs from 'node:fs';
|
|
7
|
-
import dayjs from 'dayjs';
|
|
8
|
-
import { LoggerFactory } from '@/logger';
|
|
9
|
-
import { EventManager } from 'gugle-event';
|
|
10
|
-
import { GroupMessageWSMSG, LoginInfo, LoginInfoData, Message, NodeMessage, SentMessage, WSMSG } from '@/type';
|
|
11
|
-
import axios, { AxiosInstance } from 'axios';
|
|
12
|
-
import { Github } from '@/features/github';
|
|
13
|
-
import * as cron from 'node-cron';
|
|
14
|
-
import { BotEvent, BotEventCancelable, EventCallback } from '@/type/event';
|
|
15
|
-
import { CommandManager, CommandSource } from 'gugle-command';
|
|
16
|
-
import { BotCommandSource, executeCommand } from '@/command';
|
|
17
|
-
import { restartStartDelayMs } from '@/features/admin';
|
|
18
|
-
|
|
19
|
-
/** bot.sendGroupMsg 的附加选项 */
|
|
20
|
-
export interface SendGroupMsgOptions {
|
|
21
|
-
/**
|
|
22
|
-
* 标记该消息由互通桥自身发出(Discord→QQ 转发、/send 结果等)。
|
|
23
|
-
* 发送出口监听器据此跳过,避免「转发出去的消息又被转发回 Discord」形成回环。
|
|
24
|
-
*/
|
|
25
|
-
fromBridge?: boolean;
|
|
26
|
-
/**
|
|
27
|
-
* 标记该消息由 webhook 事件推送(GitHub issue/PR/release 订阅通知)。
|
|
28
|
-
* 互通桥据此尊重频道门控:不转发到 need_cmd / need_reply 为 true 的频道
|
|
29
|
-
* (命令回复、AI 对话等交互式机器人消息不受此限)。
|
|
30
|
-
*/
|
|
31
|
-
fromWebhook?: boolean;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** bot.sendGroupForwardMsg 的合并转发节点 */
|
|
35
|
-
export interface ForwardNode {
|
|
36
|
-
/** 节点署名(缺省用 bot 昵称/QQ 号由调用方决定) */
|
|
37
|
-
name?: string;
|
|
38
|
-
uin?: string | number;
|
|
39
|
-
/** 节点内容:纯文本或消息段数组 */
|
|
40
|
-
content: string | Message[];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** bot.sendGroupMsg 成功后的通知负载(供互通桥把机器人自身消息转发到 Discord) */
|
|
44
|
-
export interface GroupMessageSentEvent {
|
|
45
|
-
groupId: number;
|
|
46
|
-
message: SentMessage;
|
|
47
|
-
/** OneBot 返回的消息 id;失败时为 undefined */
|
|
48
|
-
messageId: number | undefined;
|
|
49
|
-
/** 是否由互通桥自身发出(true 时监听方应忽略) */
|
|
50
|
-
fromBridge: boolean;
|
|
51
|
-
/** 是否由 webhook 事件推送(true 时互通桥尊重频道门控) */
|
|
52
|
-
fromWebhook: boolean;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export class GroupMsgCommandSource implements BotCommandSource {
|
|
56
|
-
public readonly bot: QQBot;
|
|
57
|
-
public readonly msg: GroupMessageWSMSG;
|
|
58
|
-
private readonly replayMsg: Message[];
|
|
59
|
-
|
|
60
|
-
public constructor(bot: QQBot, msg: GroupMessageWSMSG) {
|
|
61
|
-
this.bot = bot;
|
|
62
|
-
this.msg = msg;
|
|
63
|
-
this.replayMsg = [
|
|
64
|
-
{
|
|
65
|
-
type: 'reply',
|
|
66
|
-
data: {
|
|
67
|
-
id: this.msg.message_id
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
];
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
public getUserId(): string {
|
|
74
|
-
return String(this.msg.sender.user_id);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
public getGroupId(): number | undefined {
|
|
78
|
-
return this.msg.group_id;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
public isAdmin(): boolean {
|
|
82
|
-
return isOperator(this.msg.sender.user_id);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
public success(message: string): void {
|
|
86
|
-
bot.sendGroupMsg(this.msg.group_id, [
|
|
87
|
-
...this.replayMsg,
|
|
88
|
-
{
|
|
89
|
-
type: 'text',
|
|
90
|
-
data: {
|
|
91
|
-
text: message
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
]);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
public fail(message: string): void {
|
|
98
|
-
bot.sendGroupMsg(this.msg.group_id, [
|
|
99
|
-
...this.replayMsg,
|
|
100
|
-
{
|
|
101
|
-
type: 'text',
|
|
102
|
-
data: {
|
|
103
|
-
text: message
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
]);
|
|
107
|
-
bot.logger?.error(`[${this.msg.sender.nickname}|${this.msg.sender.user_id}] ${message}: ${this.msg.raw_message}`);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
public getName(): string {
|
|
111
|
-
return this.msg.sender.nickname;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
public hasPermission(permission: string): boolean {
|
|
115
|
-
if (!permission) return true;
|
|
116
|
-
const getPermissionLevel = (text: string): number => {
|
|
117
|
-
const level = Number.parseInt(text);
|
|
118
|
-
if (Number.isNaN(level)) {
|
|
119
|
-
return text === 'owner' ? 2 : text === 'admin' ? 1 : 0;
|
|
120
|
-
}
|
|
121
|
-
return Number.parseInt(text);
|
|
122
|
-
};
|
|
123
|
-
const permissionLevel: number = getPermissionLevel(this.msg.sender.role);
|
|
124
|
-
const needPermissionLevel: number = getPermissionLevel(permission);
|
|
125
|
-
bot.logger?.debug(
|
|
126
|
-
`permission: ${permission}, role: ${this.msg.sender.role}, permissionLevel: ${permissionLevel}, needPermissionLevel: ${needPermissionLevel}, ${permissionLevel >= needPermissionLevel}`
|
|
127
|
-
);
|
|
128
|
-
return permissionLevel >= needPermissionLevel;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export class QQBot {
|
|
133
|
-
/** 断线后的最大重连次数 */
|
|
134
|
-
private static readonly MAX_RECONNECT_ATTEMPTS = 10;
|
|
135
|
-
/** 重连基础退避时长(毫秒):第 n 次重试等待 BASE * 2^(n-1) */
|
|
136
|
-
private static readonly RECONNECT_BASE_DELAY_MS = 2000;
|
|
137
|
-
/** 单次退避上限(毫秒):指数增长到此值后不再增大,避免等待时间失控 */
|
|
138
|
-
private static readonly RECONNECT_MAX_DELAY_MS = 60000;
|
|
139
|
-
|
|
140
|
-
private loginInfo?: LoginInfoData = undefined;
|
|
141
|
-
private path: string = process.cwd();
|
|
142
|
-
logger?: Logger;
|
|
143
|
-
private readonly config: BotConfig;
|
|
144
|
-
public readonly axiosInstance: AxiosInstance;
|
|
145
|
-
private ws?: WebSocket;
|
|
146
|
-
private readonly eventManager: EventManager;
|
|
147
|
-
private readonly commandManager: CommandManager;
|
|
148
|
-
private wsOpened: boolean = false;
|
|
149
|
-
private lastHeartbeatTime: number = 0;
|
|
150
|
-
private checkHeartbeatFunc?: NodeJS.Timeout = undefined;
|
|
151
|
-
private reconnectFunc?: NodeJS.Timeout = undefined;
|
|
152
|
-
private reconnectAttempts: number = 0;
|
|
153
|
-
private stopping: boolean = false;
|
|
154
|
-
private operationQueue: (() => void)[] = [];
|
|
155
|
-
private lastOperationHandle = -1;
|
|
156
|
-
/** bot.sendGroupMsg 成功后的监听器(互通桥用它转发机器人自身消息到 Discord) */
|
|
157
|
-
private groupMessageSentListeners: ((event: GroupMessageSentEvent) => void)[] = [];
|
|
158
|
-
|
|
159
|
-
public constructor(config: BotConfig) {
|
|
160
|
-
this.config = config;
|
|
161
|
-
this.eventManager = new EventManager();
|
|
162
|
-
this.commandManager = new CommandManager();
|
|
163
|
-
this.axiosInstance = axios.create({
|
|
164
|
-
timeout: 15000,
|
|
165
|
-
baseURL: config.httpUrl,
|
|
166
|
-
headers: {
|
|
167
|
-
'Content-Type': 'application/json',
|
|
168
|
-
Authorization: `Bearer ${config.httpToken}`,
|
|
169
|
-
'User-Agent': config.userAgent
|
|
170
|
-
}
|
|
171
|
-
});
|
|
172
|
-
setInterval(() => this.handlerOperationQueue(this), 2000);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* 建立 WebSocket 连接并注册事件处理。
|
|
177
|
-
* 连接失败或断开时由 close 事件统一触发退避重连,因此这里可以安全地重复调用。
|
|
178
|
-
*/
|
|
179
|
-
private connect(): void {
|
|
180
|
-
const bot = this;
|
|
181
|
-
bot.ws = new WebSocket(`${bot.config.wsUrl}/${bot.config.tokenParams}${bot.config.wsToken}`);
|
|
182
|
-
bot.ws.on('open', () => {
|
|
183
|
-
bot.wsOpened = true;
|
|
184
|
-
bot.lastHeartbeatTime = Date.now();
|
|
185
|
-
bot.logger?.info('WebSocket connected');
|
|
186
|
-
bot.checkHeartbeatFunc = setTimeout(() => bot.checkHeartbeat(bot), 40000);
|
|
187
|
-
bot
|
|
188
|
-
.getLoginInfo()
|
|
189
|
-
.then(info => {
|
|
190
|
-
bot.loginInfo = info;
|
|
191
|
-
})
|
|
192
|
-
.catch((error: Error) => {
|
|
193
|
-
bot.logger?.error(`获取登录信息失败:${error.message}`);
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
bot.ws.on('message', rawData => {
|
|
197
|
-
bot.post('websocket-message', bot, rawData).then();
|
|
198
|
-
});
|
|
199
|
-
bot.ws.on('error', (error: Error) => {
|
|
200
|
-
if (error.message.endsWith('401')) {
|
|
201
|
-
bot.logger?.error(`无法连接至 WebSocket 服务器,请检查你的 Token!${error.stack}`);
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
bot.logger?.error(`WebSocket error: ${error.stack ?? error.message}`);
|
|
205
|
-
});
|
|
206
|
-
bot.ws.on('close', (code: number, reason: Buffer) => {
|
|
207
|
-
bot.wsOpened = false;
|
|
208
|
-
clearTimeout(bot.checkHeartbeatFunc);
|
|
209
|
-
bot.logger?.warn(`WebSocket closed: ${code} ${reason.toString('utf-8')}`);
|
|
210
|
-
if (bot.stopping) return;
|
|
211
|
-
bot.scheduleReconnect();
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** 按指数退避安排下一次重连;达到上限后记录错误并停止重连 */
|
|
216
|
-
private scheduleReconnect(): void {
|
|
217
|
-
const bot = this;
|
|
218
|
-
if (bot.reconnectAttempts >= QQBot.MAX_RECONNECT_ATTEMPTS) {
|
|
219
|
-
bot.logger?.error(`WebSocket 重连已达上限(${QQBot.MAX_RECONNECT_ATTEMPTS} 次),停止重连`);
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
bot.reconnectAttempts++;
|
|
223
|
-
const delay = Math.min(
|
|
224
|
-
QQBot.RECONNECT_BASE_DELAY_MS * 2 ** (bot.reconnectAttempts - 1),
|
|
225
|
-
QQBot.RECONNECT_MAX_DELAY_MS
|
|
226
|
-
);
|
|
227
|
-
bot.logger?.warn(
|
|
228
|
-
`WebSocket 连接已断开,${delay}ms 后发起第 ${bot.reconnectAttempts}/${QQBot.MAX_RECONNECT_ATTEMPTS} 次重连`
|
|
229
|
-
);
|
|
230
|
-
bot.reconnectFunc = setTimeout(() => bot.connect(), delay);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
public async start(path: string = process.cwd()): Promise<QQBot> {
|
|
234
|
-
const bot = this;
|
|
235
|
-
return new Promise(resolve => {
|
|
236
|
-
bot.post('before-start', bot, path).then(args => {
|
|
237
|
-
path = args[1];
|
|
238
|
-
bot.path = path;
|
|
239
|
-
const logPath = `${bot.path}/logs`;
|
|
240
|
-
if (!fs.existsSync(logPath)) fs.mkdirSync(logPath);
|
|
241
|
-
if (fs.existsSync(`${logPath}/latest.log`)) {
|
|
242
|
-
let logName = `${logPath}/${dayjs().format('YYYY-MM-DD-HH-mm-ss')}.log`;
|
|
243
|
-
let count = 0;
|
|
244
|
-
while (fs.existsSync(logName)) {
|
|
245
|
-
count++;
|
|
246
|
-
logName = `${logPath}/${dayjs().format('YYYY-MM-DD-HH-mm-ss')}-${count}.log`;
|
|
247
|
-
}
|
|
248
|
-
fs.renameSync(`${logPath}/latest.log`, logName);
|
|
249
|
-
}
|
|
250
|
-
bot.logger = LoggerFactory.createLogger('QQBot', logPath, bot.config.logLevel || 'info');
|
|
251
|
-
bot.logger.info(`QQ Bot starting...`);
|
|
252
|
-
bot.listen('websocket-message', bot.onWebsocketMsg);
|
|
253
|
-
bot.listen('meta-event-heartbeat', bot.onHeartbeat);
|
|
254
|
-
bot.listen('message-event-group', bot.onGroupMsg);
|
|
255
|
-
bot.connect();
|
|
256
|
-
bot.post('command-register', bot, this.commandManager).then();
|
|
257
|
-
bot.post('after-start', bot).then();
|
|
258
|
-
resolve(bot);
|
|
259
|
-
}).catch((error: Error) => {
|
|
260
|
-
bot.logger?.error(`启动流程失败:${error.stack ?? error.message}`);
|
|
261
|
-
resolve(bot);
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
public stop(): QQBot {
|
|
267
|
-
this.stopping = true;
|
|
268
|
-
clearTimeout(this.reconnectFunc);
|
|
269
|
-
clearTimeout(this.checkHeartbeatFunc);
|
|
270
|
-
// 在停止之前触发'before-stop'事件,传递当前实例
|
|
271
|
-
this.post('before-stop', this).then(() => {
|
|
272
|
-
// 如果WebSocket连接是打开的状态,关闭连接
|
|
273
|
-
if (this.wsOpened) this.ws?.close();
|
|
274
|
-
// 在停止之后触发'after-stop'事件,传递当前实例
|
|
275
|
-
this.post('after-stop', this).then();
|
|
276
|
-
});
|
|
277
|
-
return this;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
public async post(event: BotEvent, ...args: any): Promise<any[]> {
|
|
281
|
-
return await this.eventManager.post(event, ...args);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* 心跳超时检测:超过 100 秒未收到心跳视为连接已断开,主动关闭以触发重连。
|
|
286
|
-
* lastHeartbeatTime 记录的是「本次连接开始等待心跳的时刻」,避免刚连上就误判超时。
|
|
287
|
-
*/
|
|
288
|
-
private checkHeartbeat(bot: QQBot) {
|
|
289
|
-
if (Date.now() - bot.lastHeartbeatTime > 100000) {
|
|
290
|
-
bot.logger?.error('WebSocket 心跳超时,重建连接...');
|
|
291
|
-
bot.ws?.terminate();
|
|
292
|
-
} else {
|
|
293
|
-
bot.checkHeartbeatFunc = setTimeout(() => bot.checkHeartbeat(bot), 40000);
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
private handlerOperationQueue(bot: QQBot) {
|
|
298
|
-
const operation = bot.operationQueue.shift();
|
|
299
|
-
if (!operation) return;
|
|
300
|
-
this.lastOperationHandle = dayjs().valueOf();
|
|
301
|
-
operation();
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
private operation(func: () => void) {
|
|
305
|
-
const time = dayjs().valueOf();
|
|
306
|
-
if (time - this.lastOperationHandle > 2000) {
|
|
307
|
-
func();
|
|
308
|
-
this.lastOperationHandle = time;
|
|
309
|
-
} else {
|
|
310
|
-
this.operationQueue.push(func);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
public listen<T extends BotEvent, C extends BotEventCancelable>(
|
|
315
|
-
event: T,
|
|
316
|
-
callback: EventCallback<T, C>,
|
|
317
|
-
namespace: string = 'gugle-event',
|
|
318
|
-
priority: number = 100,
|
|
319
|
-
cancelable: C = false as C
|
|
320
|
-
) {
|
|
321
|
-
this.eventManager.listen(event, callback, namespace, priority, cancelable);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
private onWebsocketMsg(bot: QQBot, data: RawData) {
|
|
325
|
-
let msg: WSMSG;
|
|
326
|
-
try {
|
|
327
|
-
msg = JSON.parse(data.toString('utf-8'));
|
|
328
|
-
} catch (error) {
|
|
329
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
330
|
-
bot.logger?.error(`解析 WebSocket 消息失败:${reason}`);
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
bot.logger?.debug(`Received message: ${JSON.stringify(msg)}`);
|
|
334
|
-
let eventName = 'unknown-event';
|
|
335
|
-
if (msg.post_type == 'meta_event') {
|
|
336
|
-
eventName = `meta-event-${msg.meta_event_type}`;
|
|
337
|
-
}
|
|
338
|
-
if (msg.post_type == 'message') {
|
|
339
|
-
eventName = `message-event-${msg.message_type}`;
|
|
340
|
-
}
|
|
341
|
-
if (msg.post_type == 'notice') {
|
|
342
|
-
eventName = `notice-event-${msg.notice_type}`;
|
|
343
|
-
}
|
|
344
|
-
if (msg.post_type == 'request') {
|
|
345
|
-
eventName = `request-event-${msg.request_type}`;
|
|
346
|
-
}
|
|
347
|
-
eventName = eventName.toLowerCase().replace(/_/g, '-');
|
|
348
|
-
bot.logger?.debug(`post notice event: ${eventName}`);
|
|
349
|
-
bot.post(eventName, bot, msg).then();
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
private onHeartbeat(bot: QQBot, data: RawData) {
|
|
353
|
-
bot.logger?.debug(`Received heartbeat: ${JSON.stringify(data)}`);
|
|
354
|
-
// 收到心跳才说明对端确实在工作,此时才视为连接真正恢复并清零重连计数;
|
|
355
|
-
// 仅握手成功(open)不足以判断,napcat 不可用时也会完成握手后立即断开。
|
|
356
|
-
bot.reconnectAttempts = 0;
|
|
357
|
-
bot.lastHeartbeatTime = Date.now();
|
|
358
|
-
clearTimeout(bot.checkHeartbeatFunc);
|
|
359
|
-
bot.checkHeartbeatFunc = setTimeout(() => bot.checkHeartbeat(bot), 40000);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
private onGroupMsg(bot: QQBot, msg: GroupMessageWSMSG): void {
|
|
363
|
-
if (!getFeatureGroups('command').includes(msg.group_id)) return;
|
|
364
|
-
const command = msg.raw_message;
|
|
365
|
-
if (!command.startsWith('/')) return;
|
|
366
|
-
// 互通桥接的 /send 由 DiscordBridge 自行处理,不走命令分发
|
|
367
|
-
// (否则 gugle-command 找不到 send 节点会回发 Invalid command)
|
|
368
|
-
if (command.startsWith('/send')) {
|
|
369
|
-
void import('@/features/discord-bridge')
|
|
370
|
-
.then(module => {
|
|
371
|
-
// /send 后必须是空白或结尾,/sendxxx 仍交给命令分发
|
|
372
|
-
const rest = command.slice('/send'.length);
|
|
373
|
-
if (rest && !/^\s/.test(rest)) {
|
|
374
|
-
executeCommand(bot.commandManager, new GroupMsgCommandSource(bot, msg), command);
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
if (!module.DiscordBridge.isSendTargetGroup(msg.group_id)) {
|
|
378
|
-
executeCommand(bot.commandManager, new GroupMsgCommandSource(bot, msg), command);
|
|
379
|
-
}
|
|
380
|
-
})
|
|
381
|
-
.catch(e => {
|
|
382
|
-
bot.logger?.error(`DiscordBridge /send dispatch failed: ${e?.message ?? e}`);
|
|
383
|
-
});
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
executeCommand(bot.commandManager, new GroupMsgCommandSource(bot, msg), command);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// ------------------------------------------------
|
|
390
|
-
// Decorators
|
|
391
|
-
// ------------------------------------------------
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
* 定义一个 cron 装饰器,用于根据给定的 cron 表达式调度任务
|
|
395
|
-
*
|
|
396
|
-
* @param _cron cron 表达式,六位格式:秒 分 时 日 月 周;秒与分使用步进语法(星号加斜杠步长)
|
|
397
|
-
* @returns {(executor: (bot: QQBot) => void) => void} 返回一个函数,该函数接受一个执行器函数作为参数,并在指定时间执行该执行器函数
|
|
398
|
-
*
|
|
399
|
-
* @example
|
|
400
|
-
* 每 30 秒执行一次:秒字段写步进 30,其余字段写星号
|
|
401
|
-
*/
|
|
402
|
-
public cron(_cron: string): (executor: (bot: QQBot) => void) => void {
|
|
403
|
-
// 保存当前实例的引用,以便在后续的执行器函数中使用
|
|
404
|
-
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
405
|
-
const self: QQBot = this;
|
|
406
|
-
// 返回一个函数,该函数负责调度执行器函数
|
|
407
|
-
return function (executor: (bot: QQBot) => void) {
|
|
408
|
-
// 表达式非法时 cron.schedule 会直接抛出,这里记录带表达式的错误便于定位
|
|
409
|
-
try {
|
|
410
|
-
cron.schedule(_cron, () => {
|
|
411
|
-
executor(self);
|
|
412
|
-
});
|
|
413
|
-
} catch (error) {
|
|
414
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
415
|
-
self.logger?.error(`cron 表达式非法(${_cron}):${reason}`);
|
|
416
|
-
}
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/**
|
|
421
|
-
* 定义一个事件订阅装饰器,用于根据事件触发回调
|
|
422
|
-
*
|
|
423
|
-
* @param event {string} 事件名称
|
|
424
|
-
* @param namespace {string} 命名空间,用于组织事件监听器
|
|
425
|
-
* @param priority {number} 优先级,决定事件回调的执行顺序
|
|
426
|
-
* @param cancelable {boolean} 是否可取消,决定是否可以取消事件,为 true 时,处理器第一个参数会传入 Cancelable
|
|
427
|
-
* @returns {(callback: (...args: any) => void) => void} 一个函数,接受事件回调并注册该回调到指定事件
|
|
428
|
-
*
|
|
429
|
-
* @example
|
|
430
|
-
* @ bot.subscribe('after-start', true)
|
|
431
|
-
* public test(cancelable: Cancelable, bot: HeyBoxBot) {}
|
|
432
|
-
*/
|
|
433
|
-
public subscribe<T extends BotEvent, C extends BotEventCancelable>(
|
|
434
|
-
event: T,
|
|
435
|
-
cancelable: C = false as C,
|
|
436
|
-
namespace: string = 'gugle-event',
|
|
437
|
-
priority: number = 100
|
|
438
|
-
): (callback: EventCallback<T, C>) => void {
|
|
439
|
-
return this.eventManager.subscribe(event, namespace, priority, cancelable);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// ------------------------------------------------
|
|
443
|
-
// API Methods
|
|
444
|
-
// ------------------------------------------------
|
|
445
|
-
|
|
446
|
-
public sendPrivateMsg(userID: string | number, message: SentMessage): Promise<number | undefined> {
|
|
447
|
-
return new Promise(resolve => {
|
|
448
|
-
this.operation(() => {
|
|
449
|
-
this.axiosInstance
|
|
450
|
-
.post(`/send_private_msg`, {
|
|
451
|
-
user_id: userID,
|
|
452
|
-
message: message
|
|
453
|
-
})
|
|
454
|
-
.then(res => resolve(res?.data?.data?.message_id))
|
|
455
|
-
.catch(error => {
|
|
456
|
-
this.logger?.error(`Failed to send private message to ${userID}: ${error.message}`);
|
|
457
|
-
resolve(undefined);
|
|
458
|
-
});
|
|
459
|
-
});
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
public sendGroupMsg(
|
|
464
|
-
groupId: string | number,
|
|
465
|
-
message: SentMessage,
|
|
466
|
-
options?: SendGroupMsgOptions
|
|
467
|
-
): Promise<number | undefined> {
|
|
468
|
-
return new Promise(resolve => {
|
|
469
|
-
this.operation(() => {
|
|
470
|
-
this.axiosInstance
|
|
471
|
-
.post(`/send_group_msg`, {
|
|
472
|
-
group_id: groupId,
|
|
473
|
-
message: message
|
|
474
|
-
})
|
|
475
|
-
.then(res => {
|
|
476
|
-
const messageId: number | undefined = res?.data?.data?.message_id;
|
|
477
|
-
this.notifyGroupMessageSent({
|
|
478
|
-
groupId: Number(groupId),
|
|
479
|
-
message,
|
|
480
|
-
messageId,
|
|
481
|
-
fromBridge: options?.fromBridge === true,
|
|
482
|
-
fromWebhook: options?.fromWebhook === true
|
|
483
|
-
});
|
|
484
|
-
resolve(messageId);
|
|
485
|
-
})
|
|
486
|
-
.catch(error => {
|
|
487
|
-
this.logger?.error(`Failed to send group message to ${groupId}: ${error.message}`);
|
|
488
|
-
resolve(undefined);
|
|
489
|
-
});
|
|
490
|
-
});
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* 以合并转发(聊天记录)形式发送群消息,用于超长 AI 回复等场景。
|
|
496
|
-
*
|
|
497
|
-
* 结构完全对齐 sendGroupMsg:同样走 operation 队列,失败只记录日志并回传 undefined(不 reject);
|
|
498
|
-
* 成功后照常通知互通桥,DiscordBridge 因而仍能镜像这条消息(它会把 node 段文本拍平后转发)。
|
|
499
|
-
*/
|
|
500
|
-
public sendGroupForwardMsg(
|
|
501
|
-
groupId: string | number,
|
|
502
|
-
nodes: ForwardNode[],
|
|
503
|
-
options?: SendGroupMsgOptions
|
|
504
|
-
): Promise<number | undefined> {
|
|
505
|
-
return new Promise(resolve => {
|
|
506
|
-
this.operation(() => {
|
|
507
|
-
const message: NodeMessage[] = nodes.map(node => QQBot.toForwardNodeMessage(node));
|
|
508
|
-
this.axiosInstance
|
|
509
|
-
.post(`/send_group_forward_msg`, {
|
|
510
|
-
group_id: groupId,
|
|
511
|
-
message: message
|
|
512
|
-
})
|
|
513
|
-
.then(res => {
|
|
514
|
-
const messageId: number | undefined = res?.data?.data?.message_id;
|
|
515
|
-
this.notifyGroupMessageSent({
|
|
516
|
-
groupId: Number(groupId),
|
|
517
|
-
message,
|
|
518
|
-
messageId,
|
|
519
|
-
fromBridge: options?.fromBridge === true,
|
|
520
|
-
fromWebhook: options?.fromWebhook === true
|
|
521
|
-
});
|
|
522
|
-
resolve(messageId);
|
|
523
|
-
})
|
|
524
|
-
.catch(error => {
|
|
525
|
-
this.logger?.error(`Failed to send group forward message to ${groupId}: ${error.message}`);
|
|
526
|
-
resolve(undefined);
|
|
527
|
-
});
|
|
528
|
-
});
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/** 把调用方的 ForwardNode 补成 OneBot 的 node 段(NapCat 口径:昵称与 QQ 号一律字符串化) */
|
|
533
|
-
private static toForwardNodeMessage(node: ForwardNode): NodeMessage {
|
|
534
|
-
// user_id 在类型里声明为 number,但 OneBot 实际接受/回传字符串(NapCat 口径即 String(uin)),这里按载荷需要断言
|
|
535
|
-
return {
|
|
536
|
-
type: 'node',
|
|
537
|
-
data: {
|
|
538
|
-
nickname: String(node.name ?? ''),
|
|
539
|
-
user_id: String(node.uin ?? '') as unknown as number,
|
|
540
|
-
content: typeof node.content === 'string' ? [{ type: 'text', data: { text: node.content } }] : node.content
|
|
541
|
-
}
|
|
542
|
-
};
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
/**
|
|
546
|
-
* 注册「群消息发送成功」监听器。
|
|
547
|
-
* 互通桥借此把机器人自身发出的消息(GitHub 通知、命令回复等)转发到 Discord。
|
|
548
|
-
* 注意:监听器必须忽略 `fromBridge === true` 的事件,避免回环。
|
|
549
|
-
*/
|
|
550
|
-
public onGroupMessageSent(listener: (event: GroupMessageSentEvent) => void): void {
|
|
551
|
-
this.groupMessageSentListeners.push(listener);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
private notifyGroupMessageSent(event: GroupMessageSentEvent): void {
|
|
555
|
-
for (const listener of this.groupMessageSentListeners) {
|
|
556
|
-
try {
|
|
557
|
-
listener(event);
|
|
558
|
-
} catch (e) {
|
|
559
|
-
this.logger?.error(`groupMessageSent listener failed: ${(e as Error)?.message ?? e}`);
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
/** 暴露命令管理器(Discord 侧命令分发需要复用同一套命令树) */
|
|
565
|
-
public getCommandManager(): CommandManager {
|
|
566
|
-
return this.commandManager;
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// 设置禁言
|
|
570
|
-
public ban(groupId: number, userId: number, duration: number) {
|
|
571
|
-
const bot = this;
|
|
572
|
-
this.operation(() => {
|
|
573
|
-
bot.logger?.info(`ban ${userId} in group ${groupId} for ${duration} seconds`);
|
|
574
|
-
bot.axiosInstance
|
|
575
|
-
.post(`/set_group_ban`, {
|
|
576
|
-
group_id: `${groupId}`,
|
|
577
|
-
user_id: `${userId}`,
|
|
578
|
-
duration: duration
|
|
579
|
-
})
|
|
580
|
-
.then();
|
|
581
|
-
});
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
// 踢出群聊
|
|
585
|
-
public kick(groupId: number, userId: number) {
|
|
586
|
-
const bot = this;
|
|
587
|
-
this.operation(() => {
|
|
588
|
-
bot.logger?.info(`kick ${userId} in group`);
|
|
589
|
-
bot.axiosInstance
|
|
590
|
-
.post(`/set_group_kick`, {
|
|
591
|
-
group_id: `${groupId}`,
|
|
592
|
-
user_id: `${userId}`,
|
|
593
|
-
reject_add_request: false
|
|
594
|
-
})
|
|
595
|
-
.then();
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
public getLoginInfo(): Promise<LoginInfoData> {
|
|
600
|
-
return new Promise<LoginInfoData>((resolve, reject) => {
|
|
601
|
-
if (!this.loginInfo) {
|
|
602
|
-
this.axiosInstance
|
|
603
|
-
.get<LoginInfo>(`/get_login_info`)
|
|
604
|
-
.then(res => resolve(res.data.data))
|
|
605
|
-
.catch(reject);
|
|
606
|
-
} else {
|
|
607
|
-
resolve(this.loginInfo);
|
|
608
|
-
}
|
|
609
|
-
});
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
public getUserInfo(userId: number) {
|
|
613
|
-
const bot = this;
|
|
614
|
-
return new Promise<{ user_id: string; nickname: string; qid: string }>((resolve, reject) => {
|
|
615
|
-
this.operation(() => {
|
|
616
|
-
bot.axiosInstance
|
|
617
|
-
.post(`/get_stranger_info`, {
|
|
618
|
-
user_id: `${userId}`
|
|
619
|
-
})
|
|
620
|
-
.then(res => resolve(res.data.data))
|
|
621
|
-
.then();
|
|
622
|
-
});
|
|
623
|
-
});
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
public getLoginInfoSync(): LoginInfoData | undefined {
|
|
627
|
-
return this.loginInfo;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
public approveGroupAddRequest(flag: string) {
|
|
631
|
-
const bot = this;
|
|
632
|
-
this.operation(() => {
|
|
633
|
-
bot.axiosInstance
|
|
634
|
-
.post(`/set_group_add_request`, {
|
|
635
|
-
flag: flag,
|
|
636
|
-
sub_type: 'add',
|
|
637
|
-
approve: true
|
|
638
|
-
})
|
|
639
|
-
.then();
|
|
640
|
-
});
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
public denyGroupAddRequest(flag: string, reason?: string) {
|
|
644
|
-
const bot = this;
|
|
645
|
-
this.operation(() => {
|
|
646
|
-
bot.axiosInstance
|
|
647
|
-
.post(`/set_group_add_request`, {
|
|
648
|
-
flag: flag,
|
|
649
|
-
sub_type: 'add',
|
|
650
|
-
approve: false,
|
|
651
|
-
reason: reason
|
|
652
|
-
})
|
|
653
|
-
.then();
|
|
654
|
-
});
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
export const bot = new QQBot(botConfig);
|
|
659
|
-
|
|
660
|
-
// 全局兜底:单个异步任务失败(如 B 站返回 412、网络超时)不应终止机器人进程。
|
|
661
|
-
// 未处理的 Promise rejection 与未捕获异常统一记录日志后继续运行。
|
|
662
|
-
process.on('unhandledRejection', reason => {
|
|
663
|
-
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
664
|
-
(bot.logger ?? console).error(`Unhandled promise rejection: ${error.stack ?? error.message}`);
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
process.on('uncaughtException', error => {
|
|
668
|
-
(bot.logger ?? console).error(`Uncaught exception: ${error.stack ?? error.message}`);
|
|
669
|
-
});
|
|
670
|
-
|
|
671
|
-
require('@/custom');
|
|
672
|
-
|
|
673
|
-
/**
|
|
674
|
-
* 启动机器人(含 /reboot 拉起时的延迟启动)。
|
|
675
|
-
*
|
|
676
|
-
* /reboot 会先拉起新进程再退出旧进程;若新进程立刻连接 NapCat,会与旧进程的
|
|
677
|
-
* 同号连接冲突(互踢 / 消息丢失)。因此被 /reboot 拉起时按 QQBOT_START_DELAY_MS
|
|
678
|
-
* 延迟启动,给旧进程留出断开时间。正常启动该延迟为 0,行为不变。
|
|
679
|
-
*/
|
|
680
|
-
async function startBot(): Promise<void> {
|
|
681
|
-
const restartDelay = restartStartDelayMs();
|
|
682
|
-
if (restartDelay > 0) {
|
|
683
|
-
(bot.logger ?? console).info(
|
|
684
|
-
`[Admin] 本进程由 /reboot 拉起,延迟 ${restartDelay}ms 启动以避免与旧连接重叠`
|
|
685
|
-
);
|
|
686
|
-
await new Promise(resolve => setTimeout(resolve, restartDelay));
|
|
687
|
-
}
|
|
688
|
-
const started = await bot.start();
|
|
689
|
-
const github = new Github(started);
|
|
690
|
-
github.start(botConfig.githubPort);
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
startBot().catch(error => {
|
|
694
|
-
(bot.logger ?? console).error(`启动失败: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
695
|
-
});
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { Logger } from 'winston';
|
|
3
|
+
import { RawData, WebSocket } from 'ws';
|
|
4
|
+
import { botConfig, BotConfig } from '@/config';
|
|
5
|
+
import { getFeatureGroups, isOperator } from '@/config/features';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import dayjs from 'dayjs';
|
|
8
|
+
import { LoggerFactory } from '@/logger';
|
|
9
|
+
import { EventManager } from 'gugle-event';
|
|
10
|
+
import { GroupMessageWSMSG, LoginInfo, LoginInfoData, Message, NodeMessage, SentMessage, WSMSG } from '@/type';
|
|
11
|
+
import axios, { AxiosInstance } from 'axios';
|
|
12
|
+
import { Github } from '@/features/github';
|
|
13
|
+
import * as cron from 'node-cron';
|
|
14
|
+
import { BotEvent, BotEventCancelable, EventCallback } from '@/type/event';
|
|
15
|
+
import { CommandManager, CommandSource } from 'gugle-command';
|
|
16
|
+
import { BotCommandSource, executeCommand } from '@/command';
|
|
17
|
+
import { restartStartDelayMs } from '@/features/admin';
|
|
18
|
+
|
|
19
|
+
/** bot.sendGroupMsg 的附加选项 */
|
|
20
|
+
export interface SendGroupMsgOptions {
|
|
21
|
+
/**
|
|
22
|
+
* 标记该消息由互通桥自身发出(Discord→QQ 转发、/send 结果等)。
|
|
23
|
+
* 发送出口监听器据此跳过,避免「转发出去的消息又被转发回 Discord」形成回环。
|
|
24
|
+
*/
|
|
25
|
+
fromBridge?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* 标记该消息由 webhook 事件推送(GitHub issue/PR/release 订阅通知)。
|
|
28
|
+
* 互通桥据此尊重频道门控:不转发到 need_cmd / need_reply 为 true 的频道
|
|
29
|
+
* (命令回复、AI 对话等交互式机器人消息不受此限)。
|
|
30
|
+
*/
|
|
31
|
+
fromWebhook?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** bot.sendGroupForwardMsg 的合并转发节点 */
|
|
35
|
+
export interface ForwardNode {
|
|
36
|
+
/** 节点署名(缺省用 bot 昵称/QQ 号由调用方决定) */
|
|
37
|
+
name?: string;
|
|
38
|
+
uin?: string | number;
|
|
39
|
+
/** 节点内容:纯文本或消息段数组 */
|
|
40
|
+
content: string | Message[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** bot.sendGroupMsg 成功后的通知负载(供互通桥把机器人自身消息转发到 Discord) */
|
|
44
|
+
export interface GroupMessageSentEvent {
|
|
45
|
+
groupId: number;
|
|
46
|
+
message: SentMessage;
|
|
47
|
+
/** OneBot 返回的消息 id;失败时为 undefined */
|
|
48
|
+
messageId: number | undefined;
|
|
49
|
+
/** 是否由互通桥自身发出(true 时监听方应忽略) */
|
|
50
|
+
fromBridge: boolean;
|
|
51
|
+
/** 是否由 webhook 事件推送(true 时互通桥尊重频道门控) */
|
|
52
|
+
fromWebhook: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class GroupMsgCommandSource implements BotCommandSource {
|
|
56
|
+
public readonly bot: QQBot;
|
|
57
|
+
public readonly msg: GroupMessageWSMSG;
|
|
58
|
+
private readonly replayMsg: Message[];
|
|
59
|
+
|
|
60
|
+
public constructor(bot: QQBot, msg: GroupMessageWSMSG) {
|
|
61
|
+
this.bot = bot;
|
|
62
|
+
this.msg = msg;
|
|
63
|
+
this.replayMsg = [
|
|
64
|
+
{
|
|
65
|
+
type: 'reply',
|
|
66
|
+
data: {
|
|
67
|
+
id: this.msg.message_id
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public getUserId(): string {
|
|
74
|
+
return String(this.msg.sender.user_id);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
public getGroupId(): number | undefined {
|
|
78
|
+
return this.msg.group_id;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public isAdmin(): boolean {
|
|
82
|
+
return isOperator(this.msg.sender.user_id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public success(message: string): void {
|
|
86
|
+
bot.sendGroupMsg(this.msg.group_id, [
|
|
87
|
+
...this.replayMsg,
|
|
88
|
+
{
|
|
89
|
+
type: 'text',
|
|
90
|
+
data: {
|
|
91
|
+
text: message
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
public fail(message: string): void {
|
|
98
|
+
bot.sendGroupMsg(this.msg.group_id, [
|
|
99
|
+
...this.replayMsg,
|
|
100
|
+
{
|
|
101
|
+
type: 'text',
|
|
102
|
+
data: {
|
|
103
|
+
text: message
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
]);
|
|
107
|
+
bot.logger?.error(`[${this.msg.sender.nickname}|${this.msg.sender.user_id}] ${message}: ${this.msg.raw_message}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
public getName(): string {
|
|
111
|
+
return this.msg.sender.nickname;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
public hasPermission(permission: string): boolean {
|
|
115
|
+
if (!permission) return true;
|
|
116
|
+
const getPermissionLevel = (text: string): number => {
|
|
117
|
+
const level = Number.parseInt(text);
|
|
118
|
+
if (Number.isNaN(level)) {
|
|
119
|
+
return text === 'owner' ? 2 : text === 'admin' ? 1 : 0;
|
|
120
|
+
}
|
|
121
|
+
return Number.parseInt(text);
|
|
122
|
+
};
|
|
123
|
+
const permissionLevel: number = getPermissionLevel(this.msg.sender.role);
|
|
124
|
+
const needPermissionLevel: number = getPermissionLevel(permission);
|
|
125
|
+
bot.logger?.debug(
|
|
126
|
+
`permission: ${permission}, role: ${this.msg.sender.role}, permissionLevel: ${permissionLevel}, needPermissionLevel: ${needPermissionLevel}, ${permissionLevel >= needPermissionLevel}`
|
|
127
|
+
);
|
|
128
|
+
return permissionLevel >= needPermissionLevel;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export class QQBot {
|
|
133
|
+
/** 断线后的最大重连次数 */
|
|
134
|
+
private static readonly MAX_RECONNECT_ATTEMPTS = 10;
|
|
135
|
+
/** 重连基础退避时长(毫秒):第 n 次重试等待 BASE * 2^(n-1) */
|
|
136
|
+
private static readonly RECONNECT_BASE_DELAY_MS = 2000;
|
|
137
|
+
/** 单次退避上限(毫秒):指数增长到此值后不再增大,避免等待时间失控 */
|
|
138
|
+
private static readonly RECONNECT_MAX_DELAY_MS = 60000;
|
|
139
|
+
|
|
140
|
+
private loginInfo?: LoginInfoData = undefined;
|
|
141
|
+
private path: string = process.cwd();
|
|
142
|
+
logger?: Logger;
|
|
143
|
+
private readonly config: BotConfig;
|
|
144
|
+
public readonly axiosInstance: AxiosInstance;
|
|
145
|
+
private ws?: WebSocket;
|
|
146
|
+
private readonly eventManager: EventManager;
|
|
147
|
+
private readonly commandManager: CommandManager;
|
|
148
|
+
private wsOpened: boolean = false;
|
|
149
|
+
private lastHeartbeatTime: number = 0;
|
|
150
|
+
private checkHeartbeatFunc?: NodeJS.Timeout = undefined;
|
|
151
|
+
private reconnectFunc?: NodeJS.Timeout = undefined;
|
|
152
|
+
private reconnectAttempts: number = 0;
|
|
153
|
+
private stopping: boolean = false;
|
|
154
|
+
private operationQueue: (() => void)[] = [];
|
|
155
|
+
private lastOperationHandle = -1;
|
|
156
|
+
/** bot.sendGroupMsg 成功后的监听器(互通桥用它转发机器人自身消息到 Discord) */
|
|
157
|
+
private groupMessageSentListeners: ((event: GroupMessageSentEvent) => void)[] = [];
|
|
158
|
+
|
|
159
|
+
public constructor(config: BotConfig) {
|
|
160
|
+
this.config = config;
|
|
161
|
+
this.eventManager = new EventManager();
|
|
162
|
+
this.commandManager = new CommandManager();
|
|
163
|
+
this.axiosInstance = axios.create({
|
|
164
|
+
timeout: 15000,
|
|
165
|
+
baseURL: config.httpUrl,
|
|
166
|
+
headers: {
|
|
167
|
+
'Content-Type': 'application/json',
|
|
168
|
+
Authorization: `Bearer ${config.httpToken}`,
|
|
169
|
+
'User-Agent': config.userAgent
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
setInterval(() => this.handlerOperationQueue(this), 2000);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 建立 WebSocket 连接并注册事件处理。
|
|
177
|
+
* 连接失败或断开时由 close 事件统一触发退避重连,因此这里可以安全地重复调用。
|
|
178
|
+
*/
|
|
179
|
+
private connect(): void {
|
|
180
|
+
const bot = this;
|
|
181
|
+
bot.ws = new WebSocket(`${bot.config.wsUrl}/${bot.config.tokenParams}${bot.config.wsToken}`);
|
|
182
|
+
bot.ws.on('open', () => {
|
|
183
|
+
bot.wsOpened = true;
|
|
184
|
+
bot.lastHeartbeatTime = Date.now();
|
|
185
|
+
bot.logger?.info('WebSocket connected');
|
|
186
|
+
bot.checkHeartbeatFunc = setTimeout(() => bot.checkHeartbeat(bot), 40000);
|
|
187
|
+
bot
|
|
188
|
+
.getLoginInfo()
|
|
189
|
+
.then(info => {
|
|
190
|
+
bot.loginInfo = info;
|
|
191
|
+
})
|
|
192
|
+
.catch((error: Error) => {
|
|
193
|
+
bot.logger?.error(`获取登录信息失败:${error.message}`);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
bot.ws.on('message', rawData => {
|
|
197
|
+
bot.post('websocket-message', bot, rawData).then();
|
|
198
|
+
});
|
|
199
|
+
bot.ws.on('error', (error: Error) => {
|
|
200
|
+
if (error.message.endsWith('401')) {
|
|
201
|
+
bot.logger?.error(`无法连接至 WebSocket 服务器,请检查你的 Token!${error.stack}`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
bot.logger?.error(`WebSocket error: ${error.stack ?? error.message}`);
|
|
205
|
+
});
|
|
206
|
+
bot.ws.on('close', (code: number, reason: Buffer) => {
|
|
207
|
+
bot.wsOpened = false;
|
|
208
|
+
clearTimeout(bot.checkHeartbeatFunc);
|
|
209
|
+
bot.logger?.warn(`WebSocket closed: ${code} ${reason.toString('utf-8')}`);
|
|
210
|
+
if (bot.stopping) return;
|
|
211
|
+
bot.scheduleReconnect();
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 按指数退避安排下一次重连;达到上限后记录错误并停止重连 */
|
|
216
|
+
private scheduleReconnect(): void {
|
|
217
|
+
const bot = this;
|
|
218
|
+
if (bot.reconnectAttempts >= QQBot.MAX_RECONNECT_ATTEMPTS) {
|
|
219
|
+
bot.logger?.error(`WebSocket 重连已达上限(${QQBot.MAX_RECONNECT_ATTEMPTS} 次),停止重连`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
bot.reconnectAttempts++;
|
|
223
|
+
const delay = Math.min(
|
|
224
|
+
QQBot.RECONNECT_BASE_DELAY_MS * 2 ** (bot.reconnectAttempts - 1),
|
|
225
|
+
QQBot.RECONNECT_MAX_DELAY_MS
|
|
226
|
+
);
|
|
227
|
+
bot.logger?.warn(
|
|
228
|
+
`WebSocket 连接已断开,${delay}ms 后发起第 ${bot.reconnectAttempts}/${QQBot.MAX_RECONNECT_ATTEMPTS} 次重连`
|
|
229
|
+
);
|
|
230
|
+
bot.reconnectFunc = setTimeout(() => bot.connect(), delay);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
public async start(path: string = process.cwd()): Promise<QQBot> {
|
|
234
|
+
const bot = this;
|
|
235
|
+
return new Promise(resolve => {
|
|
236
|
+
bot.post('before-start', bot, path).then(args => {
|
|
237
|
+
path = args[1];
|
|
238
|
+
bot.path = path;
|
|
239
|
+
const logPath = `${bot.path}/logs`;
|
|
240
|
+
if (!fs.existsSync(logPath)) fs.mkdirSync(logPath);
|
|
241
|
+
if (fs.existsSync(`${logPath}/latest.log`)) {
|
|
242
|
+
let logName = `${logPath}/${dayjs().format('YYYY-MM-DD-HH-mm-ss')}.log`;
|
|
243
|
+
let count = 0;
|
|
244
|
+
while (fs.existsSync(logName)) {
|
|
245
|
+
count++;
|
|
246
|
+
logName = `${logPath}/${dayjs().format('YYYY-MM-DD-HH-mm-ss')}-${count}.log`;
|
|
247
|
+
}
|
|
248
|
+
fs.renameSync(`${logPath}/latest.log`, logName);
|
|
249
|
+
}
|
|
250
|
+
bot.logger = LoggerFactory.createLogger('QQBot', logPath, bot.config.logLevel || 'info');
|
|
251
|
+
bot.logger.info(`QQ Bot starting...`);
|
|
252
|
+
bot.listen('websocket-message', bot.onWebsocketMsg);
|
|
253
|
+
bot.listen('meta-event-heartbeat', bot.onHeartbeat);
|
|
254
|
+
bot.listen('message-event-group', bot.onGroupMsg);
|
|
255
|
+
bot.connect();
|
|
256
|
+
bot.post('command-register', bot, this.commandManager).then();
|
|
257
|
+
bot.post('after-start', bot).then();
|
|
258
|
+
resolve(bot);
|
|
259
|
+
}).catch((error: Error) => {
|
|
260
|
+
bot.logger?.error(`启动流程失败:${error.stack ?? error.message}`);
|
|
261
|
+
resolve(bot);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
public stop(): QQBot {
|
|
267
|
+
this.stopping = true;
|
|
268
|
+
clearTimeout(this.reconnectFunc);
|
|
269
|
+
clearTimeout(this.checkHeartbeatFunc);
|
|
270
|
+
// 在停止之前触发'before-stop'事件,传递当前实例
|
|
271
|
+
this.post('before-stop', this).then(() => {
|
|
272
|
+
// 如果WebSocket连接是打开的状态,关闭连接
|
|
273
|
+
if (this.wsOpened) this.ws?.close();
|
|
274
|
+
// 在停止之后触发'after-stop'事件,传递当前实例
|
|
275
|
+
this.post('after-stop', this).then();
|
|
276
|
+
});
|
|
277
|
+
return this;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
public async post(event: BotEvent, ...args: any): Promise<any[]> {
|
|
281
|
+
return await this.eventManager.post(event, ...args);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 心跳超时检测:超过 100 秒未收到心跳视为连接已断开,主动关闭以触发重连。
|
|
286
|
+
* lastHeartbeatTime 记录的是「本次连接开始等待心跳的时刻」,避免刚连上就误判超时。
|
|
287
|
+
*/
|
|
288
|
+
private checkHeartbeat(bot: QQBot) {
|
|
289
|
+
if (Date.now() - bot.lastHeartbeatTime > 100000) {
|
|
290
|
+
bot.logger?.error('WebSocket 心跳超时,重建连接...');
|
|
291
|
+
bot.ws?.terminate();
|
|
292
|
+
} else {
|
|
293
|
+
bot.checkHeartbeatFunc = setTimeout(() => bot.checkHeartbeat(bot), 40000);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private handlerOperationQueue(bot: QQBot) {
|
|
298
|
+
const operation = bot.operationQueue.shift();
|
|
299
|
+
if (!operation) return;
|
|
300
|
+
this.lastOperationHandle = dayjs().valueOf();
|
|
301
|
+
operation();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private operation(func: () => void) {
|
|
305
|
+
const time = dayjs().valueOf();
|
|
306
|
+
if (time - this.lastOperationHandle > 2000) {
|
|
307
|
+
func();
|
|
308
|
+
this.lastOperationHandle = time;
|
|
309
|
+
} else {
|
|
310
|
+
this.operationQueue.push(func);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
public listen<T extends BotEvent, C extends BotEventCancelable>(
|
|
315
|
+
event: T,
|
|
316
|
+
callback: EventCallback<T, C>,
|
|
317
|
+
namespace: string = 'gugle-event',
|
|
318
|
+
priority: number = 100,
|
|
319
|
+
cancelable: C = false as C
|
|
320
|
+
) {
|
|
321
|
+
this.eventManager.listen(event, callback, namespace, priority, cancelable);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private onWebsocketMsg(bot: QQBot, data: RawData) {
|
|
325
|
+
let msg: WSMSG;
|
|
326
|
+
try {
|
|
327
|
+
msg = JSON.parse(data.toString('utf-8'));
|
|
328
|
+
} catch (error) {
|
|
329
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
330
|
+
bot.logger?.error(`解析 WebSocket 消息失败:${reason}`);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
bot.logger?.debug(`Received message: ${JSON.stringify(msg)}`);
|
|
334
|
+
let eventName = 'unknown-event';
|
|
335
|
+
if (msg.post_type == 'meta_event') {
|
|
336
|
+
eventName = `meta-event-${msg.meta_event_type}`;
|
|
337
|
+
}
|
|
338
|
+
if (msg.post_type == 'message') {
|
|
339
|
+
eventName = `message-event-${msg.message_type}`;
|
|
340
|
+
}
|
|
341
|
+
if (msg.post_type == 'notice') {
|
|
342
|
+
eventName = `notice-event-${msg.notice_type}`;
|
|
343
|
+
}
|
|
344
|
+
if (msg.post_type == 'request') {
|
|
345
|
+
eventName = `request-event-${msg.request_type}`;
|
|
346
|
+
}
|
|
347
|
+
eventName = eventName.toLowerCase().replace(/_/g, '-');
|
|
348
|
+
bot.logger?.debug(`post notice event: ${eventName}`);
|
|
349
|
+
bot.post(eventName, bot, msg).then();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private onHeartbeat(bot: QQBot, data: RawData) {
|
|
353
|
+
bot.logger?.debug(`Received heartbeat: ${JSON.stringify(data)}`);
|
|
354
|
+
// 收到心跳才说明对端确实在工作,此时才视为连接真正恢复并清零重连计数;
|
|
355
|
+
// 仅握手成功(open)不足以判断,napcat 不可用时也会完成握手后立即断开。
|
|
356
|
+
bot.reconnectAttempts = 0;
|
|
357
|
+
bot.lastHeartbeatTime = Date.now();
|
|
358
|
+
clearTimeout(bot.checkHeartbeatFunc);
|
|
359
|
+
bot.checkHeartbeatFunc = setTimeout(() => bot.checkHeartbeat(bot), 40000);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private onGroupMsg(bot: QQBot, msg: GroupMessageWSMSG): void {
|
|
363
|
+
if (!getFeatureGroups('command').includes(msg.group_id)) return;
|
|
364
|
+
const command = msg.raw_message;
|
|
365
|
+
if (!command.startsWith('/')) return;
|
|
366
|
+
// 互通桥接的 /send 由 DiscordBridge 自行处理,不走命令分发
|
|
367
|
+
// (否则 gugle-command 找不到 send 节点会回发 Invalid command)
|
|
368
|
+
if (command.startsWith('/send')) {
|
|
369
|
+
void import('@/features/discord-bridge')
|
|
370
|
+
.then(module => {
|
|
371
|
+
// /send 后必须是空白或结尾,/sendxxx 仍交给命令分发
|
|
372
|
+
const rest = command.slice('/send'.length);
|
|
373
|
+
if (rest && !/^\s/.test(rest)) {
|
|
374
|
+
executeCommand(bot.commandManager, new GroupMsgCommandSource(bot, msg), command);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (!module.DiscordBridge.isSendTargetGroup(msg.group_id)) {
|
|
378
|
+
executeCommand(bot.commandManager, new GroupMsgCommandSource(bot, msg), command);
|
|
379
|
+
}
|
|
380
|
+
})
|
|
381
|
+
.catch(e => {
|
|
382
|
+
bot.logger?.error(`DiscordBridge /send dispatch failed: ${e?.message ?? e}`);
|
|
383
|
+
});
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
executeCommand(bot.commandManager, new GroupMsgCommandSource(bot, msg), command);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ------------------------------------------------
|
|
390
|
+
// Decorators
|
|
391
|
+
// ------------------------------------------------
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* 定义一个 cron 装饰器,用于根据给定的 cron 表达式调度任务
|
|
395
|
+
*
|
|
396
|
+
* @param _cron cron 表达式,六位格式:秒 分 时 日 月 周;秒与分使用步进语法(星号加斜杠步长)
|
|
397
|
+
* @returns {(executor: (bot: QQBot) => void) => void} 返回一个函数,该函数接受一个执行器函数作为参数,并在指定时间执行该执行器函数
|
|
398
|
+
*
|
|
399
|
+
* @example
|
|
400
|
+
* 每 30 秒执行一次:秒字段写步进 30,其余字段写星号
|
|
401
|
+
*/
|
|
402
|
+
public cron(_cron: string): (executor: (bot: QQBot) => void) => void {
|
|
403
|
+
// 保存当前实例的引用,以便在后续的执行器函数中使用
|
|
404
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
405
|
+
const self: QQBot = this;
|
|
406
|
+
// 返回一个函数,该函数负责调度执行器函数
|
|
407
|
+
return function (executor: (bot: QQBot) => void) {
|
|
408
|
+
// 表达式非法时 cron.schedule 会直接抛出,这里记录带表达式的错误便于定位
|
|
409
|
+
try {
|
|
410
|
+
cron.schedule(_cron, () => {
|
|
411
|
+
executor(self);
|
|
412
|
+
});
|
|
413
|
+
} catch (error) {
|
|
414
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
415
|
+
self.logger?.error(`cron 表达式非法(${_cron}):${reason}`);
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* 定义一个事件订阅装饰器,用于根据事件触发回调
|
|
422
|
+
*
|
|
423
|
+
* @param event {string} 事件名称
|
|
424
|
+
* @param namespace {string} 命名空间,用于组织事件监听器
|
|
425
|
+
* @param priority {number} 优先级,决定事件回调的执行顺序
|
|
426
|
+
* @param cancelable {boolean} 是否可取消,决定是否可以取消事件,为 true 时,处理器第一个参数会传入 Cancelable
|
|
427
|
+
* @returns {(callback: (...args: any) => void) => void} 一个函数,接受事件回调并注册该回调到指定事件
|
|
428
|
+
*
|
|
429
|
+
* @example
|
|
430
|
+
* @ bot.subscribe('after-start', true)
|
|
431
|
+
* public test(cancelable: Cancelable, bot: HeyBoxBot) {}
|
|
432
|
+
*/
|
|
433
|
+
public subscribe<T extends BotEvent, C extends BotEventCancelable>(
|
|
434
|
+
event: T,
|
|
435
|
+
cancelable: C = false as C,
|
|
436
|
+
namespace: string = 'gugle-event',
|
|
437
|
+
priority: number = 100
|
|
438
|
+
): (callback: EventCallback<T, C>) => void {
|
|
439
|
+
return this.eventManager.subscribe(event, namespace, priority, cancelable);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ------------------------------------------------
|
|
443
|
+
// API Methods
|
|
444
|
+
// ------------------------------------------------
|
|
445
|
+
|
|
446
|
+
public sendPrivateMsg(userID: string | number, message: SentMessage): Promise<number | undefined> {
|
|
447
|
+
return new Promise(resolve => {
|
|
448
|
+
this.operation(() => {
|
|
449
|
+
this.axiosInstance
|
|
450
|
+
.post(`/send_private_msg`, {
|
|
451
|
+
user_id: userID,
|
|
452
|
+
message: message
|
|
453
|
+
})
|
|
454
|
+
.then(res => resolve(res?.data?.data?.message_id))
|
|
455
|
+
.catch(error => {
|
|
456
|
+
this.logger?.error(`Failed to send private message to ${userID}: ${error.message}`);
|
|
457
|
+
resolve(undefined);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
public sendGroupMsg(
|
|
464
|
+
groupId: string | number,
|
|
465
|
+
message: SentMessage,
|
|
466
|
+
options?: SendGroupMsgOptions
|
|
467
|
+
): Promise<number | undefined> {
|
|
468
|
+
return new Promise(resolve => {
|
|
469
|
+
this.operation(() => {
|
|
470
|
+
this.axiosInstance
|
|
471
|
+
.post(`/send_group_msg`, {
|
|
472
|
+
group_id: groupId,
|
|
473
|
+
message: message
|
|
474
|
+
})
|
|
475
|
+
.then(res => {
|
|
476
|
+
const messageId: number | undefined = res?.data?.data?.message_id;
|
|
477
|
+
this.notifyGroupMessageSent({
|
|
478
|
+
groupId: Number(groupId),
|
|
479
|
+
message,
|
|
480
|
+
messageId,
|
|
481
|
+
fromBridge: options?.fromBridge === true,
|
|
482
|
+
fromWebhook: options?.fromWebhook === true
|
|
483
|
+
});
|
|
484
|
+
resolve(messageId);
|
|
485
|
+
})
|
|
486
|
+
.catch(error => {
|
|
487
|
+
this.logger?.error(`Failed to send group message to ${groupId}: ${error.message}`);
|
|
488
|
+
resolve(undefined);
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* 以合并转发(聊天记录)形式发送群消息,用于超长 AI 回复等场景。
|
|
496
|
+
*
|
|
497
|
+
* 结构完全对齐 sendGroupMsg:同样走 operation 队列,失败只记录日志并回传 undefined(不 reject);
|
|
498
|
+
* 成功后照常通知互通桥,DiscordBridge 因而仍能镜像这条消息(它会把 node 段文本拍平后转发)。
|
|
499
|
+
*/
|
|
500
|
+
public sendGroupForwardMsg(
|
|
501
|
+
groupId: string | number,
|
|
502
|
+
nodes: ForwardNode[],
|
|
503
|
+
options?: SendGroupMsgOptions
|
|
504
|
+
): Promise<number | undefined> {
|
|
505
|
+
return new Promise(resolve => {
|
|
506
|
+
this.operation(() => {
|
|
507
|
+
const message: NodeMessage[] = nodes.map(node => QQBot.toForwardNodeMessage(node));
|
|
508
|
+
this.axiosInstance
|
|
509
|
+
.post(`/send_group_forward_msg`, {
|
|
510
|
+
group_id: groupId,
|
|
511
|
+
message: message
|
|
512
|
+
})
|
|
513
|
+
.then(res => {
|
|
514
|
+
const messageId: number | undefined = res?.data?.data?.message_id;
|
|
515
|
+
this.notifyGroupMessageSent({
|
|
516
|
+
groupId: Number(groupId),
|
|
517
|
+
message,
|
|
518
|
+
messageId,
|
|
519
|
+
fromBridge: options?.fromBridge === true,
|
|
520
|
+
fromWebhook: options?.fromWebhook === true
|
|
521
|
+
});
|
|
522
|
+
resolve(messageId);
|
|
523
|
+
})
|
|
524
|
+
.catch(error => {
|
|
525
|
+
this.logger?.error(`Failed to send group forward message to ${groupId}: ${error.message}`);
|
|
526
|
+
resolve(undefined);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** 把调用方的 ForwardNode 补成 OneBot 的 node 段(NapCat 口径:昵称与 QQ 号一律字符串化) */
|
|
533
|
+
private static toForwardNodeMessage(node: ForwardNode): NodeMessage {
|
|
534
|
+
// user_id 在类型里声明为 number,但 OneBot 实际接受/回传字符串(NapCat 口径即 String(uin)),这里按载荷需要断言
|
|
535
|
+
return {
|
|
536
|
+
type: 'node',
|
|
537
|
+
data: {
|
|
538
|
+
nickname: String(node.name ?? ''),
|
|
539
|
+
user_id: String(node.uin ?? '') as unknown as number,
|
|
540
|
+
content: typeof node.content === 'string' ? [{ type: 'text', data: { text: node.content } }] : node.content
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* 注册「群消息发送成功」监听器。
|
|
547
|
+
* 互通桥借此把机器人自身发出的消息(GitHub 通知、命令回复等)转发到 Discord。
|
|
548
|
+
* 注意:监听器必须忽略 `fromBridge === true` 的事件,避免回环。
|
|
549
|
+
*/
|
|
550
|
+
public onGroupMessageSent(listener: (event: GroupMessageSentEvent) => void): void {
|
|
551
|
+
this.groupMessageSentListeners.push(listener);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private notifyGroupMessageSent(event: GroupMessageSentEvent): void {
|
|
555
|
+
for (const listener of this.groupMessageSentListeners) {
|
|
556
|
+
try {
|
|
557
|
+
listener(event);
|
|
558
|
+
} catch (e) {
|
|
559
|
+
this.logger?.error(`groupMessageSent listener failed: ${(e as Error)?.message ?? e}`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** 暴露命令管理器(Discord 侧命令分发需要复用同一套命令树) */
|
|
565
|
+
public getCommandManager(): CommandManager {
|
|
566
|
+
return this.commandManager;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// 设置禁言
|
|
570
|
+
public ban(groupId: number, userId: number, duration: number) {
|
|
571
|
+
const bot = this;
|
|
572
|
+
this.operation(() => {
|
|
573
|
+
bot.logger?.info(`ban ${userId} in group ${groupId} for ${duration} seconds`);
|
|
574
|
+
bot.axiosInstance
|
|
575
|
+
.post(`/set_group_ban`, {
|
|
576
|
+
group_id: `${groupId}`,
|
|
577
|
+
user_id: `${userId}`,
|
|
578
|
+
duration: duration
|
|
579
|
+
})
|
|
580
|
+
.then();
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// 踢出群聊
|
|
585
|
+
public kick(groupId: number, userId: number) {
|
|
586
|
+
const bot = this;
|
|
587
|
+
this.operation(() => {
|
|
588
|
+
bot.logger?.info(`kick ${userId} in group`);
|
|
589
|
+
bot.axiosInstance
|
|
590
|
+
.post(`/set_group_kick`, {
|
|
591
|
+
group_id: `${groupId}`,
|
|
592
|
+
user_id: `${userId}`,
|
|
593
|
+
reject_add_request: false
|
|
594
|
+
})
|
|
595
|
+
.then();
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
public getLoginInfo(): Promise<LoginInfoData> {
|
|
600
|
+
return new Promise<LoginInfoData>((resolve, reject) => {
|
|
601
|
+
if (!this.loginInfo) {
|
|
602
|
+
this.axiosInstance
|
|
603
|
+
.get<LoginInfo>(`/get_login_info`)
|
|
604
|
+
.then(res => resolve(res.data.data))
|
|
605
|
+
.catch(reject);
|
|
606
|
+
} else {
|
|
607
|
+
resolve(this.loginInfo);
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
public getUserInfo(userId: number) {
|
|
613
|
+
const bot = this;
|
|
614
|
+
return new Promise<{ user_id: string; nickname: string; qid: string }>((resolve, reject) => {
|
|
615
|
+
this.operation(() => {
|
|
616
|
+
bot.axiosInstance
|
|
617
|
+
.post(`/get_stranger_info`, {
|
|
618
|
+
user_id: `${userId}`
|
|
619
|
+
})
|
|
620
|
+
.then(res => resolve(res.data.data))
|
|
621
|
+
.then();
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
public getLoginInfoSync(): LoginInfoData | undefined {
|
|
627
|
+
return this.loginInfo;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
public approveGroupAddRequest(flag: string) {
|
|
631
|
+
const bot = this;
|
|
632
|
+
this.operation(() => {
|
|
633
|
+
bot.axiosInstance
|
|
634
|
+
.post(`/set_group_add_request`, {
|
|
635
|
+
flag: flag,
|
|
636
|
+
sub_type: 'add',
|
|
637
|
+
approve: true
|
|
638
|
+
})
|
|
639
|
+
.then();
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
public denyGroupAddRequest(flag: string, reason?: string) {
|
|
644
|
+
const bot = this;
|
|
645
|
+
this.operation(() => {
|
|
646
|
+
bot.axiosInstance
|
|
647
|
+
.post(`/set_group_add_request`, {
|
|
648
|
+
flag: flag,
|
|
649
|
+
sub_type: 'add',
|
|
650
|
+
approve: false,
|
|
651
|
+
reason: reason
|
|
652
|
+
})
|
|
653
|
+
.then();
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export const bot = new QQBot(botConfig);
|
|
659
|
+
|
|
660
|
+
// 全局兜底:单个异步任务失败(如 B 站返回 412、网络超时)不应终止机器人进程。
|
|
661
|
+
// 未处理的 Promise rejection 与未捕获异常统一记录日志后继续运行。
|
|
662
|
+
process.on('unhandledRejection', reason => {
|
|
663
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
664
|
+
(bot.logger ?? console).error(`Unhandled promise rejection: ${error.stack ?? error.message}`);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
process.on('uncaughtException', error => {
|
|
668
|
+
(bot.logger ?? console).error(`Uncaught exception: ${error.stack ?? error.message}`);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
require('@/custom');
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* 启动机器人(含 /reboot 拉起时的延迟启动)。
|
|
675
|
+
*
|
|
676
|
+
* /reboot 会先拉起新进程再退出旧进程;若新进程立刻连接 NapCat,会与旧进程的
|
|
677
|
+
* 同号连接冲突(互踢 / 消息丢失)。因此被 /reboot 拉起时按 QQBOT_START_DELAY_MS
|
|
678
|
+
* 延迟启动,给旧进程留出断开时间。正常启动该延迟为 0,行为不变。
|
|
679
|
+
*/
|
|
680
|
+
async function startBot(): Promise<void> {
|
|
681
|
+
const restartDelay = restartStartDelayMs();
|
|
682
|
+
if (restartDelay > 0) {
|
|
683
|
+
(bot.logger ?? console).info(
|
|
684
|
+
`[Admin] 本进程由 /reboot 拉起,延迟 ${restartDelay}ms 启动以避免与旧连接重叠`
|
|
685
|
+
);
|
|
686
|
+
await new Promise(resolve => setTimeout(resolve, restartDelay));
|
|
687
|
+
}
|
|
688
|
+
const started = await bot.start();
|
|
689
|
+
const github = new Github(started);
|
|
690
|
+
github.start(botConfig.githubPort);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
startBot().catch(error => {
|
|
694
|
+
(bot.logger ?? console).error(`启动失败: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
695
|
+
});
|