@nestim/koishi-plugin-qq-group-manager 0.1.3
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/lib/commands.d.ts +4 -0
- package/lib/commands.js +212 -0
- package/lib/index.d.ts +99 -0
- package/lib/index.js +187 -0
- package/lib/service.d.ts +167 -0
- package/lib/service.js +3160 -0
- package/lib/types.d.ts +23 -0
- package/lib/types.js +2 -0
- package/package.json +37 -0
- package/readme.md +203 -0
package/lib/service.js
ADDED
|
@@ -0,0 +1,3160 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.QQGroupManagerService = void 0;
|
|
7
|
+
const koishi_1 = require("koishi");
|
|
8
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
11
|
+
const node_child_process_1 = require("node:child_process");
|
|
12
|
+
const node_util_1 = require("node:util");
|
|
13
|
+
const execFile = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
14
|
+
class QQGroupManagerService extends koishi_1.Service {
|
|
15
|
+
ctx;
|
|
16
|
+
config;
|
|
17
|
+
_logger;
|
|
18
|
+
unauthorizedMuteAttempts = new Map();
|
|
19
|
+
menuPairings = new Map();
|
|
20
|
+
aiBuffers = new Map();
|
|
21
|
+
aiCounters = new Map();
|
|
22
|
+
aiLastReplyAt = new Map();
|
|
23
|
+
aiInFlight = new Set();
|
|
24
|
+
aiHandledEvents = new Map();
|
|
25
|
+
aiFollowups = new Map();
|
|
26
|
+
aiUserMemory = new Map();
|
|
27
|
+
repeatStates = new Map();
|
|
28
|
+
aiTopicMemory = new Map();
|
|
29
|
+
pendingJoinRequests = new Map();
|
|
30
|
+
pendingJoinRequestFlags = new Map();
|
|
31
|
+
violationRecords = new Map();
|
|
32
|
+
constructor(ctx, config) {
|
|
33
|
+
super(ctx, 'nestimQqGroupManager', true);
|
|
34
|
+
this.ctx = ctx;
|
|
35
|
+
this.config = config;
|
|
36
|
+
this._logger = ctx.logger('nestim-qq-group-manager');
|
|
37
|
+
ctx.on('message', (session) => this.handleGroupModeration(session));
|
|
38
|
+
ctx.on('guild-member-request', (session) => this.handleGuildMemberRequest(session));
|
|
39
|
+
}
|
|
40
|
+
isPlatformAllowed(platform) {
|
|
41
|
+
return !!platform && this.config.platformFilter.includes(platform);
|
|
42
|
+
}
|
|
43
|
+
sessionTag(session) {
|
|
44
|
+
if (!session)
|
|
45
|
+
return 'platform=unknown user=unknown guild=unknown';
|
|
46
|
+
return `platform=${session.platform ?? 'unknown'} user=${session.userId ?? 'unknown'} guild=${session.guildId ?? 'private'}`;
|
|
47
|
+
}
|
|
48
|
+
styleText(text) {
|
|
49
|
+
return this.config.responseStyle === 'meow' ? `喵~ ${text}` : text;
|
|
50
|
+
}
|
|
51
|
+
escapeHtml(source) {
|
|
52
|
+
return source
|
|
53
|
+
.replace(/&/g, '&')
|
|
54
|
+
.replace(/</g, '<')
|
|
55
|
+
.replace(/>/g, '>')
|
|
56
|
+
.replace(/"/g, '"')
|
|
57
|
+
.replace(/'/g, ''');
|
|
58
|
+
}
|
|
59
|
+
collectCategories(session) {
|
|
60
|
+
const list = this.ctx.$commander._commandList;
|
|
61
|
+
const available = session ? new Set(this.ctx.$commander.available(session)) : null;
|
|
62
|
+
const categories = new Map();
|
|
63
|
+
const getDesc = (command) => {
|
|
64
|
+
const key = `commands.${command.name}.description`;
|
|
65
|
+
const localized = session?.text?.(key);
|
|
66
|
+
if (typeof localized === 'string' && localized && localized !== key)
|
|
67
|
+
return localized;
|
|
68
|
+
const rawDesc = command.toJSON?.().description;
|
|
69
|
+
return typeof rawDesc === 'string' ? rawDesc : '';
|
|
70
|
+
};
|
|
71
|
+
const getRoot = (command) => {
|
|
72
|
+
let node = command;
|
|
73
|
+
while (node?.parent)
|
|
74
|
+
node = node.parent;
|
|
75
|
+
return node;
|
|
76
|
+
};
|
|
77
|
+
const getDepth = (command) => {
|
|
78
|
+
let depth = 0;
|
|
79
|
+
let node = command;
|
|
80
|
+
while (node?.parent) {
|
|
81
|
+
depth += 1;
|
|
82
|
+
node = node.parent;
|
|
83
|
+
}
|
|
84
|
+
return depth;
|
|
85
|
+
};
|
|
86
|
+
for (const command of list) {
|
|
87
|
+
if (!command.parent)
|
|
88
|
+
continue;
|
|
89
|
+
const root = getRoot(command);
|
|
90
|
+
if (!root)
|
|
91
|
+
continue;
|
|
92
|
+
if (available && !available.has(command.name) && !available.has(command.displayName || ''))
|
|
93
|
+
continue;
|
|
94
|
+
const key = root.name;
|
|
95
|
+
if (!categories.has(key)) {
|
|
96
|
+
categories.set(key, {
|
|
97
|
+
key,
|
|
98
|
+
title: root.displayName || key,
|
|
99
|
+
desc: getDesc(root),
|
|
100
|
+
children: [],
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const category = categories.get(key);
|
|
104
|
+
if (!category)
|
|
105
|
+
continue;
|
|
106
|
+
const depth = getDepth(command);
|
|
107
|
+
if (depth !== 1)
|
|
108
|
+
continue;
|
|
109
|
+
const desc = getDesc(command);
|
|
110
|
+
const display = command.displayName || command.name;
|
|
111
|
+
category.children.push({ name: display, desc });
|
|
112
|
+
}
|
|
113
|
+
if (!categories.size) {
|
|
114
|
+
for (const command of list) {
|
|
115
|
+
if (command.parent)
|
|
116
|
+
continue;
|
|
117
|
+
categories.set(command.name, {
|
|
118
|
+
key: command.name,
|
|
119
|
+
title: command.displayName || command.name,
|
|
120
|
+
desc: getDesc(command),
|
|
121
|
+
children: [],
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const category of categories.values()) {
|
|
126
|
+
category.children.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
|
127
|
+
}
|
|
128
|
+
const miscChildren = [];
|
|
129
|
+
for (const command of list) {
|
|
130
|
+
if (command.parent)
|
|
131
|
+
continue;
|
|
132
|
+
if (available && !available.has(command.name) && !available.has(command.displayName || ''))
|
|
133
|
+
continue;
|
|
134
|
+
const hasChildren = list.some((item) => item.parent === command);
|
|
135
|
+
if (hasChildren)
|
|
136
|
+
continue;
|
|
137
|
+
miscChildren.push({ name: command.displayName || command.name, desc: getDesc(command) });
|
|
138
|
+
}
|
|
139
|
+
if (miscChildren.length) {
|
|
140
|
+
miscChildren.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
|
141
|
+
categories.set('other', {
|
|
142
|
+
key: 'other',
|
|
143
|
+
title: '其它',
|
|
144
|
+
desc: '无父指令的功能集合',
|
|
145
|
+
children: miscChildren,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return [...categories.values()].sort((a, b) => {
|
|
149
|
+
if (a.key === 'other' && b.key !== 'other')
|
|
150
|
+
return 1;
|
|
151
|
+
if (b.key === 'other' && a.key !== 'other')
|
|
152
|
+
return -1;
|
|
153
|
+
return a.title.localeCompare(b.title, 'zh-CN');
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
resolveMenuCategory(keyword, session) {
|
|
157
|
+
const query = keyword?.trim().toLowerCase();
|
|
158
|
+
if (!query)
|
|
159
|
+
return null;
|
|
160
|
+
const norm = query.replace(/\s+/g, '');
|
|
161
|
+
const categories = this.collectCategories(session);
|
|
162
|
+
return categories.find((cat) => {
|
|
163
|
+
const k = cat.key.toLowerCase();
|
|
164
|
+
const t = cat.title.toLowerCase();
|
|
165
|
+
const tk = `${t}菜单`;
|
|
166
|
+
const kk = `${k}菜单`;
|
|
167
|
+
return k === norm || t === norm || tk === norm || kk === norm || k.includes(norm) || t.includes(norm);
|
|
168
|
+
}) ?? null;
|
|
169
|
+
}
|
|
170
|
+
pairingKey(session) {
|
|
171
|
+
if (!session?.userId)
|
|
172
|
+
return '';
|
|
173
|
+
const scope = session.guildId || session.channelId || 'private';
|
|
174
|
+
return `${session.platform}:${scope}:${session.userId}`;
|
|
175
|
+
}
|
|
176
|
+
armMenuPairing(session, ttlMs = 30_000) {
|
|
177
|
+
const key = this.pairingKey(session);
|
|
178
|
+
if (!key)
|
|
179
|
+
return;
|
|
180
|
+
this.menuPairings.set(key, Date.now() + ttlMs);
|
|
181
|
+
}
|
|
182
|
+
getMessageText(session) {
|
|
183
|
+
const raw = session?.content ?? '';
|
|
184
|
+
return raw.replace(/\[CQ:[^\]]+\]/g, ' ').trim();
|
|
185
|
+
}
|
|
186
|
+
aiGroupKey(session) {
|
|
187
|
+
if (!session?.guildId)
|
|
188
|
+
return '';
|
|
189
|
+
return `${session.platform ?? 'unknown'}:${session.guildId}`;
|
|
190
|
+
}
|
|
191
|
+
resolveAiEnabled(session) {
|
|
192
|
+
const guildId = this.normalizeGuildId(session.guildId);
|
|
193
|
+
const rule = this.config.groupRules.find((item) => this.normalizeGuildId(item.guildId) === guildId);
|
|
194
|
+
if (typeof rule?.enableAiReply === 'boolean')
|
|
195
|
+
return rule.enableAiReply;
|
|
196
|
+
return this.config.enableAiReply;
|
|
197
|
+
}
|
|
198
|
+
extractAiMessageText(session) {
|
|
199
|
+
const text = this.getMessageText(session)
|
|
200
|
+
.replace(/\s+/g, ' ')
|
|
201
|
+
.trim();
|
|
202
|
+
return text;
|
|
203
|
+
}
|
|
204
|
+
getMemoryFilePath() {
|
|
205
|
+
const base = (this.ctx && this.ctx.baseDir) || process.cwd();
|
|
206
|
+
const name = (this.config.memoryFileName || 'Memory.md').trim();
|
|
207
|
+
return node_path_1.default.resolve(base, name);
|
|
208
|
+
}
|
|
209
|
+
async readMemoryFile() {
|
|
210
|
+
const p = this.getMemoryFilePath();
|
|
211
|
+
try {
|
|
212
|
+
return await node_fs_1.default.promises.readFile(p, 'utf8');
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return '';
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async writeMemoryFile(content) {
|
|
219
|
+
const p = this.getMemoryFilePath();
|
|
220
|
+
await node_fs_1.default.promises.writeFile(p, String(content), 'utf8');
|
|
221
|
+
}
|
|
222
|
+
extractMemoryBlocks(content) {
|
|
223
|
+
return String(content)
|
|
224
|
+
.split(/\r?\n---\r?\n/)
|
|
225
|
+
.map((b) => b.trim())
|
|
226
|
+
.filter(Boolean);
|
|
227
|
+
}
|
|
228
|
+
async saveMemoryToFile(userId, content) {
|
|
229
|
+
const now = new Date();
|
|
230
|
+
const stamp = now.toLocaleString('zh-CN', { hour12: false });
|
|
231
|
+
const block = `## ${stamp} · 用户 ${userId || 'unknown'}\n${String(content).trim()}`;
|
|
232
|
+
const existing = (await this.readMemoryFile()).trim();
|
|
233
|
+
const next = existing ? `${existing}\n\n---\n\n${block}\n` : `${block}\n`;
|
|
234
|
+
await this.writeMemoryFile(next);
|
|
235
|
+
return block;
|
|
236
|
+
}
|
|
237
|
+
async searchMemory(keyword, limit = 8) {
|
|
238
|
+
const blocks = this.extractMemoryBlocks(await this.readMemoryFile());
|
|
239
|
+
const kw = String(keyword || '').trim();
|
|
240
|
+
if (!kw)
|
|
241
|
+
return blocks.slice(-limit);
|
|
242
|
+
return blocks.filter((b) => b.includes(kw)).slice(-limit);
|
|
243
|
+
}
|
|
244
|
+
async decoratePromptWithMemory(prompt, options) {
|
|
245
|
+
if (!this.config.enableMemory || this.config.memoryInAi === false)
|
|
246
|
+
return prompt;
|
|
247
|
+
const keyword = (options?.focusText || options?.topicSummary || '').trim();
|
|
248
|
+
let results = await this.searchMemory(keyword, 6);
|
|
249
|
+
// 关键词没命中时(中文分词易漏),退而注入最近记忆,由模型自行判断相关性
|
|
250
|
+
if (!results.length && keyword)
|
|
251
|
+
results = await this.searchMemory('', 6);
|
|
252
|
+
if (!results.length)
|
|
253
|
+
return prompt;
|
|
254
|
+
const memoryLines = results.map((b, i) => `${i + 1}. ${b.replace(/\n+/g, ' ').trim().slice(0, 140)}`).join('\n');
|
|
255
|
+
return `${prompt}\n\n【记忆库参考】(若与当前话题相关可引用;不相关请忽略,不要编造,不要暴露"系统记忆"字样):\n${memoryLines}\n`;
|
|
256
|
+
}
|
|
257
|
+
async handleMemoryCommand(session) {
|
|
258
|
+
if (!this.config.enableMemory)
|
|
259
|
+
return false;
|
|
260
|
+
if (!session?.userId)
|
|
261
|
+
return false;
|
|
262
|
+
const text = this.getMessageText(session);
|
|
263
|
+
const saveMatch = text.match(/^\[记忆\]\s*[::]?\s*([\s\S]+)$/);
|
|
264
|
+
if (saveMatch) {
|
|
265
|
+
const content = saveMatch[1].trim();
|
|
266
|
+
if (!content) {
|
|
267
|
+
await session.send(this.styleText('用法:发送 [记忆]+内容 来保存一条记忆。'));
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
await this.saveMemoryToFile(session.userId, content);
|
|
272
|
+
await session.send(this.styleText('已记住。'));
|
|
273
|
+
this.logCommandResult('memory-save', { ok: true, message: 'memory saved' }, session, { content });
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
await session.send(this.styleText(`记忆保存失败: ${String(error)}`));
|
|
277
|
+
this.logCommandResult('memory-save', { ok: false, message: String(error) }, session, { content });
|
|
278
|
+
}
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
const queryMatch = text.match(/^\[记忆查询\]\s*[::]?\s*([\s\S]*)$/);
|
|
282
|
+
if (queryMatch) {
|
|
283
|
+
const keyword = queryMatch[1].trim();
|
|
284
|
+
const results = await this.searchMemory(keyword, 8);
|
|
285
|
+
if (!results.length) {
|
|
286
|
+
await session.send(this.styleText(keyword ? `没有找到包含「${keyword}」的记忆。` : '记忆库为空或没有可显示的记录。'));
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
const reply = results.map((b, i) => `${i + 1}. ${b.replace(/\n+/g, ' ').trim()}`).join('\n');
|
|
290
|
+
await session.send(this.styleText(`记忆内容:\n${reply}`));
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
const listMatch = text.match(/^\[记忆(?:列表|全部|看)\]$/);
|
|
294
|
+
if (listMatch) {
|
|
295
|
+
const results = await this.searchMemory('', 8);
|
|
296
|
+
if (!results.length) {
|
|
297
|
+
await session.send(this.styleText('记忆库为空。'));
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
const reply = results.map((b, i) => `${i + 1}. ${b.replace(/\n+/g, ' ').trim()}`).join('\n');
|
|
301
|
+
await session.send(this.styleText(`最近的记忆:\n${reply}`));
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
const deleteMatch = text.match(/^\[记忆删除\]\s*[::]?\s*([\s\S]+)$/);
|
|
305
|
+
if (deleteMatch) {
|
|
306
|
+
const keyword = deleteMatch[1].trim();
|
|
307
|
+
const existing = await this.readMemoryFile();
|
|
308
|
+
const blocks = this.extractMemoryBlocks(existing);
|
|
309
|
+
const kept = blocks.filter((b) => !b.includes(keyword));
|
|
310
|
+
if (kept.length === blocks.length) {
|
|
311
|
+
await session.send(this.styleText(`没有找到包含「${keyword}」的记忆可删除。`));
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
await this.writeMemoryFile(kept.length ? `${kept.join('\n\n---\n\n')}\n` : '');
|
|
315
|
+
await session.send(this.styleText(`已删除包含「${keyword}」的记忆(${blocks.length - kept.length} 条)。`));
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
getDisplayName(session) {
|
|
321
|
+
return session.username?.trim()
|
|
322
|
+
|| session.author?.nick?.trim()
|
|
323
|
+
|| session.author?.nickname?.trim()
|
|
324
|
+
|| session.userId
|
|
325
|
+
|| 'unknown';
|
|
326
|
+
}
|
|
327
|
+
isHomeUser(session) {
|
|
328
|
+
const ownerPlatform = this.config.aiOwnerPlatform?.trim() || this.config.aiHomePlatform?.trim();
|
|
329
|
+
const ownerUserId = this.config.aiOwnerUserId?.trim() || this.config.aiHomeUserId?.trim();
|
|
330
|
+
if (!ownerPlatform || !ownerUserId)
|
|
331
|
+
return false;
|
|
332
|
+
return session.platform === ownerPlatform && session.userId === ownerUserId;
|
|
333
|
+
}
|
|
334
|
+
rememberUser(session) {
|
|
335
|
+
if (!session.userId)
|
|
336
|
+
return;
|
|
337
|
+
const key = `${session.platform ?? 'unknown'}:${session.userId}`;
|
|
338
|
+
this.aiUserMemory.set(key, {
|
|
339
|
+
platform: session.platform ?? 'unknown',
|
|
340
|
+
userId: session.userId,
|
|
341
|
+
name: this.getDisplayName(session),
|
|
342
|
+
isHomeUser: this.isHomeUser(session),
|
|
343
|
+
updatedAt: Date.now(),
|
|
344
|
+
});
|
|
345
|
+
if (this.aiUserMemory.size > 2048) {
|
|
346
|
+
const expireBefore = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
347
|
+
for (const [id, item] of this.aiUserMemory) {
|
|
348
|
+
if (item.updatedAt < expireBefore)
|
|
349
|
+
this.aiUserMemory.delete(id);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
getFollowupSession(groupKey) {
|
|
354
|
+
const state = this.aiFollowups.get(groupKey);
|
|
355
|
+
if (!state)
|
|
356
|
+
return null;
|
|
357
|
+
if (Date.now() > state.expiresAt) {
|
|
358
|
+
this.aiFollowups.delete(groupKey);
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
return state;
|
|
362
|
+
}
|
|
363
|
+
tokenizeTopic(text) {
|
|
364
|
+
const normalized = text
|
|
365
|
+
.toLowerCase()
|
|
366
|
+
.replace(/[^\u4e00-\u9fa5a-z0-9]+/g, ' ')
|
|
367
|
+
.trim();
|
|
368
|
+
if (!normalized)
|
|
369
|
+
return [];
|
|
370
|
+
return [...new Set(normalized.split(/\s+/g).filter((item) => item.length >= 2))].slice(0, 24);
|
|
371
|
+
}
|
|
372
|
+
scoreTopicRelevance(text, topicTokens) {
|
|
373
|
+
if (!topicTokens.length)
|
|
374
|
+
return 0;
|
|
375
|
+
const tokens = this.tokenizeTopic(text);
|
|
376
|
+
if (!tokens.length)
|
|
377
|
+
return 0;
|
|
378
|
+
const overlap = tokens.filter((token) => topicTokens.includes(token)).length;
|
|
379
|
+
if (!overlap)
|
|
380
|
+
return 0;
|
|
381
|
+
return overlap / Math.max(1, Math.min(tokens.length, topicTokens.length));
|
|
382
|
+
}
|
|
383
|
+
startFollowupSession(groupKey, userId, anchorText) {
|
|
384
|
+
if (!this.config.aiEnableFollowupAfterMention)
|
|
385
|
+
return;
|
|
386
|
+
const seconds = Math.max(1, this.config.aiFollowupWindowSeconds || 120);
|
|
387
|
+
this.aiFollowups.set(groupKey, {
|
|
388
|
+
userId,
|
|
389
|
+
turns: 0,
|
|
390
|
+
expiresAt: Date.now() + seconds * 1000,
|
|
391
|
+
anchorTokens: this.tokenizeTopic(anchorText),
|
|
392
|
+
lastText: anchorText,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
bumpFollowupSession(groupKey) {
|
|
396
|
+
const state = this.getFollowupSession(groupKey);
|
|
397
|
+
if (!state)
|
|
398
|
+
return;
|
|
399
|
+
state.turns += 1;
|
|
400
|
+
const seconds = Math.max(1, this.config.aiFollowupWindowSeconds || 120);
|
|
401
|
+
state.expiresAt = Date.now() + seconds * 1000;
|
|
402
|
+
const maxTurns = Math.max(1, this.config.aiFollowupMaxTurns || 3);
|
|
403
|
+
if (state.turns >= maxTurns) {
|
|
404
|
+
this.aiFollowups.delete(groupKey);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
this.aiFollowups.set(groupKey, state);
|
|
408
|
+
}
|
|
409
|
+
shouldContinueFollowup(text, state) {
|
|
410
|
+
const value = text.trim();
|
|
411
|
+
if (!value)
|
|
412
|
+
return false;
|
|
413
|
+
if (/^[/.!!##]/.test(value))
|
|
414
|
+
return false;
|
|
415
|
+
if (value.length <= 2)
|
|
416
|
+
return false;
|
|
417
|
+
if (/^[\u{1F300}-\u{1FAFF}\s~~!!.。]+$/u.test(value))
|
|
418
|
+
return false;
|
|
419
|
+
const shiftMarker = /(换个话题|换个问题|另外|题外话|不聊这个|先不说这个|说点别的|再问个新的|顺便问)/.test(value);
|
|
420
|
+
if (shiftMarker)
|
|
421
|
+
return false;
|
|
422
|
+
if (/(何意|何意味|hyw|什么意思|啥意思|怎么回事|发生了什么)/.test(value))
|
|
423
|
+
return true;
|
|
424
|
+
if (state?.anchorTokens?.length) {
|
|
425
|
+
const current = this.tokenizeTopic(value);
|
|
426
|
+
if (current.length) {
|
|
427
|
+
const overlap = current.filter((token) => state.anchorTokens.includes(token)).length;
|
|
428
|
+
const ratio = overlap / Math.max(1, Math.min(current.length, state.anchorTokens.length));
|
|
429
|
+
// Topic drift: almost no overlap with the original mentioned topic.
|
|
430
|
+
if (value.length >= 8 && overlap === 0)
|
|
431
|
+
return false;
|
|
432
|
+
if (value.length >= 8 && ratio <= 0.12 && /[??]/.test(value))
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (/[??]/.test(value))
|
|
437
|
+
return true;
|
|
438
|
+
if (/(继续|然后|再|那|所以|为什么|怎么|如何|帮我|请问|还能|可以吗|行吗|能否)/.test(value))
|
|
439
|
+
return true;
|
|
440
|
+
return value.length >= 8;
|
|
441
|
+
}
|
|
442
|
+
getActivePersona() {
|
|
443
|
+
const id = this.config.aiActivePersona?.trim();
|
|
444
|
+
if (!id)
|
|
445
|
+
return null;
|
|
446
|
+
return this.config.aiPersonas.find((item) => item.id?.trim() === id) ?? null;
|
|
447
|
+
}
|
|
448
|
+
getActiveAgentName() {
|
|
449
|
+
const persona = this.getActivePersona();
|
|
450
|
+
const fromPersona = persona?.selfName?.trim();
|
|
451
|
+
if (fromPersona)
|
|
452
|
+
return fromPersona;
|
|
453
|
+
return this.config.aiAgentName?.trim() || 'MeowBot';
|
|
454
|
+
}
|
|
455
|
+
getActiveSystemPrompt() {
|
|
456
|
+
const persona = this.getActivePersona();
|
|
457
|
+
const fromPersona = persona?.prompt?.trim();
|
|
458
|
+
if (fromPersona)
|
|
459
|
+
return fromPersona;
|
|
460
|
+
return this.config.aiSystemPrompt?.trim() || '你是群聊中的友好机器人,请简洁、自然、符合中文互联网语境地回复。';
|
|
461
|
+
}
|
|
462
|
+
containsAgentName(text) {
|
|
463
|
+
const agentName = this.getActiveAgentName();
|
|
464
|
+
if (!agentName)
|
|
465
|
+
return false;
|
|
466
|
+
return text.toLowerCase().includes(agentName.toLowerCase());
|
|
467
|
+
}
|
|
468
|
+
isMentionBot(session) {
|
|
469
|
+
const selfId = session.bot?.selfId;
|
|
470
|
+
if (!selfId)
|
|
471
|
+
return false;
|
|
472
|
+
const content = session.content ?? '';
|
|
473
|
+
if (content.includes(`<at id="${selfId}"`))
|
|
474
|
+
return true;
|
|
475
|
+
const escaped = selfId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
476
|
+
const cqAt = new RegExp(`\\[CQ:at,[^\\]]*(qq|id)=${escaped}(?:,|\\])`, 'i');
|
|
477
|
+
return cqAt.test(content);
|
|
478
|
+
}
|
|
479
|
+
isCommandLikeMessage(text) {
|
|
480
|
+
const value = text.trim();
|
|
481
|
+
if (!value)
|
|
482
|
+
return false;
|
|
483
|
+
if (/^[/.!!##]/.test(value))
|
|
484
|
+
return true;
|
|
485
|
+
const prefixes = [this.config.command, this.config.menuCommand, 'help', 'status']
|
|
486
|
+
.map((item) => item?.trim())
|
|
487
|
+
.filter(Boolean);
|
|
488
|
+
return prefixes.some((prefix) => value === prefix || value.startsWith(`${prefix} `) || value === `${prefix}菜单`);
|
|
489
|
+
}
|
|
490
|
+
isDuplicateAiEvent(session) {
|
|
491
|
+
if (!session.messageId)
|
|
492
|
+
return false;
|
|
493
|
+
const key = `${session.platform ?? 'unknown'}:${session.guildId ?? 'private'}:${session.userId ?? 'unknown'}:${session.messageId}`;
|
|
494
|
+
const now = Date.now();
|
|
495
|
+
const previous = this.aiHandledEvents.get(key) ?? 0;
|
|
496
|
+
this.aiHandledEvents.set(key, now);
|
|
497
|
+
const expireBefore = now - 120_000;
|
|
498
|
+
if (this.aiHandledEvents.size > 512) {
|
|
499
|
+
for (const [id, ts] of this.aiHandledEvents) {
|
|
500
|
+
if (ts < expireBefore)
|
|
501
|
+
this.aiHandledEvents.delete(id);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return previous > 0 && now - previous < 15_000;
|
|
505
|
+
}
|
|
506
|
+
pushAiRecord(session, text) {
|
|
507
|
+
const key = this.aiGroupKey(session);
|
|
508
|
+
if (!key || !session.userId)
|
|
509
|
+
return;
|
|
510
|
+
const name = session.username?.trim()
|
|
511
|
+
|| session.author?.nick?.trim()
|
|
512
|
+
|| session.author?.nickname?.trim()
|
|
513
|
+
|| session.userId;
|
|
514
|
+
const record = {
|
|
515
|
+
userId: session.userId,
|
|
516
|
+
name,
|
|
517
|
+
text,
|
|
518
|
+
imageUrls: this.extractImageUrls(session),
|
|
519
|
+
timestamp: Date.now(),
|
|
520
|
+
};
|
|
521
|
+
const current = this.aiBuffers.get(key) ?? [];
|
|
522
|
+
current.push(record);
|
|
523
|
+
const maxWindow = Math.max(6, this.config.aiContextWindow || 24);
|
|
524
|
+
const next = current.slice(-maxWindow);
|
|
525
|
+
this.aiBuffers.set(key, next);
|
|
526
|
+
this.aiCounters.set(key, (this.aiCounters.get(key) ?? 0) + 1);
|
|
527
|
+
}
|
|
528
|
+
appendAiContextNote(groupKey, text) {
|
|
529
|
+
if (!groupKey || !text.trim())
|
|
530
|
+
return;
|
|
531
|
+
const current = this.aiBuffers.get(groupKey) ?? [];
|
|
532
|
+
current.push({
|
|
533
|
+
userId: 'system',
|
|
534
|
+
name: '系统',
|
|
535
|
+
text,
|
|
536
|
+
imageUrls: [],
|
|
537
|
+
timestamp: Date.now(),
|
|
538
|
+
});
|
|
539
|
+
const maxWindow = Math.max(6, this.config.aiContextWindow || 24);
|
|
540
|
+
this.aiBuffers.set(groupKey, current.slice(-maxWindow));
|
|
541
|
+
}
|
|
542
|
+
shouldTriggerAiReply(session, forceMention = false) {
|
|
543
|
+
const key = this.aiGroupKey(session);
|
|
544
|
+
if (!key)
|
|
545
|
+
return { ok: false, reason: 'no-group' };
|
|
546
|
+
const now = Date.now();
|
|
547
|
+
const last = this.aiLastReplyAt.get(key) ?? 0;
|
|
548
|
+
const minIntervalMs = Math.max(0, this.config.aiMinReplyIntervalSeconds || 0) * 1000;
|
|
549
|
+
if (!forceMention && now - last < minIntervalMs)
|
|
550
|
+
return { ok: false, reason: 'cooldown' };
|
|
551
|
+
if (this.aiInFlight.has(key))
|
|
552
|
+
return { ok: false, reason: 'in-flight' };
|
|
553
|
+
if (forceMention)
|
|
554
|
+
return { ok: true, reason: 'direct-mention' };
|
|
555
|
+
const mode = this.config.aiReplyMode;
|
|
556
|
+
const messageCount = this.aiCounters.get(key) ?? 0;
|
|
557
|
+
const threshold = Math.max(1, this.config.aiMessageThreshold || 1);
|
|
558
|
+
const thresholdHit = (mode === 'threshold' || mode === 'hybrid') && messageCount >= threshold;
|
|
559
|
+
if (thresholdHit)
|
|
560
|
+
return { ok: true, reason: `threshold(${messageCount}/${threshold})` };
|
|
561
|
+
if (mode === 'threshold')
|
|
562
|
+
return { ok: false, reason: 'not-triggered' };
|
|
563
|
+
return { ok: true, reason: 'interest-check' };
|
|
564
|
+
}
|
|
565
|
+
parseInterestDecision(raw) {
|
|
566
|
+
const text = raw.trim();
|
|
567
|
+
if (!text)
|
|
568
|
+
return { reply: false, score: 0, reason: 'empty' };
|
|
569
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
570
|
+
const candidate = jsonMatch ? jsonMatch[0] : text;
|
|
571
|
+
try {
|
|
572
|
+
const parsed = JSON.parse(candidate);
|
|
573
|
+
const reply = !!parsed.reply;
|
|
574
|
+
const score = Number.isFinite(parsed.score) ? Number(parsed.score) : 0;
|
|
575
|
+
return { reply, score: Math.max(0, Math.min(100, score)), reason: parsed.reason || '' };
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
const lowered = text.toLowerCase();
|
|
579
|
+
const reply = lowered.includes('true') || lowered.includes('yes');
|
|
580
|
+
const scoreMatch = lowered.match(/(\d{1,3})/);
|
|
581
|
+
const score = scoreMatch ? Math.max(0, Math.min(100, Number(scoreMatch[1]))) : 0;
|
|
582
|
+
return { reply, score, reason: 'parse-fallback' };
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
buildInterestContextPrompt(groupKey, currentText, window = 8) {
|
|
586
|
+
const size = Math.max(4, Math.min(16, window));
|
|
587
|
+
const records = (this.aiBuffers.get(groupKey) ?? []).slice(-size);
|
|
588
|
+
const lines = records.map((item, idx) => {
|
|
589
|
+
const imageHint = item.imageUrls.length ? ` [图片:${item.imageUrls.length}张]` : '';
|
|
590
|
+
const content = item.text?.trim() || '[图片消息]';
|
|
591
|
+
return `${idx + 1}. ${item.name}(${item.userId}): ${content}${imageHint}`;
|
|
592
|
+
});
|
|
593
|
+
const topicHint = (this.aiTopicMemory.get(groupKey)?.text || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
594
|
+
const prompt = [
|
|
595
|
+
`当前消息:${currentText || '[空文本]'}`,
|
|
596
|
+
topicHint ? `当前群话题参考:${topicHint}` : '',
|
|
597
|
+
`最近上下文(最多${size}条):`,
|
|
598
|
+
...(lines.length ? lines : ['(无历史上下文)']),
|
|
599
|
+
].filter(Boolean).join('\n');
|
|
600
|
+
return { prompt, ctxSize: lines.length, topicHint };
|
|
601
|
+
}
|
|
602
|
+
async decideInterestByOpenAI(contextPrompt) {
|
|
603
|
+
const apiKey = this.config.aiApiKey?.trim();
|
|
604
|
+
if (!apiKey)
|
|
605
|
+
return { reply: false, score: 0, reason: 'no-api-key' };
|
|
606
|
+
const base = (this.config.aiBaseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '');
|
|
607
|
+
const model = this.config.aiModel?.trim();
|
|
608
|
+
if (!model)
|
|
609
|
+
return { reply: false, score: 0, reason: 'no-model' };
|
|
610
|
+
const controller = new AbortController();
|
|
611
|
+
const timeout = setTimeout(() => controller.abort(), 18_000);
|
|
612
|
+
try {
|
|
613
|
+
const response = await fetch(`${base}/chat/completions`, {
|
|
614
|
+
method: 'POST',
|
|
615
|
+
headers: {
|
|
616
|
+
Authorization: `Bearer ${apiKey}`,
|
|
617
|
+
'Content-Type': 'application/json',
|
|
618
|
+
},
|
|
619
|
+
body: JSON.stringify({
|
|
620
|
+
model,
|
|
621
|
+
temperature: 0.15,
|
|
622
|
+
max_tokens: 48,
|
|
623
|
+
messages: [
|
|
624
|
+
{
|
|
625
|
+
role: 'system',
|
|
626
|
+
content: '你是群聊回复开关决策器。只输出 JSON: {"reply":boolean,"score":0-100,"reason":"..."}。除 JSON 外不输出任何文字。',
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
role: 'user',
|
|
630
|
+
content: `请判断是否值得回复这条消息(从严,降低热度)。以下是消息与上下文:\n${contextPrompt}`,
|
|
631
|
+
},
|
|
632
|
+
],
|
|
633
|
+
}),
|
|
634
|
+
signal: controller.signal,
|
|
635
|
+
});
|
|
636
|
+
const payload = await response.json().catch(() => ({}));
|
|
637
|
+
const content = payload?.choices?.[0]?.message?.content;
|
|
638
|
+
if (!response.ok)
|
|
639
|
+
return { reply: false, score: 0, reason: `http-${response.status}` };
|
|
640
|
+
const plain = typeof content === 'string'
|
|
641
|
+
? content
|
|
642
|
+
: Array.isArray(content)
|
|
643
|
+
? content.map((item) => item?.text || '').join('')
|
|
644
|
+
: '';
|
|
645
|
+
return this.parseInterestDecision(plain);
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
return { reply: false, score: 0, reason: 'request-failed' };
|
|
649
|
+
}
|
|
650
|
+
finally {
|
|
651
|
+
clearTimeout(timeout);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
async decideInterestByGemini(contextPrompt) {
|
|
655
|
+
const apiKey = this.config.aiApiKey?.trim();
|
|
656
|
+
if (!apiKey)
|
|
657
|
+
return { reply: false, score: 0, reason: 'no-api-key' };
|
|
658
|
+
const model = this.config.aiModel?.trim();
|
|
659
|
+
if (!model)
|
|
660
|
+
return { reply: false, score: 0, reason: 'no-model' };
|
|
661
|
+
const rawBase = (this.config.aiBaseUrl || 'https://generativelanguage.googleapis.com/v1beta').replace(/\/+$/, '');
|
|
662
|
+
const base = /\/v\d/i.test(rawBase) ? rawBase : `${rawBase}/v1beta`;
|
|
663
|
+
const endpoint = `${base}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`;
|
|
664
|
+
const controller = new AbortController();
|
|
665
|
+
const timeout = setTimeout(() => controller.abort(), 18_000);
|
|
666
|
+
try {
|
|
667
|
+
const response = await fetch(endpoint, {
|
|
668
|
+
method: 'POST',
|
|
669
|
+
headers: { 'Content-Type': 'application/json' },
|
|
670
|
+
body: JSON.stringify({
|
|
671
|
+
systemInstruction: {
|
|
672
|
+
parts: [{ text: '你是群聊回复开关决策器。只输出 JSON: {"reply":boolean,"score":0-100,"reason":"..."}。除 JSON 外不输出任何文字。' }],
|
|
673
|
+
},
|
|
674
|
+
contents: [{ role: 'user', parts: [{ text: `请判断是否值得回复这条消息(从严,降低热度)。以下是消息与上下文:\n${contextPrompt}` }] }],
|
|
675
|
+
generationConfig: {
|
|
676
|
+
temperature: 0.15,
|
|
677
|
+
maxOutputTokens: 64,
|
|
678
|
+
},
|
|
679
|
+
}),
|
|
680
|
+
signal: controller.signal,
|
|
681
|
+
});
|
|
682
|
+
const payload = await response.json().catch(() => ({}));
|
|
683
|
+
if (!response.ok)
|
|
684
|
+
return { reply: false, score: 0, reason: `http-${response.status}` };
|
|
685
|
+
const plain = (payload?.candidates?.[0]?.content?.parts ?? [])
|
|
686
|
+
.map((item) => item?.text || '')
|
|
687
|
+
.join('');
|
|
688
|
+
return this.parseInterestDecision(plain);
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
return { reply: false, score: 0, reason: 'request-failed' };
|
|
692
|
+
}
|
|
693
|
+
finally {
|
|
694
|
+
clearTimeout(timeout);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
async shouldReplyByInterest(params) {
|
|
698
|
+
const context = this.buildInterestContextPrompt(params.groupKey, params.text, this.config.aiInterestContextWindow || 8);
|
|
699
|
+
const decision = this.config.aiProvider === 'gemini'
|
|
700
|
+
? await this.decideInterestByGemini(context.prompt)
|
|
701
|
+
: await this.decideInterestByOpenAI(context.prompt);
|
|
702
|
+
const threshold = Math.max(0, Math.min(100, this.config.aiInterestMinScore ?? 82));
|
|
703
|
+
return {
|
|
704
|
+
ok: decision.reply && decision.score >= threshold,
|
|
705
|
+
score: decision.score,
|
|
706
|
+
reason: decision.reason,
|
|
707
|
+
threshold,
|
|
708
|
+
ctxSize: context.ctxSize,
|
|
709
|
+
topicHint: context.topicHint,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
buildAiUserPrompt(records, options) {
|
|
713
|
+
const scored = records.map((item, index) => ({ item, index, score: this.scoreTopicRelevance(item.text, options?.topicTokens ?? []) }));
|
|
714
|
+
const renderLine = (item, idx, score) => {
|
|
715
|
+
const imageHint = item.imageUrls.length ? ` [图片:${item.imageUrls.length}张]` : '';
|
|
716
|
+
const relHint = typeof score === 'number' && options?.thresholdMode && (options?.topicTokens?.length ?? 0) > 0
|
|
717
|
+
? ` [相关度:${score.toFixed(2)}]`
|
|
718
|
+
: '';
|
|
719
|
+
return `${idx + 1}. ${item.name}(${item.userId}): ${item.text}${imageHint}${relHint}`;
|
|
720
|
+
};
|
|
721
|
+
const ordered = options?.thresholdMode && (options?.topicTokens?.length ?? 0) > 0
|
|
722
|
+
? [...scored].sort((a, b) => b.score - a.score || a.index - b.index)
|
|
723
|
+
: scored;
|
|
724
|
+
let lines = ordered.map((entry, idx) => renderLine(entry.item, idx, entry.score));
|
|
725
|
+
const selectedIndex = options?.selectedRecord
|
|
726
|
+
? records.findIndex((item) => item === options.selectedRecord)
|
|
727
|
+
: -1;
|
|
728
|
+
if (options?.thresholdMode && selectedIndex >= 0) {
|
|
729
|
+
const start = Math.max(0, selectedIndex - 2);
|
|
730
|
+
const end = Math.min(records.length, selectedIndex + 3);
|
|
731
|
+
const local = records.slice(start, end);
|
|
732
|
+
lines = local.map((item, idx) => renderLine(item, idx, this.scoreTopicRelevance(item.text, options?.topicTokens ?? [])));
|
|
733
|
+
}
|
|
734
|
+
const selfName = this.getActiveAgentName();
|
|
735
|
+
const thresholdFocus = options?.thresholdMode && options?.selectedRecord
|
|
736
|
+
? [
|
|
737
|
+
'SELECTED_TARGET_BEGIN',
|
|
738
|
+
`${options.selectedRecord.name}(${options.selectedRecord.userId}): ${options.selectedRecord.text}`,
|
|
739
|
+
'SELECTED_TARGET_END',
|
|
740
|
+
'FORBIDDEN: 不要优先回复最后一条,除非与选定目标是同一对象同一问题。',
|
|
741
|
+
].join('\n')
|
|
742
|
+
: '';
|
|
743
|
+
const focusLine = options?.focusText
|
|
744
|
+
? `当前点名消息来自 ${options.focusUserName || '某用户'}(${options.focusUserId || 'unknown'}):${options.focusText}`
|
|
745
|
+
: (thresholdFocus || '请从下面的聊天记录中选择一条你认为最值得回应的消息并回复。');
|
|
746
|
+
const formatHint = '只输出最终要发送到群里的回复正文,不要解释,不要输出编号,不要代码块。';
|
|
747
|
+
const imageHint = options?.focusImages?.length
|
|
748
|
+
? `本条点名消息附带图片 ${options.focusImages.length} 张,请结合图片理解后作答。`
|
|
749
|
+
: '';
|
|
750
|
+
if (options?.directMention) {
|
|
751
|
+
const ownerPlatform = this.config.aiOwnerPlatform?.trim() || this.config.aiHomePlatform?.trim();
|
|
752
|
+
const ownerUserId = this.config.aiOwnerUserId?.trim() || this.config.aiHomeUserId?.trim();
|
|
753
|
+
const homeHint = options.focusUserId && ownerUserId && options.focusUserId === ownerUserId
|
|
754
|
+
&& (ownerPlatform ? `当前发言者命中主人身份(${ownerPlatform}:${ownerUserId})。` : '');
|
|
755
|
+
return [
|
|
756
|
+
'你现在在一个QQ群中。',
|
|
757
|
+
`你的自称是「${selfName}」。当你描述自己时请使用这个自称,不要在每句话前机械加前缀。`,
|
|
758
|
+
focusLine,
|
|
759
|
+
homeHint || '',
|
|
760
|
+
imageHint,
|
|
761
|
+
'用户正在直接点名你,请优先围绕该点名问题回复;可结合其他人的反馈补充说明。',
|
|
762
|
+
formatHint,
|
|
763
|
+
'如果主人或其他用户在上下文里追问该问题,可向追问者解释,但不要偏离当前话题。',
|
|
764
|
+
'',
|
|
765
|
+
'最近上下文(仅参考):',
|
|
766
|
+
...lines.slice(-8),
|
|
767
|
+
].join('\n');
|
|
768
|
+
}
|
|
769
|
+
if (options?.followup) {
|
|
770
|
+
return [
|
|
771
|
+
'你现在在一个QQ群中。',
|
|
772
|
+
`你的自称是「${selfName}」。当你描述自己时请使用这个自称,不要在每句话前机械加前缀。`,
|
|
773
|
+
focusLine,
|
|
774
|
+
'这是点名话题的后续追问,允许回应上下文中对该话题的相关追问(包括主人)。',
|
|
775
|
+
formatHint,
|
|
776
|
+
'',
|
|
777
|
+
'最近上下文(仅参考):',
|
|
778
|
+
...lines.slice(-8),
|
|
779
|
+
].join('\n');
|
|
780
|
+
}
|
|
781
|
+
return [
|
|
782
|
+
'你现在在一个QQ群中。',
|
|
783
|
+
`你的自称是「${selfName}」。当你描述自己时请使用这个自称,不要在每句话前机械加前缀。`,
|
|
784
|
+
focusLine,
|
|
785
|
+
options?.thresholdMode && options?.topicSummary ? `当前群主话题摘要:${options.topicSummary}` : '',
|
|
786
|
+
imageHint,
|
|
787
|
+
formatHint,
|
|
788
|
+
options?.thresholdMode
|
|
789
|
+
? `当前为阈值触发:你必须优先回复“已选定优先回复消息”;不要回复最后一条,除非它就是该消息。既有主题关键词:${(options.topicTokens ?? []).join(', ') || '无'}`
|
|
790
|
+
: '',
|
|
791
|
+
'请自然、简洁,尽量控制在80字内。',
|
|
792
|
+
'',
|
|
793
|
+
'聊天记录:',
|
|
794
|
+
...lines,
|
|
795
|
+
].join('\n');
|
|
796
|
+
}
|
|
797
|
+
normalizeAiOutput(text, preserveLines = false) {
|
|
798
|
+
const cleaned = text
|
|
799
|
+
.replace(/^```[\s\S]*?\n/, '')
|
|
800
|
+
.replace(/```$/g, '')
|
|
801
|
+
.replace(/\r\n/g, '\n')
|
|
802
|
+
.trim();
|
|
803
|
+
if (!preserveLines) {
|
|
804
|
+
return cleaned.replace(/\s+/g, ' ').trim();
|
|
805
|
+
}
|
|
806
|
+
return cleaned
|
|
807
|
+
.split('\n')
|
|
808
|
+
.map((line) => line.replace(/[ \t]+/g, ' ').trim())
|
|
809
|
+
.filter(Boolean)
|
|
810
|
+
.join('\n');
|
|
811
|
+
}
|
|
812
|
+
sanitizeAiOutputForSend(text) {
|
|
813
|
+
// Prevent model output from being parsed into platform segments (CQ/image/html image/markdown image).
|
|
814
|
+
return text
|
|
815
|
+
.replace(/\[CQ:[^\]]+\]/gi, '')
|
|
816
|
+
.replace(/<img\b[^>]*>/gi, '')
|
|
817
|
+
.replace(/!\[[^\]]*]\((https?:\/\/[^)\s]+)\)/gi, '$1')
|
|
818
|
+
.trim();
|
|
819
|
+
}
|
|
820
|
+
splitAiReplyParts(text) {
|
|
821
|
+
const source = text.trim();
|
|
822
|
+
if (!source)
|
|
823
|
+
return [];
|
|
824
|
+
const chunks = source
|
|
825
|
+
.split(/\n+/g)
|
|
826
|
+
.flatMap((line) => line.match(/[^。!?!?;;]+[。!?!?;;]?/g) ?? [line])
|
|
827
|
+
.map((item) => item.trim().replace(/[。;;]+$/g, '').trim())
|
|
828
|
+
.filter(Boolean);
|
|
829
|
+
if (!chunks.length)
|
|
830
|
+
return [source];
|
|
831
|
+
const maxParts = 4;
|
|
832
|
+
if (chunks.length <= maxParts)
|
|
833
|
+
return chunks;
|
|
834
|
+
return [
|
|
835
|
+
...chunks.slice(0, maxParts - 1),
|
|
836
|
+
chunks.slice(maxParts - 1).join(' '),
|
|
837
|
+
];
|
|
838
|
+
}
|
|
839
|
+
async waitHumanLikeDelay(minMs = 420, maxMs = 1100) {
|
|
840
|
+
const lower = Math.max(0, minMs);
|
|
841
|
+
const upper = Math.max(lower, maxMs);
|
|
842
|
+
const delay = Math.floor(Math.random() * (upper - lower + 1)) + lower;
|
|
843
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
844
|
+
}
|
|
845
|
+
isAddressingOtherThanBot(session) {
|
|
846
|
+
const selfId = session.bot?.selfId;
|
|
847
|
+
const content = session.content ?? '';
|
|
848
|
+
const mentionMatches = content.match(/\[CQ:at,[^\]]*\]/gi) ?? [];
|
|
849
|
+
const ids = mentionMatches
|
|
850
|
+
.map((item) => item.match(/(?:qq|id)=(\d+)/i)?.[1] || '')
|
|
851
|
+
.filter(Boolean);
|
|
852
|
+
if (!ids.length)
|
|
853
|
+
return false;
|
|
854
|
+
return ids.some((id) => !selfId || id !== selfId);
|
|
855
|
+
}
|
|
856
|
+
extractAiImageUrls(text) {
|
|
857
|
+
const urls = new Set();
|
|
858
|
+
const maxCount = Math.max(0, this.config.aiImageMaxCount || 0) || 4;
|
|
859
|
+
const source = text || '';
|
|
860
|
+
const cqImagePattern = /\[CQ:image,[^\]]*\]/gi;
|
|
861
|
+
for (const chunk of source.match(cqImagePattern) ?? []) {
|
|
862
|
+
const match = chunk.match(/(?:url|file)=([^,\]]+)/i);
|
|
863
|
+
if (!match)
|
|
864
|
+
continue;
|
|
865
|
+
const value = decodeURIComponent(match[1].trim());
|
|
866
|
+
if (!/^https?:\/\//i.test(value))
|
|
867
|
+
continue;
|
|
868
|
+
urls.add(value);
|
|
869
|
+
if (urls.size >= maxCount)
|
|
870
|
+
return [...urls];
|
|
871
|
+
}
|
|
872
|
+
const htmlImgPattern = /<img\b[^>]*src=["']([^"']+)["'][^>]*>/gi;
|
|
873
|
+
let htmlMatch;
|
|
874
|
+
while ((htmlMatch = htmlImgPattern.exec(source))) {
|
|
875
|
+
const value = htmlMatch[1]?.trim();
|
|
876
|
+
if (!value || !/^https?:\/\//i.test(value))
|
|
877
|
+
continue;
|
|
878
|
+
urls.add(value);
|
|
879
|
+
if (urls.size >= maxCount)
|
|
880
|
+
return [...urls];
|
|
881
|
+
}
|
|
882
|
+
const markdownImgPattern = /!\[[^\]]*]\((https?:\/\/[^)\s]+)\)/gi;
|
|
883
|
+
let mdMatch;
|
|
884
|
+
while ((mdMatch = markdownImgPattern.exec(source))) {
|
|
885
|
+
const value = mdMatch[1]?.trim();
|
|
886
|
+
if (!value)
|
|
887
|
+
continue;
|
|
888
|
+
urls.add(value);
|
|
889
|
+
if (urls.size >= maxCount)
|
|
890
|
+
return [...urls];
|
|
891
|
+
}
|
|
892
|
+
return [...urls].slice(0, maxCount);
|
|
893
|
+
}
|
|
894
|
+
async callOpenAiCompatible(prompt) {
|
|
895
|
+
const apiKey = this.config.aiApiKey?.trim();
|
|
896
|
+
if (!apiKey)
|
|
897
|
+
return { ok: false, message: 'AI 未配置 apiKey。' };
|
|
898
|
+
const base = (this.config.aiBaseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '');
|
|
899
|
+
const model = this.config.aiModel?.trim();
|
|
900
|
+
if (!model)
|
|
901
|
+
return { ok: false, message: 'AI 未配置 model。' };
|
|
902
|
+
const controller = new AbortController();
|
|
903
|
+
const timeout = setTimeout(() => controller.abort(), 25_000);
|
|
904
|
+
try {
|
|
905
|
+
const response = await fetch(`${base}/chat/completions`, {
|
|
906
|
+
method: 'POST',
|
|
907
|
+
headers: {
|
|
908
|
+
Authorization: `Bearer ${apiKey}`,
|
|
909
|
+
'Content-Type': 'application/json',
|
|
910
|
+
},
|
|
911
|
+
body: JSON.stringify({
|
|
912
|
+
model,
|
|
913
|
+
temperature: this.config.aiTemperature,
|
|
914
|
+
max_tokens: this.config.aiMaxOutputTokens,
|
|
915
|
+
messages: [
|
|
916
|
+
{ role: 'system', content: this.getActiveSystemPrompt() },
|
|
917
|
+
{ role: 'user', content: prompt },
|
|
918
|
+
],
|
|
919
|
+
}),
|
|
920
|
+
signal: controller.signal,
|
|
921
|
+
});
|
|
922
|
+
const payload = await response.json().catch(() => ({}));
|
|
923
|
+
if (!response.ok) {
|
|
924
|
+
const errorMessage = payload?.error?.message;
|
|
925
|
+
return { ok: false, message: `OpenAI兼容接口错误: ${errorMessage || response.statusText}` };
|
|
926
|
+
}
|
|
927
|
+
const content = payload?.choices?.[0]?.message?.content;
|
|
928
|
+
if (typeof content === 'string')
|
|
929
|
+
return { ok: true, message: content };
|
|
930
|
+
if (Array.isArray(content)) {
|
|
931
|
+
const text = content.map((item) => item?.text || '').join('').trim();
|
|
932
|
+
if (text)
|
|
933
|
+
return { ok: true, message: text };
|
|
934
|
+
}
|
|
935
|
+
return { ok: false, message: 'OpenAI兼容接口未返回可用文本。' };
|
|
936
|
+
}
|
|
937
|
+
catch (error) {
|
|
938
|
+
return { ok: false, message: `OpenAI兼容请求失败: ${String(error)}` };
|
|
939
|
+
}
|
|
940
|
+
finally {
|
|
941
|
+
clearTimeout(timeout);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
async callGemini(prompt) {
|
|
945
|
+
const apiKey = this.config.aiApiKey?.trim();
|
|
946
|
+
if (!apiKey)
|
|
947
|
+
return { ok: false, message: 'Gemini 未配置 apiKey。' };
|
|
948
|
+
const model = this.config.aiModel?.trim();
|
|
949
|
+
if (!model)
|
|
950
|
+
return { ok: false, message: 'Gemini 未配置 model。' };
|
|
951
|
+
const rawBase = (this.config.aiBaseUrl || 'https://generativelanguage.googleapis.com/v1beta').replace(/\/+$/, '');
|
|
952
|
+
const base = /\/v\d/i.test(rawBase) ? rawBase : `${rawBase}/v1beta`;
|
|
953
|
+
const endpoint = `${base}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`;
|
|
954
|
+
const controller = new AbortController();
|
|
955
|
+
const timeout = setTimeout(() => controller.abort(), 25_000);
|
|
956
|
+
try {
|
|
957
|
+
const response = await fetch(endpoint, {
|
|
958
|
+
method: 'POST',
|
|
959
|
+
headers: { 'Content-Type': 'application/json' },
|
|
960
|
+
body: JSON.stringify({
|
|
961
|
+
systemInstruction: { parts: [{ text: this.getActiveSystemPrompt() }] },
|
|
962
|
+
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
|
963
|
+
generationConfig: {
|
|
964
|
+
temperature: this.config.aiTemperature,
|
|
965
|
+
maxOutputTokens: this.config.aiMaxOutputTokens,
|
|
966
|
+
},
|
|
967
|
+
}),
|
|
968
|
+
signal: controller.signal,
|
|
969
|
+
});
|
|
970
|
+
const payload = await response.json().catch(() => ({}));
|
|
971
|
+
if (!response.ok) {
|
|
972
|
+
const errorMessage = payload?.error?.message;
|
|
973
|
+
return { ok: false, message: `Gemini接口错误: ${errorMessage || response.statusText}` };
|
|
974
|
+
}
|
|
975
|
+
const text = (payload?.candidates?.[0]?.content?.parts ?? [])
|
|
976
|
+
.map((item) => item?.text || '')
|
|
977
|
+
.join('')
|
|
978
|
+
.trim();
|
|
979
|
+
if (!text)
|
|
980
|
+
return { ok: false, message: 'Gemini接口未返回可用文本。' };
|
|
981
|
+
return { ok: true, message: text };
|
|
982
|
+
}
|
|
983
|
+
catch (error) {
|
|
984
|
+
return { ok: false, message: `Gemini请求失败: ${String(error)}` };
|
|
985
|
+
}
|
|
986
|
+
finally {
|
|
987
|
+
clearTimeout(timeout);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
extractImageUrls(session) {
|
|
991
|
+
if (!this.config.aiEnableImageRecognition)
|
|
992
|
+
return [];
|
|
993
|
+
const maxCount = Math.max(0, this.config.aiImageMaxCount || 0);
|
|
994
|
+
if (!maxCount)
|
|
995
|
+
return [];
|
|
996
|
+
return this.extractMessageImageUrls(session, maxCount);
|
|
997
|
+
}
|
|
998
|
+
isImageSendableUrl(value) {
|
|
999
|
+
if (!value || typeof value !== 'string')
|
|
1000
|
+
return false;
|
|
1001
|
+
const v = value.trim();
|
|
1002
|
+
if (/^https?:\/\//i.test(v))
|
|
1003
|
+
return true;
|
|
1004
|
+
if (/^data:image\//i.test(v))
|
|
1005
|
+
return true;
|
|
1006
|
+
return false;
|
|
1007
|
+
}
|
|
1008
|
+
extractMessageImageUrls(session, maxCount = 4) {
|
|
1009
|
+
const urls = [];
|
|
1010
|
+
const content = session.content ?? '';
|
|
1011
|
+
const cqPattern = /\[CQ:image,[^\]]*\]/gi;
|
|
1012
|
+
const cqList = content.match(cqPattern) ?? [];
|
|
1013
|
+
for (const chunk of cqList) {
|
|
1014
|
+
const match = chunk.match(/(?:url|src)=([^,\]]+)/i);
|
|
1015
|
+
if (!match)
|
|
1016
|
+
continue;
|
|
1017
|
+
const value = decodeURIComponent(match[1].trim());
|
|
1018
|
+
if (!this.isImageSendableUrl(value))
|
|
1019
|
+
continue;
|
|
1020
|
+
urls.push(value);
|
|
1021
|
+
if (urls.length >= maxCount)
|
|
1022
|
+
return urls;
|
|
1023
|
+
}
|
|
1024
|
+
const elements = session.elements ?? [];
|
|
1025
|
+
for (const element of elements) {
|
|
1026
|
+
if (element.type !== 'image')
|
|
1027
|
+
continue;
|
|
1028
|
+
const maybeUrl = (typeof element.url === 'string' && element.url)
|
|
1029
|
+
|| (typeof element.src === 'string' && element.src)
|
|
1030
|
+
|| (typeof element.attrs?.url === 'string' && element.attrs.url)
|
|
1031
|
+
|| (typeof element.attrs?.src === 'string' && element.attrs.src);
|
|
1032
|
+
if (!this.isImageSendableUrl(maybeUrl))
|
|
1033
|
+
continue;
|
|
1034
|
+
urls.push(maybeUrl);
|
|
1035
|
+
if (urls.length >= maxCount)
|
|
1036
|
+
break;
|
|
1037
|
+
}
|
|
1038
|
+
return [...new Set(urls)].slice(0, maxCount);
|
|
1039
|
+
}
|
|
1040
|
+
describeImageMessage(session) {
|
|
1041
|
+
const content = session.content ?? '';
|
|
1042
|
+
const rawCq = (content.match(/\[CQ:image,[^\]]+\]/gi) ?? []);
|
|
1043
|
+
const elems = [];
|
|
1044
|
+
for (const el of session.elements ?? []) {
|
|
1045
|
+
if (el?.type !== 'image')
|
|
1046
|
+
continue;
|
|
1047
|
+
const fields = {
|
|
1048
|
+
type: el.type,
|
|
1049
|
+
url: el.url || el.attrs?.url || '',
|
|
1050
|
+
src: el.src || el.attrs?.src || '',
|
|
1051
|
+
file: el.file || el.attrs?.file || '',
|
|
1052
|
+
fileId: el.fileId || el.file_id || el.attrs?.file_id || '',
|
|
1053
|
+
data: el.data ? String(el.data).slice(0, 40) : (el.attrs?.data ? String(el.attrs.data).slice(0, 40) : ''),
|
|
1054
|
+
};
|
|
1055
|
+
elems.push(fields);
|
|
1056
|
+
}
|
|
1057
|
+
return {
|
|
1058
|
+
hasCqImage: rawCq.length > 0,
|
|
1059
|
+
rawCq,
|
|
1060
|
+
elementImages: elems,
|
|
1061
|
+
extractableUrls: this.extractMessageImageUrls(session, 10),
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
logImageDebug(session) {
|
|
1065
|
+
const info = this.describeImageMessage(session);
|
|
1066
|
+
this._logger.info(`[img-debug] cq=${info.rawCq.length} elements=${info.elementImages.length} urls=${JSON.stringify(info.extractableUrls)} cqRaw=${JSON.stringify(info.rawCq.slice(0, 2))} elems=${JSON.stringify(info.elementImages).slice(0, 300)} | ${this.sessionTag(session)}`);
|
|
1067
|
+
}
|
|
1068
|
+
extractImageFingerprintKeys(session, maxCount = 6) {
|
|
1069
|
+
const keys = [];
|
|
1070
|
+
const content = session.content ?? '';
|
|
1071
|
+
const pushKey = (value) => {
|
|
1072
|
+
const key = value.trim();
|
|
1073
|
+
if (!key)
|
|
1074
|
+
return;
|
|
1075
|
+
keys.push(key);
|
|
1076
|
+
};
|
|
1077
|
+
const hashPattern = /([A-Fa-f0-9]{32})/g;
|
|
1078
|
+
let hashMatch;
|
|
1079
|
+
while ((hashMatch = hashPattern.exec(content))) {
|
|
1080
|
+
pushKey(`hash:${hashMatch[1].toUpperCase()}`);
|
|
1081
|
+
if (keys.length >= maxCount)
|
|
1082
|
+
return [...new Set(keys)];
|
|
1083
|
+
}
|
|
1084
|
+
const cqPattern = /\[CQ:image,[^\]]*\]/gi;
|
|
1085
|
+
const cqList = content.match(cqPattern) ?? [];
|
|
1086
|
+
for (const chunk of cqList) {
|
|
1087
|
+
const file = chunk.match(/file=([^,\]]+)/i)?.[1];
|
|
1088
|
+
const id = chunk.match(/(?:file_id|id)=([^,\]]+)/i)?.[1];
|
|
1089
|
+
const md5 = chunk.match(/md5=([^,\]]+)/i)?.[1];
|
|
1090
|
+
const url = chunk.match(/(?:url|src)=([^,\]]+)/i)?.[1];
|
|
1091
|
+
let key = '';
|
|
1092
|
+
if (md5)
|
|
1093
|
+
key = `md5:${decodeURIComponent(md5).toUpperCase()}`;
|
|
1094
|
+
else if (file)
|
|
1095
|
+
key = `file:${decodeURIComponent(file)}`;
|
|
1096
|
+
else if (id)
|
|
1097
|
+
key = `id:${decodeURIComponent(id)}`;
|
|
1098
|
+
else if (url) {
|
|
1099
|
+
const u = decodeURIComponent(url);
|
|
1100
|
+
const hash = u.match(/([A-Fa-f0-9]{32})/)?.[1];
|
|
1101
|
+
if (hash)
|
|
1102
|
+
key = `hash:${hash.toUpperCase()}`;
|
|
1103
|
+
else {
|
|
1104
|
+
try {
|
|
1105
|
+
const parsed = new URL(u);
|
|
1106
|
+
key = `urlp:${parsed.origin}${parsed.pathname}`;
|
|
1107
|
+
}
|
|
1108
|
+
catch {
|
|
1109
|
+
key = `url:${u.split('?')[0]}`;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
if (!key && file) {
|
|
1114
|
+
const hash = decodeURIComponent(file).match(/([A-Fa-f0-9]{32})/)?.[1];
|
|
1115
|
+
if (hash)
|
|
1116
|
+
key = `hash:${hash.toUpperCase()}`;
|
|
1117
|
+
}
|
|
1118
|
+
if (!key)
|
|
1119
|
+
continue;
|
|
1120
|
+
pushKey(key);
|
|
1121
|
+
if (keys.length >= maxCount)
|
|
1122
|
+
return [...new Set(keys)];
|
|
1123
|
+
}
|
|
1124
|
+
const htmlImgPattern = /<img\b[^>]*src=["']([^"']+)["'][^>]*>/gi;
|
|
1125
|
+
let htmlMatch;
|
|
1126
|
+
while ((htmlMatch = htmlImgPattern.exec(content))) {
|
|
1127
|
+
const src = htmlMatch[1] || '';
|
|
1128
|
+
const hash = src.match(/([A-Fa-f0-9]{32})/)?.[1];
|
|
1129
|
+
if (hash) {
|
|
1130
|
+
pushKey(`hash:${hash.toUpperCase()}`);
|
|
1131
|
+
}
|
|
1132
|
+
else if (src) {
|
|
1133
|
+
try {
|
|
1134
|
+
const parsed = new URL(src);
|
|
1135
|
+
pushKey(`urlp:${parsed.origin}${parsed.pathname}`);
|
|
1136
|
+
}
|
|
1137
|
+
catch {
|
|
1138
|
+
pushKey(`url:${src.split('?')[0]}`);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
if (keys.length >= maxCount)
|
|
1142
|
+
return [...new Set(keys)];
|
|
1143
|
+
}
|
|
1144
|
+
const elements = session.elements ?? [];
|
|
1145
|
+
for (const element of elements) {
|
|
1146
|
+
if (element.type !== 'image')
|
|
1147
|
+
continue;
|
|
1148
|
+
const file = (typeof element.file === 'string' && element.file)
|
|
1149
|
+
|| (typeof element.attrs?.file === 'string' && element.attrs.file);
|
|
1150
|
+
const id = (typeof element.id === 'string' && element.id)
|
|
1151
|
+
|| (typeof element.attrs?.id === 'string' && element.attrs.id)
|
|
1152
|
+
|| (typeof element.attrs?.file_id === 'string' && element.attrs.file_id);
|
|
1153
|
+
const md5 = typeof element.attrs?.md5 === 'string' ? element.attrs.md5 : '';
|
|
1154
|
+
const url = (typeof element.url === 'string' && element.url)
|
|
1155
|
+
|| (typeof element.src === 'string' && element.src)
|
|
1156
|
+
|| (typeof element.attrs?.url === 'string' && element.attrs.url)
|
|
1157
|
+
|| (typeof element.attrs?.src === 'string' && element.attrs.src);
|
|
1158
|
+
let key = '';
|
|
1159
|
+
if (md5)
|
|
1160
|
+
key = `md5:${md5.toUpperCase()}`;
|
|
1161
|
+
else if (file)
|
|
1162
|
+
key = `file:${file}`;
|
|
1163
|
+
else if (id)
|
|
1164
|
+
key = `id:${id}`;
|
|
1165
|
+
else if (url) {
|
|
1166
|
+
const hash = url.match(/([A-Fa-f0-9]{32})/)?.[1];
|
|
1167
|
+
if (hash)
|
|
1168
|
+
key = `hash:${hash.toUpperCase()}`;
|
|
1169
|
+
else {
|
|
1170
|
+
try {
|
|
1171
|
+
const parsed = new URL(url);
|
|
1172
|
+
key = `urlp:${parsed.origin}${parsed.pathname}`;
|
|
1173
|
+
}
|
|
1174
|
+
catch {
|
|
1175
|
+
key = `url:${url.split('?')[0]}`;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
if (!key && file) {
|
|
1180
|
+
const hash = file.match(/([A-Fa-f0-9]{32})/)?.[1];
|
|
1181
|
+
if (hash)
|
|
1182
|
+
key = `hash:${hash.toUpperCase()}`;
|
|
1183
|
+
}
|
|
1184
|
+
if (!key)
|
|
1185
|
+
continue;
|
|
1186
|
+
pushKey(key);
|
|
1187
|
+
if (keys.length >= maxCount)
|
|
1188
|
+
break;
|
|
1189
|
+
}
|
|
1190
|
+
return [...new Set(keys)].slice(0, maxCount);
|
|
1191
|
+
}
|
|
1192
|
+
normalizeRepeatText(text) {
|
|
1193
|
+
return text
|
|
1194
|
+
.replace(/<img\b[^>]*>/gi, '[图片]')
|
|
1195
|
+
.replace(/\[CQ:image,[^\]]*\]/gi, '[图片]')
|
|
1196
|
+
.replace(/\s+/g, ' ')
|
|
1197
|
+
.trim();
|
|
1198
|
+
}
|
|
1199
|
+
buildRepeatPayload(session) {
|
|
1200
|
+
const text = this.normalizeRepeatText(this.extractAiMessageText(session));
|
|
1201
|
+
const images = this.extractMessageImageUrls(session, 6);
|
|
1202
|
+
const imageKeys = this.extractImageFingerprintKeys(session, 6);
|
|
1203
|
+
const normalizedText = text.replace(/\s+/g, ' ').trim();
|
|
1204
|
+
const textCore = normalizedText.replace(/\[图片\]/g, '').trim();
|
|
1205
|
+
const signature = imageKeys.length
|
|
1206
|
+
? `img:${imageKeys.join('|')}|txt:${textCore}`
|
|
1207
|
+
: `txt:${normalizedText}`;
|
|
1208
|
+
const rawImageSegments = (session.content ?? '').match(/\[CQ:image,[^\]]+\]/gi) ?? [];
|
|
1209
|
+
const sendRefs = this.extractImageSendRefs(session);
|
|
1210
|
+
return { signature, text: normalizedText, images, imageKeys, rawImageSegments, sendRefs };
|
|
1211
|
+
}
|
|
1212
|
+
parseCqImageParams(chunk) {
|
|
1213
|
+
const body = chunk.replace(/^\[CQ:image,?/i, '').replace(/\]$/i, '');
|
|
1214
|
+
const params = {};
|
|
1215
|
+
for (const part of body.split(',')) {
|
|
1216
|
+
const idx = part.indexOf('=');
|
|
1217
|
+
if (idx <= 0)
|
|
1218
|
+
continue;
|
|
1219
|
+
const key = part.slice(0, idx).trim().toLowerCase();
|
|
1220
|
+
const value = part.slice(idx + 1).trim();
|
|
1221
|
+
if (!key || !value)
|
|
1222
|
+
continue;
|
|
1223
|
+
try {
|
|
1224
|
+
params[key] = decodeURIComponent(value);
|
|
1225
|
+
}
|
|
1226
|
+
catch {
|
|
1227
|
+
params[key] = value;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return params;
|
|
1231
|
+
}
|
|
1232
|
+
extractImageRefsFromRawMessage(raw, maxCount = 6) {
|
|
1233
|
+
const refs = [];
|
|
1234
|
+
const push = (value) => {
|
|
1235
|
+
if (typeof value !== 'string')
|
|
1236
|
+
return;
|
|
1237
|
+
const v = value.trim();
|
|
1238
|
+
if (!v)
|
|
1239
|
+
return;
|
|
1240
|
+
refs.push(v);
|
|
1241
|
+
};
|
|
1242
|
+
const parseString = (text) => {
|
|
1243
|
+
const cqPattern = /\[CQ:image,[^\]]*\]/gi;
|
|
1244
|
+
for (const chunk of text.match(cqPattern) ?? []) {
|
|
1245
|
+
const params = this.parseCqImageParams(chunk);
|
|
1246
|
+
if (params.file_id)
|
|
1247
|
+
push(params.file_id);
|
|
1248
|
+
if (params.id)
|
|
1249
|
+
push(params.id);
|
|
1250
|
+
if (params.file)
|
|
1251
|
+
push(params.file);
|
|
1252
|
+
if (params.url)
|
|
1253
|
+
push(params.url);
|
|
1254
|
+
if (params.src)
|
|
1255
|
+
push(params.src);
|
|
1256
|
+
if (refs.length >= maxCount)
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
const htmlImgPattern = /<img\b[^>]*src=["']([^"']+)["'][^>]*>/gi;
|
|
1260
|
+
let htmlMatch;
|
|
1261
|
+
while ((htmlMatch = htmlImgPattern.exec(text))) {
|
|
1262
|
+
push(htmlMatch[1]);
|
|
1263
|
+
if (refs.length >= maxCount)
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
const walk = (value) => {
|
|
1268
|
+
if (refs.length >= maxCount || value == null)
|
|
1269
|
+
return;
|
|
1270
|
+
if (typeof value === 'string') {
|
|
1271
|
+
parseString(value);
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
if (Array.isArray(value)) {
|
|
1275
|
+
for (const item of value) {
|
|
1276
|
+
walk(item);
|
|
1277
|
+
if (refs.length >= maxCount)
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
if (typeof value !== 'object')
|
|
1283
|
+
return;
|
|
1284
|
+
const node = value;
|
|
1285
|
+
const candidates = [
|
|
1286
|
+
node.url,
|
|
1287
|
+
node.src,
|
|
1288
|
+
node.file,
|
|
1289
|
+
node.id,
|
|
1290
|
+
node.file_id,
|
|
1291
|
+
node.data?.url,
|
|
1292
|
+
node.data?.src,
|
|
1293
|
+
node.data?.file,
|
|
1294
|
+
node.data?.id,
|
|
1295
|
+
node.data?.file_id,
|
|
1296
|
+
node.attrs?.url,
|
|
1297
|
+
node.attrs?.src,
|
|
1298
|
+
node.attrs?.file,
|
|
1299
|
+
node.attrs?.id,
|
|
1300
|
+
node.attrs?.file_id,
|
|
1301
|
+
];
|
|
1302
|
+
for (const item of candidates) {
|
|
1303
|
+
push(item);
|
|
1304
|
+
if (refs.length >= maxCount)
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
walk(node.message);
|
|
1308
|
+
walk(node.raw_message);
|
|
1309
|
+
walk(node.content);
|
|
1310
|
+
};
|
|
1311
|
+
walk(raw);
|
|
1312
|
+
return [...new Set(refs)].slice(0, maxCount);
|
|
1313
|
+
}
|
|
1314
|
+
extractImageSegmentsFromRawMessage(raw, maxCount = 6) {
|
|
1315
|
+
const segments = [];
|
|
1316
|
+
const push = (value) => {
|
|
1317
|
+
const v = value.trim();
|
|
1318
|
+
if (!v)
|
|
1319
|
+
return;
|
|
1320
|
+
segments.push(v);
|
|
1321
|
+
};
|
|
1322
|
+
const parseString = (text) => {
|
|
1323
|
+
const cqPattern = /\[CQ:image,[^\]]+\]/gi;
|
|
1324
|
+
for (const chunk of text.match(cqPattern) ?? []) {
|
|
1325
|
+
push(chunk);
|
|
1326
|
+
if (segments.length >= maxCount)
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
};
|
|
1330
|
+
const walk = (value) => {
|
|
1331
|
+
if (segments.length >= maxCount || value == null)
|
|
1332
|
+
return;
|
|
1333
|
+
if (typeof value === 'string') {
|
|
1334
|
+
parseString(value);
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
if (Array.isArray(value)) {
|
|
1338
|
+
for (const item of value) {
|
|
1339
|
+
walk(item);
|
|
1340
|
+
if (segments.length >= maxCount)
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
if (typeof value !== 'object')
|
|
1346
|
+
return;
|
|
1347
|
+
const node = value;
|
|
1348
|
+
walk(node.message);
|
|
1349
|
+
walk(node.raw_message);
|
|
1350
|
+
walk(node.content);
|
|
1351
|
+
};
|
|
1352
|
+
walk(raw);
|
|
1353
|
+
return [...new Set(segments)].slice(0, maxCount);
|
|
1354
|
+
}
|
|
1355
|
+
extractImageSendRefs(session, maxCount = 6) {
|
|
1356
|
+
const refs = [];
|
|
1357
|
+
const pushRef = (value) => {
|
|
1358
|
+
const v = value.trim();
|
|
1359
|
+
if (!v)
|
|
1360
|
+
return;
|
|
1361
|
+
refs.push(v);
|
|
1362
|
+
};
|
|
1363
|
+
const content = session.content ?? '';
|
|
1364
|
+
const cqPattern = /\[CQ:image,[^\]]*\]/gi;
|
|
1365
|
+
for (const chunk of content.match(cqPattern) ?? []) {
|
|
1366
|
+
const params = this.parseCqImageParams(chunk);
|
|
1367
|
+
if (params.file_id)
|
|
1368
|
+
pushRef(params.file_id);
|
|
1369
|
+
if (params.id)
|
|
1370
|
+
pushRef(params.id);
|
|
1371
|
+
if (params.file)
|
|
1372
|
+
pushRef(params.file);
|
|
1373
|
+
if (params.url)
|
|
1374
|
+
pushRef(params.url);
|
|
1375
|
+
if (params.src)
|
|
1376
|
+
pushRef(params.src);
|
|
1377
|
+
if (refs.length >= maxCount)
|
|
1378
|
+
return [...new Set(refs)].slice(0, maxCount);
|
|
1379
|
+
}
|
|
1380
|
+
const elements = session.elements ?? [];
|
|
1381
|
+
for (const element of elements) {
|
|
1382
|
+
if (element.type !== 'image')
|
|
1383
|
+
continue;
|
|
1384
|
+
const candidates = [
|
|
1385
|
+
typeof element.id === 'string' ? element.id : '',
|
|
1386
|
+
typeof element.file === 'string' ? element.file : '',
|
|
1387
|
+
typeof element.url === 'string' ? element.url : '',
|
|
1388
|
+
typeof element.src === 'string' ? element.src : '',
|
|
1389
|
+
typeof element.attrs?.file_id === 'string' ? element.attrs.file_id : '',
|
|
1390
|
+
typeof element.attrs?.id === 'string' ? element.attrs.id : '',
|
|
1391
|
+
typeof element.attrs?.file === 'string' ? element.attrs.file : '',
|
|
1392
|
+
typeof element.attrs?.url === 'string' ? element.attrs.url : '',
|
|
1393
|
+
typeof element.attrs?.src === 'string' ? element.attrs.src : '',
|
|
1394
|
+
].filter(Boolean);
|
|
1395
|
+
for (const item of candidates) {
|
|
1396
|
+
pushRef(item);
|
|
1397
|
+
if (refs.length >= maxCount)
|
|
1398
|
+
return [...new Set(refs)].slice(0, maxCount);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
return [...new Set(refs)].slice(0, maxCount);
|
|
1402
|
+
}
|
|
1403
|
+
async fetchMessageImagePayload(session, maxCount = 6) {
|
|
1404
|
+
if (!this.config.repeaterEnableGetMsgRefetch)
|
|
1405
|
+
return { segments: [], refs: [] };
|
|
1406
|
+
if (session.platform !== 'onebot')
|
|
1407
|
+
return { segments: [], refs: [] };
|
|
1408
|
+
const messageId = session.messageId;
|
|
1409
|
+
if (!messageId)
|
|
1410
|
+
return { segments: [], refs: [] };
|
|
1411
|
+
const onebot = this.getOneBotApi(session);
|
|
1412
|
+
if (!onebot?.getMsg)
|
|
1413
|
+
return { segments: [], refs: [] };
|
|
1414
|
+
try {
|
|
1415
|
+
const data = await onebot.getMsg(messageId);
|
|
1416
|
+
return {
|
|
1417
|
+
segments: this.extractImageSegmentsFromRawMessage(data, maxCount),
|
|
1418
|
+
refs: this.extractImageRefsFromRawMessage(data, maxCount),
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
catch (error) {
|
|
1422
|
+
this._logger.warn(`[repeater] get_msg refetch failed: ${String(error)} | ${this.sessionTag(session)} messageId=${messageId}`);
|
|
1423
|
+
return { segments: [], refs: [] };
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
async sendImageRef(session, ref) {
|
|
1427
|
+
const value = ref.trim();
|
|
1428
|
+
if (!value)
|
|
1429
|
+
return false;
|
|
1430
|
+
const isHttp = /^https?:\/\//i.test(value);
|
|
1431
|
+
if (isHttp)
|
|
1432
|
+
return false;
|
|
1433
|
+
try {
|
|
1434
|
+
await session.send((0, koishi_1.h)('image', { src: value, cache: 0 }));
|
|
1435
|
+
return true;
|
|
1436
|
+
}
|
|
1437
|
+
catch (error) {
|
|
1438
|
+
this._logger.warn(`[repeater] send-image-by-file-failed: ${String(error)} | ${this.sessionTag(session)} ref=${value}`);
|
|
1439
|
+
}
|
|
1440
|
+
try {
|
|
1441
|
+
await session.send(koishi_1.h.image(value));
|
|
1442
|
+
return true;
|
|
1443
|
+
}
|
|
1444
|
+
catch {
|
|
1445
|
+
return false;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
async handleRepeater(session) {
|
|
1449
|
+
if (!this.config.enableRepeater)
|
|
1450
|
+
return;
|
|
1451
|
+
if (!session.guildId || !session.userId)
|
|
1452
|
+
return;
|
|
1453
|
+
if (session.userId === session.bot?.selfId)
|
|
1454
|
+
return;
|
|
1455
|
+
const groupKey = this.aiGroupKey(session);
|
|
1456
|
+
if (!groupKey)
|
|
1457
|
+
return;
|
|
1458
|
+
const payload = this.buildRepeatPayload(session);
|
|
1459
|
+
if (!payload.signature || (!payload.text && !payload.images.length && !payload.rawImageSegments.length && !payload.sendRefs.length && !payload.imageKeys.length))
|
|
1460
|
+
return;
|
|
1461
|
+
const now = Date.now();
|
|
1462
|
+
const prev = this.repeatStates.get(groupKey);
|
|
1463
|
+
const next = prev && prev.signature === payload.signature
|
|
1464
|
+
? { ...prev, count: prev.count + 1, updatedAt: now }
|
|
1465
|
+
: { signature: payload.signature, count: 1, updatedAt: now, lastRepeatedSignature: prev?.lastRepeatedSignature, lastRepeatedAt: prev?.lastRepeatedAt };
|
|
1466
|
+
this.repeatStates.set(groupKey, next);
|
|
1467
|
+
const threshold = Math.max(2, this.config.repeaterThreshold || 3);
|
|
1468
|
+
if (next.count < threshold)
|
|
1469
|
+
return;
|
|
1470
|
+
const cooldownMs = Math.max(1, this.config.repeaterCooldownSeconds || 90) * 1000;
|
|
1471
|
+
const isSameAsLast = next.lastRepeatedSignature === payload.signature;
|
|
1472
|
+
if (isSameAsLast && next.lastRepeatedAt && now - next.lastRepeatedAt < cooldownMs)
|
|
1473
|
+
return;
|
|
1474
|
+
const meaningfulText = payload.text.replace(/\[图片\]/g, '').replace(/\s+/g, '').trim();
|
|
1475
|
+
if (meaningfulText) {
|
|
1476
|
+
const parts = this.splitAiReplyParts(payload.text);
|
|
1477
|
+
let first = true;
|
|
1478
|
+
for (const part of parts) {
|
|
1479
|
+
if (!first)
|
|
1480
|
+
await this.waitHumanLikeDelay(350, 900);
|
|
1481
|
+
await session.send(koishi_1.h.text(part));
|
|
1482
|
+
first = false;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
let imageSent = false;
|
|
1486
|
+
let sendAttemptCount = 0;
|
|
1487
|
+
let sendSuccessCount = 0;
|
|
1488
|
+
let resolvedSegmentsFromGetMsg = 0;
|
|
1489
|
+
let resolvedRefsFromGetMsg = 0;
|
|
1490
|
+
const sentRefSet = new Set();
|
|
1491
|
+
const trySendRef = async (raw) => {
|
|
1492
|
+
const ref = raw.trim();
|
|
1493
|
+
if (!ref || /^https?:\/\//i.test(ref))
|
|
1494
|
+
return false;
|
|
1495
|
+
if (sentRefSet.has(ref))
|
|
1496
|
+
return false;
|
|
1497
|
+
sentRefSet.add(ref);
|
|
1498
|
+
await this.waitHumanLikeDelay(420, 980);
|
|
1499
|
+
sendAttemptCount += 1;
|
|
1500
|
+
if (await this.sendImageRef(session, ref)) {
|
|
1501
|
+
imageSent = true;
|
|
1502
|
+
sendSuccessCount += 1;
|
|
1503
|
+
return true;
|
|
1504
|
+
}
|
|
1505
|
+
return false;
|
|
1506
|
+
};
|
|
1507
|
+
for (const segment of payload.rawImageSegments) {
|
|
1508
|
+
const params = this.parseCqImageParams(segment);
|
|
1509
|
+
const refs = [params.file_id, params.id, params.file, params.url, params.src].filter(Boolean);
|
|
1510
|
+
for (const ref of refs) {
|
|
1511
|
+
await trySendRef(ref);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
if (!imageSent) {
|
|
1515
|
+
for (const ref of payload.sendRefs) {
|
|
1516
|
+
await trySendRef(ref);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
if (!imageSent && payload.imageKeys.length) {
|
|
1520
|
+
const fetched = await this.fetchMessageImagePayload(session, 6);
|
|
1521
|
+
resolvedSegmentsFromGetMsg = fetched.segments.length;
|
|
1522
|
+
resolvedRefsFromGetMsg = fetched.refs.length;
|
|
1523
|
+
for (const segment of fetched.segments) {
|
|
1524
|
+
const params = this.parseCqImageParams(segment);
|
|
1525
|
+
const file = params.file || '';
|
|
1526
|
+
const fileId = params.file_id || params.id || '';
|
|
1527
|
+
if (fileId)
|
|
1528
|
+
await trySendRef(fileId);
|
|
1529
|
+
if (file && !/^https?:\/\//i.test(file))
|
|
1530
|
+
await trySendRef(file);
|
|
1531
|
+
}
|
|
1532
|
+
for (const ref of fetched.refs) {
|
|
1533
|
+
await trySendRef(ref);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
const noSendableReason = !imageSent && payload.imageKeys.length ? 'no-sendable-image-payload' : '';
|
|
1537
|
+
if (!imageSent) {
|
|
1538
|
+
this._logger.warn(`[repeater] triggered but no sendable image payload | ${this.sessionTag(session)} keys=${JSON.stringify(payload.imageKeys)} refs=${JSON.stringify(payload.sendRefs)}`);
|
|
1539
|
+
}
|
|
1540
|
+
this.repeatStates.set(groupKey, {
|
|
1541
|
+
...next,
|
|
1542
|
+
lastRepeatedSignature: payload.signature,
|
|
1543
|
+
lastRepeatedAt: now,
|
|
1544
|
+
});
|
|
1545
|
+
this.logCommandResult('repeater', { ok: true, message: `repeat triggered x${next.count}` }, session, {
|
|
1546
|
+
text: payload.text,
|
|
1547
|
+
images: payload.images.length,
|
|
1548
|
+
imageKeys: payload.imageKeys,
|
|
1549
|
+
sendRefs: payload.sendRefs,
|
|
1550
|
+
rawSegments: payload.rawImageSegments.length,
|
|
1551
|
+
resolvedSegmentsFromGetMsg,
|
|
1552
|
+
resolvedRefsFromGetMsg,
|
|
1553
|
+
sendAttemptCount,
|
|
1554
|
+
sendSuccessCount,
|
|
1555
|
+
reason: noSendableReason || undefined,
|
|
1556
|
+
});
|
|
1557
|
+
this.appendAiContextNote(groupKey, `群内触发复读:${payload.text || '[图片消息]'} (${payload.images.length}张图)`);
|
|
1558
|
+
}
|
|
1559
|
+
async callOpenAiCompatibleWithImages(prompt, imageUrls) {
|
|
1560
|
+
const apiKey = this.config.aiApiKey?.trim();
|
|
1561
|
+
if (!apiKey)
|
|
1562
|
+
return { ok: false, message: 'AI 未配置 apiKey。' };
|
|
1563
|
+
const base = (this.config.aiBaseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '');
|
|
1564
|
+
const model = this.config.aiModel?.trim();
|
|
1565
|
+
if (!model)
|
|
1566
|
+
return { ok: false, message: 'AI 未配置 model。' };
|
|
1567
|
+
const userContent = [{ type: 'text', text: prompt }];
|
|
1568
|
+
for (const url of imageUrls) {
|
|
1569
|
+
userContent.push({ type: 'image_url', image_url: { url } });
|
|
1570
|
+
}
|
|
1571
|
+
const controller = new AbortController();
|
|
1572
|
+
const timeout = setTimeout(() => controller.abort(), 25_000);
|
|
1573
|
+
try {
|
|
1574
|
+
const response = await fetch(`${base}/chat/completions`, {
|
|
1575
|
+
method: 'POST',
|
|
1576
|
+
headers: {
|
|
1577
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1578
|
+
'Content-Type': 'application/json',
|
|
1579
|
+
},
|
|
1580
|
+
body: JSON.stringify({
|
|
1581
|
+
model,
|
|
1582
|
+
temperature: this.config.aiTemperature,
|
|
1583
|
+
max_tokens: this.config.aiMaxOutputTokens,
|
|
1584
|
+
messages: [
|
|
1585
|
+
{ role: 'system', content: this.getActiveSystemPrompt() },
|
|
1586
|
+
{ role: 'user', content: userContent },
|
|
1587
|
+
],
|
|
1588
|
+
}),
|
|
1589
|
+
signal: controller.signal,
|
|
1590
|
+
});
|
|
1591
|
+
const payload = await response.json().catch(() => ({}));
|
|
1592
|
+
if (!response.ok) {
|
|
1593
|
+
const errorMessage = payload?.error?.message;
|
|
1594
|
+
return { ok: false, message: `OpenAI兼容接口错误: ${errorMessage || response.statusText}` };
|
|
1595
|
+
}
|
|
1596
|
+
const content = payload?.choices?.[0]?.message?.content;
|
|
1597
|
+
if (typeof content === 'string')
|
|
1598
|
+
return { ok: true, message: content };
|
|
1599
|
+
if (Array.isArray(content)) {
|
|
1600
|
+
const text = content.map((item) => item?.text || '').join('').trim();
|
|
1601
|
+
if (text)
|
|
1602
|
+
return { ok: true, message: text };
|
|
1603
|
+
}
|
|
1604
|
+
return { ok: false, message: 'OpenAI兼容接口未返回可用文本。' };
|
|
1605
|
+
}
|
|
1606
|
+
catch (error) {
|
|
1607
|
+
return { ok: false, message: `OpenAI兼容请求失败: ${String(error)}` };
|
|
1608
|
+
}
|
|
1609
|
+
finally {
|
|
1610
|
+
clearTimeout(timeout);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
async generateAiReply(session, options) {
|
|
1614
|
+
const key = this.aiGroupKey(session);
|
|
1615
|
+
if (!key)
|
|
1616
|
+
return { ok: false, message: '缺少群聊上下文。' };
|
|
1617
|
+
const records = this.aiBuffers.get(key) ?? [];
|
|
1618
|
+
if (!records.length)
|
|
1619
|
+
return { ok: false, message: '无可用聊天上下文。' };
|
|
1620
|
+
let prompt = this.buildAiUserPrompt(records, options);
|
|
1621
|
+
prompt = await this.decoratePromptWithMemory(prompt, options);
|
|
1622
|
+
const focusImages = (options?.focusImages ?? []).filter((url) => /^https?:\/\//i.test(url));
|
|
1623
|
+
const result = this.config.aiProvider === 'gemini'
|
|
1624
|
+
? await this.callGemini(focusImages.length
|
|
1625
|
+
? `${prompt}\n\n点名消息图片链接(如可访问请一并识别):\n${focusImages.map((item, i) => `${i + 1}. ${item}`).join('\n')}`
|
|
1626
|
+
: prompt)
|
|
1627
|
+
: (focusImages.length
|
|
1628
|
+
? await this.callOpenAiCompatibleWithImages(prompt, focusImages)
|
|
1629
|
+
: await this.callOpenAiCompatible(prompt));
|
|
1630
|
+
if (!result.ok)
|
|
1631
|
+
return result;
|
|
1632
|
+
const text = this.normalizeAiOutput(result.message, false).trim();
|
|
1633
|
+
if (!text)
|
|
1634
|
+
return { ok: false, message: 'AI 返回空响应。' };
|
|
1635
|
+
const normalized = text.length > 600 ? `${text.slice(0, 600)}...` : text;
|
|
1636
|
+
return { ok: true, message: normalized };
|
|
1637
|
+
}
|
|
1638
|
+
async maybeReplyWithAi(session) {
|
|
1639
|
+
if (!this.resolveAiEnabled(session))
|
|
1640
|
+
return;
|
|
1641
|
+
if (!session.guildId || !session.userId)
|
|
1642
|
+
return;
|
|
1643
|
+
if (session.userId === session.bot?.selfId)
|
|
1644
|
+
return;
|
|
1645
|
+
if (this.isDuplicateAiEvent(session))
|
|
1646
|
+
return;
|
|
1647
|
+
this.rememberUser(session);
|
|
1648
|
+
const groupKey = this.aiGroupKey(session);
|
|
1649
|
+
if (!groupKey)
|
|
1650
|
+
return;
|
|
1651
|
+
const text = this.extractAiMessageText(session);
|
|
1652
|
+
// Always record context first, including other users during follow-up windows.
|
|
1653
|
+
this.pushAiRecord(session, text || '@bot');
|
|
1654
|
+
const atBot = this.isMentionBot(session);
|
|
1655
|
+
const messageImages = this.extractImageUrls(session);
|
|
1656
|
+
// 识图调试:消息里带图但提取不到可发送链接时,记录原始图片字段,便于排查
|
|
1657
|
+
if (this.config.aiEnableImageRecognition) {
|
|
1658
|
+
const hasImage = /\[CQ:image,/i.test(session.content ?? '')
|
|
1659
|
+
|| (session.elements ?? []).some((el) => el?.type === 'image');
|
|
1660
|
+
if (hasImage && !messageImages.length) {
|
|
1661
|
+
this.logImageDebug(session);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
const directMention = this.config.aiEnableDirectMentionTrigger && (this.containsAgentName(text) || atBot);
|
|
1665
|
+
const followup = this.getFollowupSession(groupKey);
|
|
1666
|
+
const isFollowupUser = !!followup && followup.userId === session.userId;
|
|
1667
|
+
const talkingToOthers = isFollowupUser && this.isAddressingOtherThanBot(session);
|
|
1668
|
+
const isOwnerUser = this.isHomeUser(session);
|
|
1669
|
+
const followupForce = !!(this.config.aiEnableFollowupAfterMention
|
|
1670
|
+
&& isFollowupUser
|
|
1671
|
+
&& !directMention
|
|
1672
|
+
&& !talkingToOthers
|
|
1673
|
+
&& this.shouldContinueFollowup(text, followup));
|
|
1674
|
+
const relatedContextFollowup = !!(this.config.aiEnableFollowupAfterMention
|
|
1675
|
+
&& followup
|
|
1676
|
+
&& !isFollowupUser
|
|
1677
|
+
&& !directMention
|
|
1678
|
+
&& this.shouldContinueFollowup(text, followup)
|
|
1679
|
+
&& (isOwnerUser || /[??]|(何意|何意味|什么意思|啥意思|怎么回事|发生了什么)/.test(text)));
|
|
1680
|
+
if (talkingToOthers) {
|
|
1681
|
+
this.aiFollowups.delete(groupKey);
|
|
1682
|
+
}
|
|
1683
|
+
if (followup && !isFollowupUser && !directMention && !relatedContextFollowup)
|
|
1684
|
+
return;
|
|
1685
|
+
if (!text && !directMention && !followupForce && !relatedContextFollowup)
|
|
1686
|
+
return;
|
|
1687
|
+
if (this.config.aiIgnoreCommandMessage && !directMention && !followupForce && !relatedContextFollowup && this.isCommandLikeMessage(text))
|
|
1688
|
+
return;
|
|
1689
|
+
const trigger = directMention
|
|
1690
|
+
? { ok: true, reason: 'direct-mention' }
|
|
1691
|
+
: (followupForce || relatedContextFollowup)
|
|
1692
|
+
? { ok: true, reason: 'followup' }
|
|
1693
|
+
: this.shouldTriggerAiReply(session, false);
|
|
1694
|
+
if (!trigger.ok)
|
|
1695
|
+
return;
|
|
1696
|
+
const topicHint = (this.aiTopicMemory.get(groupKey)?.text || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
1697
|
+
if (trigger.reason === 'interest-check') {
|
|
1698
|
+
const interest = await this.shouldReplyByInterest({ groupKey, text });
|
|
1699
|
+
this.logCommandResult('ai-interest', { ok: interest.ok, message: `score=${interest.score} threshold=${interest.threshold} reason=${interest.reason}` }, session, { ctxSize: interest.ctxSize, topicHint: interest.topicHint || topicHint || undefined });
|
|
1700
|
+
if (!interest.ok)
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
const thresholdMode = typeof trigger.reason === 'string' && trigger.reason.startsWith('threshold(');
|
|
1704
|
+
const topicMemory = this.aiTopicMemory.get(groupKey);
|
|
1705
|
+
const topicTokens = topicMemory?.tokens ?? [];
|
|
1706
|
+
let selectedRecord;
|
|
1707
|
+
if (thresholdMode && topicTokens.length) {
|
|
1708
|
+
const records = this.aiBuffers.get(groupKey) ?? [];
|
|
1709
|
+
let bestScore = -1;
|
|
1710
|
+
for (const record of records) {
|
|
1711
|
+
if (!record.text?.trim())
|
|
1712
|
+
continue;
|
|
1713
|
+
const score = this.scoreTopicRelevance(record.text, topicTokens);
|
|
1714
|
+
if (score > bestScore) {
|
|
1715
|
+
bestScore = score;
|
|
1716
|
+
selectedRecord = record;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
if (thresholdMode) {
|
|
1721
|
+
const interest = await this.shouldReplyByInterest({ groupKey, text });
|
|
1722
|
+
this.logCommandResult('ai-threshold-interest', { ok: interest.ok, message: `score=${interest.score} threshold=${interest.threshold} reason=${interest.reason}` }, session, { ctxSize: interest.ctxSize, topicHint: interest.topicHint || topicHint || undefined });
|
|
1723
|
+
if (!interest.ok) {
|
|
1724
|
+
this.logCommandResult('ai-threshold-skip-by-interest', { ok: true, message: 'threshold hit but skipped by low interest' }, session, {
|
|
1725
|
+
score: interest.score,
|
|
1726
|
+
threshold: interest.threshold,
|
|
1727
|
+
reason: interest.reason,
|
|
1728
|
+
});
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
this.aiInFlight.add(groupKey);
|
|
1733
|
+
const startedAt = Date.now();
|
|
1734
|
+
try {
|
|
1735
|
+
const result = await this.generateAiReply(session, {
|
|
1736
|
+
directMention,
|
|
1737
|
+
followup: (followupForce || relatedContextFollowup),
|
|
1738
|
+
thresholdMode,
|
|
1739
|
+
focusText: (directMention || followupForce || relatedContextFollowup) ? (text || '@bot') : undefined,
|
|
1740
|
+
focusUserId: (directMention || followupForce || relatedContextFollowup) ? session.userId : undefined,
|
|
1741
|
+
focusImages: directMention ? messageImages : [],
|
|
1742
|
+
focusUserName: (directMention || followupForce || relatedContextFollowup) ? this.getDisplayName(session) : undefined,
|
|
1743
|
+
topicTokens: thresholdMode ? topicTokens : undefined,
|
|
1744
|
+
selectedRecord,
|
|
1745
|
+
topicSummary: thresholdMode ? (topicHint || undefined) : undefined,
|
|
1746
|
+
});
|
|
1747
|
+
if (!result.ok) {
|
|
1748
|
+
this.logCommandResult('ai-reply', { ok: false, message: result.message }, session, { trigger: trigger.reason });
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
const imageUrls = this.extractAiImageUrls(result.message);
|
|
1752
|
+
const safeText = this.sanitizeAiOutputForSend(result.message);
|
|
1753
|
+
if (!safeText) {
|
|
1754
|
+
this.logCommandResult('ai-reply', { ok: false, message: 'ai reply empty after sanitize' }, session, { trigger: trigger.reason });
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
const parts = this.splitAiReplyParts(safeText);
|
|
1758
|
+
if (!parts.length)
|
|
1759
|
+
return;
|
|
1760
|
+
if (session.messageId) {
|
|
1761
|
+
try {
|
|
1762
|
+
await session.send([koishi_1.h.quote(session.messageId), koishi_1.h.text(parts[0])]);
|
|
1763
|
+
}
|
|
1764
|
+
catch (error) {
|
|
1765
|
+
// Some OneBot implementations reject reply ids in specific ranges; fallback to plain text.
|
|
1766
|
+
this._logger.warn(`[ai-reply] quote send failed, fallback to plain: ${String(error)} | ${this.sessionTag(session)}`);
|
|
1767
|
+
await session.send(koishi_1.h.text(parts[0]));
|
|
1768
|
+
}
|
|
1769
|
+
for (const part of parts.slice(1)) {
|
|
1770
|
+
await this.waitHumanLikeDelay();
|
|
1771
|
+
await session.send(koishi_1.h.text(part));
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
else {
|
|
1775
|
+
let first = true;
|
|
1776
|
+
for (const part of parts) {
|
|
1777
|
+
if (!first)
|
|
1778
|
+
await this.waitHumanLikeDelay();
|
|
1779
|
+
await session.send(koishi_1.h.text(part));
|
|
1780
|
+
first = false;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
for (const url of imageUrls) {
|
|
1784
|
+
try {
|
|
1785
|
+
await this.waitHumanLikeDelay(500, 1200);
|
|
1786
|
+
await session.send(koishi_1.h.image(url));
|
|
1787
|
+
}
|
|
1788
|
+
catch (error) {
|
|
1789
|
+
this._logger.warn(`[ai-reply] image send failed: ${String(error)} url=${url} | ${this.sessionTag(session)}`);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
this.aiLastReplyAt.set(groupKey, Date.now());
|
|
1793
|
+
if (!directMention && !followupForce && !relatedContextFollowup)
|
|
1794
|
+
this.aiCounters.set(groupKey, 0);
|
|
1795
|
+
if (directMention)
|
|
1796
|
+
this.startFollowupSession(groupKey, session.userId, text || '@bot');
|
|
1797
|
+
if (followupForce || relatedContextFollowup) {
|
|
1798
|
+
this.bumpFollowupSession(groupKey);
|
|
1799
|
+
const latest = this.getFollowupSession(groupKey);
|
|
1800
|
+
if (latest) {
|
|
1801
|
+
latest.lastText = text;
|
|
1802
|
+
this.aiFollowups.set(groupKey, latest);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
const replyTokens = this.tokenizeTopic(safeText);
|
|
1806
|
+
if (replyTokens.length) {
|
|
1807
|
+
this.aiTopicMemory.set(groupKey, {
|
|
1808
|
+
tokens: replyTokens,
|
|
1809
|
+
text: safeText,
|
|
1810
|
+
updatedAt: Date.now(),
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
this.logCommandResult('ai-reply', { ok: true, message: 'ai reply sent' }, session, { trigger: trigger.reason, latencyMs: Date.now() - startedAt });
|
|
1814
|
+
}
|
|
1815
|
+
catch (error) {
|
|
1816
|
+
this.logCommandResult('ai-reply', { ok: false, message: `ai reply failed: ${String(error)}` }, session, { trigger: trigger.reason });
|
|
1817
|
+
}
|
|
1818
|
+
finally {
|
|
1819
|
+
this.aiInFlight.delete(groupKey);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
async fetchOneBotStatus(session) {
|
|
1823
|
+
if (!session || session.platform !== 'onebot')
|
|
1824
|
+
return null;
|
|
1825
|
+
const onebot = this.getOneBotApi(session);
|
|
1826
|
+
if (!onebot?.getStatus)
|
|
1827
|
+
return null;
|
|
1828
|
+
try {
|
|
1829
|
+
return await onebot.getStatus();
|
|
1830
|
+
}
|
|
1831
|
+
catch (error) {
|
|
1832
|
+
this._logger.warn(`[status] 获取 OneBot 状态失败: ${String(error)}`);
|
|
1833
|
+
return null;
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
async fetchOnlineClientsCount(session) {
|
|
1837
|
+
if (!session || session.platform !== 'onebot')
|
|
1838
|
+
return null;
|
|
1839
|
+
const onebot = this.getOneBotApi(session);
|
|
1840
|
+
if (!onebot?.getOnlineClients)
|
|
1841
|
+
return null;
|
|
1842
|
+
try {
|
|
1843
|
+
const data = await onebot.getOnlineClients(true);
|
|
1844
|
+
return Array.isArray(data) ? data.length : null;
|
|
1845
|
+
}
|
|
1846
|
+
catch {
|
|
1847
|
+
return null;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
formatRssKbToMb(rssKb) {
|
|
1851
|
+
if (!Number.isFinite(rssKb) || rssKb <= 0)
|
|
1852
|
+
return '-';
|
|
1853
|
+
return `${(rssKb / 1024).toFixed(1)} MB`;
|
|
1854
|
+
}
|
|
1855
|
+
async fetchProcessUsageByHints(hints) {
|
|
1856
|
+
try {
|
|
1857
|
+
const { stdout } = await execFile('ps', ['-eo', 'comm=,%cpu=,rss=,args='], { timeout: 1800, maxBuffer: 1024 * 1024 });
|
|
1858
|
+
const loweredHints = hints.map((item) => item.toLowerCase());
|
|
1859
|
+
const lines = stdout.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
1860
|
+
let best = null;
|
|
1861
|
+
for (const line of lines) {
|
|
1862
|
+
const matched = line.match(/^(\S+)\s+(\S+)\s+(\S+)\s+(.+)$/);
|
|
1863
|
+
if (!matched)
|
|
1864
|
+
continue;
|
|
1865
|
+
const comm = matched[1];
|
|
1866
|
+
const cpu = Number(matched[2]);
|
|
1867
|
+
const rssKb = Number(matched[3]);
|
|
1868
|
+
const args = matched[4];
|
|
1869
|
+
const haystack = `${comm} ${args}`.toLowerCase();
|
|
1870
|
+
if (!loweredHints.some((hint) => haystack.includes(hint)))
|
|
1871
|
+
continue;
|
|
1872
|
+
if (!best || cpu > best.cpu) {
|
|
1873
|
+
best = { name: comm, cpu: Number.isFinite(cpu) ? cpu : 0, rssKb: Number.isFinite(rssKb) ? rssKb : 0 };
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
if (!best)
|
|
1877
|
+
return null;
|
|
1878
|
+
return {
|
|
1879
|
+
source: 'process',
|
|
1880
|
+
name: best.name,
|
|
1881
|
+
cpuText: `${best.cpu.toFixed(1)}%`,
|
|
1882
|
+
memText: this.formatRssKbToMb(best.rssKb),
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
catch {
|
|
1886
|
+
return null;
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
async fetchDockerUsageByHints(hints) {
|
|
1890
|
+
try {
|
|
1891
|
+
const { stdout: namesStdout } = await execFile('docker', ['ps', '--format', '{{.Names}}'], { timeout: 1800, maxBuffer: 1024 * 1024 });
|
|
1892
|
+
const names = namesStdout.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
1893
|
+
if (!names.length)
|
|
1894
|
+
return null;
|
|
1895
|
+
const loweredHints = hints.map((item) => item.toLowerCase());
|
|
1896
|
+
const target = names.find((name) => loweredHints.some((hint) => name.toLowerCase().includes(hint)));
|
|
1897
|
+
if (!target)
|
|
1898
|
+
return null;
|
|
1899
|
+
const { stdout: statsStdout } = await execFile('docker', ['stats', '--no-stream', '--format', '{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}'], { timeout: 2200, maxBuffer: 1024 * 1024 });
|
|
1900
|
+
const line = statsStdout
|
|
1901
|
+
.split('\n')
|
|
1902
|
+
.map((item) => item.trim())
|
|
1903
|
+
.find((item) => item.startsWith(`${target}|`));
|
|
1904
|
+
if (!line)
|
|
1905
|
+
return null;
|
|
1906
|
+
const parts = line.split('|');
|
|
1907
|
+
if (parts.length < 3)
|
|
1908
|
+
return null;
|
|
1909
|
+
const memUsage = parts[2].split('/')[0].trim();
|
|
1910
|
+
return {
|
|
1911
|
+
source: 'docker',
|
|
1912
|
+
name: parts[0],
|
|
1913
|
+
cpuText: parts[1].trim() || '-',
|
|
1914
|
+
memText: memUsage || '-',
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
catch {
|
|
1918
|
+
return null;
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
async fetchLlbotClientUsage() {
|
|
1922
|
+
const hints = ['llonebot', 'llbot', 'onebot', 'napcat', 'lagrange'];
|
|
1923
|
+
const dockerUsage = await this.fetchDockerUsageByHints(hints);
|
|
1924
|
+
if (dockerUsage)
|
|
1925
|
+
return dockerUsage;
|
|
1926
|
+
return this.fetchProcessUsageByHints(hints);
|
|
1927
|
+
}
|
|
1928
|
+
async fetchQqClientUsage() {
|
|
1929
|
+
const hints = ['ntqq', 'qq', 'qqnt'];
|
|
1930
|
+
return this.fetchProcessUsageByHints(hints);
|
|
1931
|
+
}
|
|
1932
|
+
async handleMenuPairing(session) {
|
|
1933
|
+
const key = this.pairingKey(session);
|
|
1934
|
+
if (!key)
|
|
1935
|
+
return false;
|
|
1936
|
+
const expiresAt = this.menuPairings.get(key);
|
|
1937
|
+
if (!expiresAt)
|
|
1938
|
+
return false;
|
|
1939
|
+
if (Date.now() > expiresAt) {
|
|
1940
|
+
this.menuPairings.delete(key);
|
|
1941
|
+
return false;
|
|
1942
|
+
}
|
|
1943
|
+
const keyword = this.getMessageText(session);
|
|
1944
|
+
if (!keyword)
|
|
1945
|
+
return false;
|
|
1946
|
+
this.menuPairings.delete(key);
|
|
1947
|
+
const hit = this.resolveMenuCategory(keyword, session);
|
|
1948
|
+
if (!hit) {
|
|
1949
|
+
this.logCommandResult('menu-pairing', { ok: true, message: 'paired menu ignored: no match' }, session, { keyword });
|
|
1950
|
+
return false;
|
|
1951
|
+
}
|
|
1952
|
+
const output = await this.renderMenu(keyword, session);
|
|
1953
|
+
try {
|
|
1954
|
+
await session.send(output);
|
|
1955
|
+
this.logCommandResult('menu-pairing', { ok: true, message: 'paired menu rendered' }, session, { keyword });
|
|
1956
|
+
}
|
|
1957
|
+
catch (error) {
|
|
1958
|
+
this.logCommandResult('menu-pairing', { ok: false, message: `paired menu send failed: ${String(error)}` }, session, { keyword });
|
|
1959
|
+
}
|
|
1960
|
+
return true;
|
|
1961
|
+
}
|
|
1962
|
+
getPuppeteer() {
|
|
1963
|
+
return this.ctx.puppeteer;
|
|
1964
|
+
}
|
|
1965
|
+
formatBytes(bytes) {
|
|
1966
|
+
const mb = bytes / 1024 / 1024;
|
|
1967
|
+
if (mb < 1024)
|
|
1968
|
+
return `${mb.toFixed(1)} MB`;
|
|
1969
|
+
return `${(mb / 1024).toFixed(2)} GB`;
|
|
1970
|
+
}
|
|
1971
|
+
async sampleCpuUsage(intervalMs = 160) {
|
|
1972
|
+
const snapshot = () => {
|
|
1973
|
+
const cpus = node_os_1.default.cpus();
|
|
1974
|
+
let idle = 0;
|
|
1975
|
+
let total = 0;
|
|
1976
|
+
for (const cpu of cpus) {
|
|
1977
|
+
const t = cpu.times;
|
|
1978
|
+
idle += t.idle;
|
|
1979
|
+
total += t.user + t.nice + t.sys + t.irq + t.idle;
|
|
1980
|
+
}
|
|
1981
|
+
return { idle, total };
|
|
1982
|
+
};
|
|
1983
|
+
const a = snapshot();
|
|
1984
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
1985
|
+
const b = snapshot();
|
|
1986
|
+
const idle = b.idle - a.idle;
|
|
1987
|
+
const total = b.total - a.total;
|
|
1988
|
+
if (total <= 0)
|
|
1989
|
+
return 0;
|
|
1990
|
+
const used = 1 - idle / total;
|
|
1991
|
+
return Math.max(0, Math.min(100, used * 100));
|
|
1992
|
+
}
|
|
1993
|
+
async renderStatusCard(session) {
|
|
1994
|
+
const cpuPercent = await this.sampleCpuUsage();
|
|
1995
|
+
const onebotStatus = await this.fetchOneBotStatus(session);
|
|
1996
|
+
const onlineClientsCount = await this.fetchOnlineClientsCount(session);
|
|
1997
|
+
const [llbotUsage, qqUsage] = await Promise.all([
|
|
1998
|
+
this.fetchLlbotClientUsage(),
|
|
1999
|
+
this.fetchQqClientUsage(),
|
|
2000
|
+
]);
|
|
2001
|
+
const totalMem = node_os_1.default.totalmem();
|
|
2002
|
+
const freeMem = node_os_1.default.freemem();
|
|
2003
|
+
const usedMem = totalMem - freeMem;
|
|
2004
|
+
const usedPercent = totalMem > 0 ? (usedMem / totalMem) * 100 : 0;
|
|
2005
|
+
const processMem = process.memoryUsage();
|
|
2006
|
+
const uptimeSec = Math.floor(process.uptime());
|
|
2007
|
+
const h = Math.floor(uptimeSec / 3600);
|
|
2008
|
+
const m = Math.floor((uptimeSec % 3600) / 60);
|
|
2009
|
+
const s = uptimeSec % 60;
|
|
2010
|
+
const uptime = `${h}h ${m}m ${s}s`;
|
|
2011
|
+
const fallback = this.styleText(`状态:CPU ${cpuPercent.toFixed(1)}% | 内存 ${usedPercent.toFixed(1)}% | 进程RSS ${this.formatBytes(processMem.rss)}`
|
|
2012
|
+
+ (onebotStatus ? ` | OneBot ${onebotStatus.online ? '在线' : '离线'}` : '')
|
|
2013
|
+
+ (llbotUsage ? ` | LLBot ${llbotUsage.cpuText}/${llbotUsage.memText}` : ''));
|
|
2014
|
+
const puppeteer = this.getPuppeteer();
|
|
2015
|
+
if (!puppeteer)
|
|
2016
|
+
return fallback;
|
|
2017
|
+
const bar = (v) => Math.max(0, Math.min(100, v)).toFixed(1);
|
|
2018
|
+
const onlineText = onebotStatus?.online ? '在线' : '离线';
|
|
2019
|
+
const healthText = onebotStatus?.good ? '正常' : '异常';
|
|
2020
|
+
const onebotSection = onebotStatus
|
|
2021
|
+
? `
|
|
2022
|
+
<div class="item">
|
|
2023
|
+
<div class="line"><span>LLOneBot 状态</span><span class="${onebotStatus.online ? 'ok' : 'bad'}">${onlineText}</span></div>
|
|
2024
|
+
<div class="meta">连接状态:<span class="${onebotStatus.online ? 'ok' : 'bad'}">${onlineText}</span></div>
|
|
2025
|
+
<div class="meta">运行状态:<span class="${onebotStatus.good ? 'ok' : 'bad'}">${healthText}</span></div>
|
|
2026
|
+
<div class="meta">收到消息数 / 发送消息数:${onebotStatus.stat?.message_received ?? '-'} / ${onebotStatus.stat?.message_sent ?? '-'}</div>
|
|
2027
|
+
<div class="meta">在线客户端数:${onlineClientsCount ?? '-'}</div>
|
|
2028
|
+
<div class="meta">LLBot 客户端占用:${llbotUsage ? `${llbotUsage.cpuText} / ${llbotUsage.memText} (${llbotUsage.source}:${llbotUsage.name})` : '未获取到'}</div>
|
|
2029
|
+
<div class="meta">QQ 客户端占用:${qqUsage ? `${qqUsage.cpuText} / ${qqUsage.memText} (${qqUsage.name})` : '未获取到'}</div>
|
|
2030
|
+
</div>`
|
|
2031
|
+
: '';
|
|
2032
|
+
const html = `
|
|
2033
|
+
<!doctype html>
|
|
2034
|
+
<html>
|
|
2035
|
+
<head>
|
|
2036
|
+
<meta charset="utf-8" />
|
|
2037
|
+
<style>
|
|
2038
|
+
html { background: transparent; }
|
|
2039
|
+
body { margin: 0; display: inline-block; font-family: "LXGW WenKai", "Noto Sans SC", sans-serif; color: #2b1d2a; background: #f8f3f8; }
|
|
2040
|
+
.bg { position: relative; padding: 22px; background: linear-gradient(165deg, #fff4fb 0%, #ffe9f4 42%, #f8f3f8 100%); }
|
|
2041
|
+
.bg::before { content: ""; position: absolute; inset: 0; opacity: .08; pointer-events: none;
|
|
2042
|
+
background-image: url("data:image/svg+xml;utf8,%3Csvg width='120' height='120' viewBox='0 0 120 120' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23bca6d9'%3E%3Ccircle cx='24' cy='24' r='6'/%3E%3Ccircle cx='44' cy='18' r='6'/%3E%3Ccircle cx='64' cy='24' r='6'/%3E%3Ccircle cx='32' cy='48' r='12'/%3E%3C/g%3E%3C/svg%3E");
|
|
2043
|
+
background-size: 140px 140px; }
|
|
2044
|
+
.card { position: relative; width: 620px; border-radius: 18px; padding: 16px 18px 14px;
|
|
2045
|
+
background: rgba(255,255,255,.74); border: 1px solid rgba(233,217,234,.92); box-shadow: 0 12px 28px rgba(47,20,47,.12); }
|
|
2046
|
+
.bar { display: flex; align-items: center; gap: 12px; padding: 2px 2px 10px; font-size: 12px; letter-spacing: 1.5px; text-transform: uppercase; }
|
|
2047
|
+
.dots { width: 10px; height: 10px; border-radius: 50%; background: #f0b1c9; box-shadow: 16px 0 0 #f6d48f, 32px 0 0 #b9efdf; }
|
|
2048
|
+
.bar-title { margin-left: 28px; font-weight: 700; opacity: .88; }
|
|
2049
|
+
.title { font-size: 28px; font-weight: 800; margin: 0 0 4px; }
|
|
2050
|
+
.sub { font-size: 15px; opacity: .76; margin: 0 0 12px; }
|
|
2051
|
+
.item { margin-bottom: 10px; background: rgba(255,255,255,.78); border: 1px solid rgba(233,217,234,.9); border-radius: 14px; padding: 10px 12px; }
|
|
2052
|
+
.line { display: flex; justify-content: space-between; font-size: 16px; font-weight: 700; margin-bottom: 6px; }
|
|
2053
|
+
.meter { height: 10px; border-radius: 999px; background: rgba(221, 195, 221, .45); overflow: hidden; }
|
|
2054
|
+
.fill { height: 100%; background: linear-gradient(90deg, #f0b1c9, #b9efdf); }
|
|
2055
|
+
.meta { font-size: 14px; opacity: .78; line-height: 1.45; margin-top: 4px; }
|
|
2056
|
+
.ok { color: #1f8f49; font-weight: 700; }
|
|
2057
|
+
.bad { color: #cf3f48; font-weight: 700; }
|
|
2058
|
+
</style>
|
|
2059
|
+
</head>
|
|
2060
|
+
<body>
|
|
2061
|
+
<div class="bg">
|
|
2062
|
+
<div class="card">
|
|
2063
|
+
<div class="bar"><span class="dots"></span><span class="bar-title">MEOW STATUS</span></div>
|
|
2064
|
+
<div class="title">系统状态</div>
|
|
2065
|
+
<div class="sub">喵~ 实时资源占用</div>
|
|
2066
|
+
|
|
2067
|
+
<div class="item">
|
|
2068
|
+
<div class="line"><span>CPU 占用</span><span>${cpuPercent.toFixed(1)}%</span></div>
|
|
2069
|
+
<div class="meter"><div class="fill" style="width:${bar(cpuPercent)}%"></div></div>
|
|
2070
|
+
</div>
|
|
2071
|
+
|
|
2072
|
+
<div class="item">
|
|
2073
|
+
<div class="line"><span>内存占用</span><span>${usedPercent.toFixed(1)}%</span></div>
|
|
2074
|
+
<div class="meter"><div class="fill" style="width:${bar(usedPercent)}%"></div></div>
|
|
2075
|
+
<div class="meta">系统内存:${this.formatBytes(usedMem)} / ${this.formatBytes(totalMem)}</div>
|
|
2076
|
+
<div class="meta">进程 RSS:${this.formatBytes(processMem.rss)},Heap:${this.formatBytes(processMem.heapUsed)} / ${this.formatBytes(processMem.heapTotal)}</div>
|
|
2077
|
+
<div class="meta">进程运行时长:${uptime}</div>
|
|
2078
|
+
</div>
|
|
2079
|
+
${onebotSection}
|
|
2080
|
+
</div>
|
|
2081
|
+
</div>
|
|
2082
|
+
</body>
|
|
2083
|
+
</html>`;
|
|
2084
|
+
try {
|
|
2085
|
+
return await puppeteer.render(html);
|
|
2086
|
+
}
|
|
2087
|
+
catch (error) {
|
|
2088
|
+
this._logger.warn(`[status] 图片渲染失败: ${String(error)}`);
|
|
2089
|
+
return fallback;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
renderCategoryHtml(title, subTitle, entries, options, narrow = true) {
|
|
2093
|
+
const showUsageLabel = options?.showUsageLabel ?? false;
|
|
2094
|
+
const lines = entries.map((item) => {
|
|
2095
|
+
const desc = item.desc
|
|
2096
|
+
? `<span class="desc">${showUsageLabel ? '用途:' : ''}${this.escapeHtml(item.desc)}</span>`
|
|
2097
|
+
: '';
|
|
2098
|
+
return `<li><span class="name">${this.escapeHtml(item.name)}</span>${desc}</li>`;
|
|
2099
|
+
}).join('');
|
|
2100
|
+
const width = narrow ? 620 : 680;
|
|
2101
|
+
return `
|
|
2102
|
+
<!doctype html>
|
|
2103
|
+
<html>
|
|
2104
|
+
<head>
|
|
2105
|
+
<meta charset="utf-8" />
|
|
2106
|
+
<style>
|
|
2107
|
+
html { background: transparent; }
|
|
2108
|
+
body { margin: 0; display: inline-block; font-family: "LXGW WenKai", "Noto Sans SC", sans-serif; color: #2b1d2a; background: #f8f3f8; }
|
|
2109
|
+
.bg { position: relative; padding: 22px; background: linear-gradient(165deg, #fff4fb 0%, #ffe9f4 42%, #f8f3f8 100%); }
|
|
2110
|
+
.bg::before { content: ""; position: absolute; inset: 0; opacity: 0.08; pointer-events: none;
|
|
2111
|
+
background-image: url("data:image/svg+xml;utf8,%3Csvg width='120' height='120' viewBox='0 0 120 120' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23bca6d9'%3E%3Ccircle cx='24' cy='24' r='6'/%3E%3Ccircle cx='44' cy='18' r='6'/%3E%3Ccircle cx='64' cy='24' r='6'/%3E%3Ccircle cx='32' cy='48' r='12'/%3E%3C/g%3E%3C/svg%3E");
|
|
2112
|
+
background-size: 140px 140px; }
|
|
2113
|
+
.card { position: relative; width: ${width}px; border-radius: 18px; padding: 16px 18px 14px;
|
|
2114
|
+
background: rgba(255, 255, 255, 0.74); border: 1px solid rgba(233, 217, 234, 0.92); box-shadow: 0 12px 28px rgba(47, 20, 47, 0.12); }
|
|
2115
|
+
.bar { display: flex; align-items: center; gap: 12px; padding: 2px 2px 10px; font-size: 12px; letter-spacing: 1.5px; text-transform: uppercase; }
|
|
2116
|
+
.dots { width: 10px; height: 10px; border-radius: 50%; background: #f0b1c9; box-shadow: 16px 0 0 #f6d48f, 32px 0 0 #b9efdf; }
|
|
2117
|
+
.bar-title { margin-left: 28px; font-weight: 700; opacity: .88; }
|
|
2118
|
+
.title { font-size: 28px; font-weight: 800; margin: 0 0 4px; }
|
|
2119
|
+
.sub { font-size: 15px; opacity: .76; margin: 0 0 12px; }
|
|
2120
|
+
ul { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: 1fr; gap: 8px; }
|
|
2121
|
+
li { border-radius: 14px; padding: 10px 12px; background: rgba(255,255,255,.78); border: 1px solid rgba(233, 217, 234, 0.9); }
|
|
2122
|
+
.name { font-size: 18px; font-weight: 700; display: block; }
|
|
2123
|
+
.desc { font-size: 13px; opacity: .75; display: block; margin-top: 2px; line-height: 1.35; }
|
|
2124
|
+
.foot { margin-top: 10px; font-size: 13px; opacity: .72; }
|
|
2125
|
+
</style>
|
|
2126
|
+
</head>
|
|
2127
|
+
<body>
|
|
2128
|
+
<div class="bg">
|
|
2129
|
+
<div class="card">
|
|
2130
|
+
<div class="bar"><span class="dots"></span><span class="bar-title">MEOW MENU</span></div>
|
|
2131
|
+
<div class="title">${this.escapeHtml(title)}</div>
|
|
2132
|
+
<div class="sub">${this.escapeHtml(subTitle)}</div>
|
|
2133
|
+
<ul>${lines}</ul>
|
|
2134
|
+
<div class="foot">喵~ 发送 help <父指令菜单> 查看子指令</div>
|
|
2135
|
+
</div>
|
|
2136
|
+
</div>
|
|
2137
|
+
</body>
|
|
2138
|
+
</html>`;
|
|
2139
|
+
}
|
|
2140
|
+
async renderMenu(keyword, session) {
|
|
2141
|
+
const categories = this.collectCategories(session);
|
|
2142
|
+
if (!categories.length)
|
|
2143
|
+
return this.styleText('没有匹配到可用指令。');
|
|
2144
|
+
const puppeteer = this.getPuppeteer();
|
|
2145
|
+
if (!puppeteer) {
|
|
2146
|
+
return this.styleText('未启用 puppeteer,无法生成图片菜单。');
|
|
2147
|
+
}
|
|
2148
|
+
const hit = this.resolveMenuCategory(keyword, session);
|
|
2149
|
+
const html = hit
|
|
2150
|
+
? this.renderCategoryHtml(`Meow 菜单 · ${hit.title}`, `分类 ${hit.key},共 ${hit.children.length} 条子指令`, hit.children, { showUsageLabel: true })
|
|
2151
|
+
: this.renderCategoryHtml('Meow 菜单', `共 ${categories.length} 个大分类`, categories.map((cat) => ({
|
|
2152
|
+
name: `${cat.title}菜单`,
|
|
2153
|
+
desc: cat.desc
|
|
2154
|
+
? `${cat.children.length} 条子指令 · ${cat.desc}`
|
|
2155
|
+
: `${cat.children.length} 条子指令`,
|
|
2156
|
+
})), { showUsageLabel: false }, false);
|
|
2157
|
+
try {
|
|
2158
|
+
return await puppeteer.render(html);
|
|
2159
|
+
}
|
|
2160
|
+
catch (error) {
|
|
2161
|
+
this._logger.warn(`[menu] 图片渲染失败: ${String(error)}`);
|
|
2162
|
+
return this.styleText('图片菜单生成失败,请检查 puppeteer。');
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
logAuth(stage, result, session) {
|
|
2166
|
+
if (!this.config.logAuthCheck)
|
|
2167
|
+
return;
|
|
2168
|
+
const level = result.ok ? 'info' : 'warn';
|
|
2169
|
+
this._logger[level](`[auth:${stage}] ${result.message} | ${this.sessionTag(session)}`);
|
|
2170
|
+
}
|
|
2171
|
+
logCommandResult(command, result, session, extra) {
|
|
2172
|
+
if (!this.config.logCommandResult)
|
|
2173
|
+
return;
|
|
2174
|
+
const level = result.ok ? 'info' : 'warn';
|
|
2175
|
+
const plan = result.plan ? JSON.stringify(result.plan) : '-';
|
|
2176
|
+
const payload = extra ? ` extra=${JSON.stringify(extra)}` : '';
|
|
2177
|
+
this._logger[level](`[cmd:${command}] ok=${result.ok} message="${result.message}" plan=${plan}${payload} | ${this.sessionTag(session)}`);
|
|
2178
|
+
}
|
|
2179
|
+
async authorizeCommand(command, session) {
|
|
2180
|
+
const result = await this.authorize(session);
|
|
2181
|
+
this.logAuth(command, result, session);
|
|
2182
|
+
return result;
|
|
2183
|
+
}
|
|
2184
|
+
async authorize(session) {
|
|
2185
|
+
if (!session) {
|
|
2186
|
+
return { ok: false, message: '缺少会话上下文。', userMessage: '权限不足或上下文不符合要求。' };
|
|
2187
|
+
}
|
|
2188
|
+
const platform = session.platform;
|
|
2189
|
+
if (!this.isPlatformAllowed(platform)) {
|
|
2190
|
+
return {
|
|
2191
|
+
ok: false,
|
|
2192
|
+
message: `当前平台 ${platform ?? 'unknown'} 未启用群管理。`,
|
|
2193
|
+
userMessage: '当前平台未启用群管理。',
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
const userId = session.userId;
|
|
2197
|
+
if (!userId) {
|
|
2198
|
+
return { ok: false, message: '无法识别当前账号。', userMessage: '权限不足或上下文不符合要求。' };
|
|
2199
|
+
}
|
|
2200
|
+
if (this.config.allowedUserIds.includes(userId)) {
|
|
2201
|
+
return { ok: true, message: '账号白名单通过。' };
|
|
2202
|
+
}
|
|
2203
|
+
if (!session.guildId) {
|
|
2204
|
+
return {
|
|
2205
|
+
ok: false,
|
|
2206
|
+
message: '当前账号不在白名单,且仅支持在群聊中按管理员权限使用。',
|
|
2207
|
+
userMessage: '仅支持群聊中按管理员权限使用。',
|
|
2208
|
+
};
|
|
2209
|
+
}
|
|
2210
|
+
const role = await this.resolveMemberRole(session);
|
|
2211
|
+
if (!role) {
|
|
2212
|
+
return { ok: false, message: '无法获取群成员角色,权限校验失败。', userMessage: '权限校验失败。' };
|
|
2213
|
+
}
|
|
2214
|
+
if (role === 'owner' && this.config.allowGroupOwner) {
|
|
2215
|
+
return { ok: true, message: '群主权限通过。' };
|
|
2216
|
+
}
|
|
2217
|
+
if (role === 'admin' && this.config.allowGroupAdmin) {
|
|
2218
|
+
return { ok: true, message: '群管理员权限通过。' };
|
|
2219
|
+
}
|
|
2220
|
+
return {
|
|
2221
|
+
ok: false,
|
|
2222
|
+
message: '权限不足:仅账号白名单、群主或群管理员可执行。',
|
|
2223
|
+
userMessage: '权限不足:仅白名单、群主或群管理员可执行。',
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2226
|
+
async canUseAdminCommand(session) {
|
|
2227
|
+
if (!this.config.requireBotOwnerForAdmin) {
|
|
2228
|
+
const allowed = { ok: true, message: '已关闭 bot 群主限制,admin 子命令可用。' };
|
|
2229
|
+
this.logAuth('admin-visibility', allowed, session);
|
|
2230
|
+
return allowed;
|
|
2231
|
+
}
|
|
2232
|
+
if (!session) {
|
|
2233
|
+
const denied = { ok: false, message: '缺少会话上下文,admin 子命令不可用。', userMessage: '当前场景不可用该子命令。' };
|
|
2234
|
+
this.logAuth('admin-visibility', denied, session);
|
|
2235
|
+
return denied;
|
|
2236
|
+
}
|
|
2237
|
+
if (!session.guildId) {
|
|
2238
|
+
const denied = { ok: false, message: '当前不是群聊上下文,admin 子命令不可用。', userMessage: '请在群聊中使用该子命令。' };
|
|
2239
|
+
this.logAuth('admin-visibility', denied, session);
|
|
2240
|
+
return denied;
|
|
2241
|
+
}
|
|
2242
|
+
const botId = Number(session.bot?.selfId);
|
|
2243
|
+
if (!Number.isFinite(botId)) {
|
|
2244
|
+
const denied = { ok: false, message: `bot selfId 无法解析为数字: ${session.bot?.selfId ?? 'unknown'}`, userMessage: '当前场景不可用该子命令。' };
|
|
2245
|
+
this.logAuth('admin-visibility', denied, session);
|
|
2246
|
+
return denied;
|
|
2247
|
+
}
|
|
2248
|
+
const botRole = await this.getTargetRole(session, botId);
|
|
2249
|
+
if (botRole === 'owner') {
|
|
2250
|
+
const allowed = { ok: true, message: `bot(${botId}) 在当前群是 owner,admin 子命令可用。` };
|
|
2251
|
+
this.logAuth('admin-visibility', allowed, session);
|
|
2252
|
+
return allowed;
|
|
2253
|
+
}
|
|
2254
|
+
const denied = {
|
|
2255
|
+
ok: false,
|
|
2256
|
+
message: `bot(${botId}) 在当前群角色=${botRole ?? 'unknown'},未达到 owner,admin 子命令不可用。`,
|
|
2257
|
+
userMessage: '当前群中 bot 不是群主,admin 子命令不可用。',
|
|
2258
|
+
};
|
|
2259
|
+
this.logAuth('admin-visibility', denied, session);
|
|
2260
|
+
return denied;
|
|
2261
|
+
}
|
|
2262
|
+
async isBotOwnerInGroup(session) {
|
|
2263
|
+
if (!session?.bot?.selfId)
|
|
2264
|
+
return false;
|
|
2265
|
+
const botId = Number(session.bot.selfId);
|
|
2266
|
+
if (!Number.isFinite(botId))
|
|
2267
|
+
return false;
|
|
2268
|
+
const role = await this.getTargetRole(session, botId);
|
|
2269
|
+
return role === 'owner';
|
|
2270
|
+
}
|
|
2271
|
+
async resolveMemberRole(session) {
|
|
2272
|
+
const authorRoles = session.author?.roles ?? [];
|
|
2273
|
+
for (const role of authorRoles) {
|
|
2274
|
+
const normalized = this.normalizeRole(role);
|
|
2275
|
+
if (normalized)
|
|
2276
|
+
return normalized;
|
|
2277
|
+
}
|
|
2278
|
+
const eventRole = session.event?.member?.role;
|
|
2279
|
+
if (eventRole === 'owner' || eventRole === 'admin' || eventRole === 'member') {
|
|
2280
|
+
return eventRole;
|
|
2281
|
+
}
|
|
2282
|
+
if (session.platform !== 'onebot')
|
|
2283
|
+
return null;
|
|
2284
|
+
const onebot = session.onebot;
|
|
2285
|
+
if (!onebot?.getGroupMemberInfo || !session.guildId || !session.userId)
|
|
2286
|
+
return null;
|
|
2287
|
+
const groupId = Number(session.guildId);
|
|
2288
|
+
const userId = Number(session.userId);
|
|
2289
|
+
if (!Number.isFinite(groupId) || !Number.isFinite(userId))
|
|
2290
|
+
return null;
|
|
2291
|
+
try {
|
|
2292
|
+
const info = await onebot.getGroupMemberInfo(groupId, userId, true);
|
|
2293
|
+
if (info.role === 'owner' || info.role === 'admin' || info.role === 'member') {
|
|
2294
|
+
return info.role;
|
|
2295
|
+
}
|
|
2296
|
+
return null;
|
|
2297
|
+
}
|
|
2298
|
+
catch {
|
|
2299
|
+
return null;
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
normalizeRole(input) {
|
|
2303
|
+
if (input === 'owner' || input === 'admin' || input === 'member') {
|
|
2304
|
+
return input;
|
|
2305
|
+
}
|
|
2306
|
+
if (!input || typeof input !== 'object')
|
|
2307
|
+
return null;
|
|
2308
|
+
const role = input;
|
|
2309
|
+
if (role.id === 'owner' || role.id === 'admin' || role.id === 'member')
|
|
2310
|
+
return role.id;
|
|
2311
|
+
if (role.name === 'owner' || role.name === 'admin' || role.name === 'member')
|
|
2312
|
+
return role.name;
|
|
2313
|
+
if (role.type === 'owner' || role.type === 'admin' || role.type === 'member')
|
|
2314
|
+
return role.type;
|
|
2315
|
+
return null;
|
|
2316
|
+
}
|
|
2317
|
+
parseTargetId(input) {
|
|
2318
|
+
const matched = input.match(/\d+/)?.[0];
|
|
2319
|
+
return matched ?? '';
|
|
2320
|
+
}
|
|
2321
|
+
randomMinutes(min = 1, max = 60) {
|
|
2322
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
2323
|
+
}
|
|
2324
|
+
extractAtIds(session) {
|
|
2325
|
+
const ids = [];
|
|
2326
|
+
const seen = new Set();
|
|
2327
|
+
const selfId = session?.bot?.selfId != null ? String(session.bot.selfId) : '';
|
|
2328
|
+
const add = (id) => {
|
|
2329
|
+
if (id == null)
|
|
2330
|
+
return;
|
|
2331
|
+
const text = String(id).trim();
|
|
2332
|
+
if (!/^\d+$/.test(text) || seen.has(text) || (selfId && text === selfId))
|
|
2333
|
+
return;
|
|
2334
|
+
seen.add(text);
|
|
2335
|
+
ids.push(text);
|
|
2336
|
+
};
|
|
2337
|
+
for (const el of session?.elements ?? []) {
|
|
2338
|
+
if (el?.type === 'at' || el?.type === 'mention') {
|
|
2339
|
+
add(el.id ?? el.pid ?? el.qq);
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
const content = session?.content ?? '';
|
|
2343
|
+
const cqRegex = /\[CQ:at,qq=(\d+)(?:,[^\]]*)?\]/gi;
|
|
2344
|
+
let match;
|
|
2345
|
+
while ((match = cqRegex.exec(content)) !== null) {
|
|
2346
|
+
add(match[1]);
|
|
2347
|
+
}
|
|
2348
|
+
return ids;
|
|
2349
|
+
}
|
|
2350
|
+
parseDurationToken(token) {
|
|
2351
|
+
if (typeof token !== 'string')
|
|
2352
|
+
return null;
|
|
2353
|
+
const matched = String(token).trim().match(/^(\d+(?:\.\d+)?)\s*(分钟|分|mins?|min|m|小时|时|h|天|d|周|w)?$/i);
|
|
2354
|
+
if (!matched)
|
|
2355
|
+
return null;
|
|
2356
|
+
const value = parseFloat(matched[1]);
|
|
2357
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
2358
|
+
return null;
|
|
2359
|
+
const unit = (matched[2] || '').toLowerCase();
|
|
2360
|
+
let factor = 1;
|
|
2361
|
+
if (unit === '小时' || unit === '时' || unit === 'h') {
|
|
2362
|
+
factor = 60;
|
|
2363
|
+
}
|
|
2364
|
+
else if (unit === '天' || unit === 'd') {
|
|
2365
|
+
factor = 24 * 60;
|
|
2366
|
+
}
|
|
2367
|
+
else if (unit === '周' || unit === 'w') {
|
|
2368
|
+
factor = 7 * 24 * 60;
|
|
2369
|
+
}
|
|
2370
|
+
return { raw: String(token).trim(), value, hasUnit: !!unit, minutes: Math.ceil(value * factor) };
|
|
2371
|
+
}
|
|
2372
|
+
extractMuteTokens(session) {
|
|
2373
|
+
const content = session?.content ?? '';
|
|
2374
|
+
const cleaned = content.replace(/\[CQ:[^\]]+\]/g, ' ');
|
|
2375
|
+
const rawTokens = cleaned.split(/\s+/).filter(Boolean);
|
|
2376
|
+
const tokens = [];
|
|
2377
|
+
for (const token of rawTokens) {
|
|
2378
|
+
const parsed = this.parseDurationToken(token);
|
|
2379
|
+
if (parsed)
|
|
2380
|
+
tokens.push(parsed);
|
|
2381
|
+
}
|
|
2382
|
+
return { atIds: this.extractAtIds(session), tokens };
|
|
2383
|
+
}
|
|
2384
|
+
async looksLikeMemberInGroup(session, targetIdRaw) {
|
|
2385
|
+
const onebot = this.getOneBotApi(session);
|
|
2386
|
+
const groupId = this.toGroupId(session);
|
|
2387
|
+
const uid = Number(targetIdRaw);
|
|
2388
|
+
if (!onebot?.getGroupMemberInfo || groupId == null || !Number.isFinite(uid))
|
|
2389
|
+
return false;
|
|
2390
|
+
try {
|
|
2391
|
+
const info = await onebot.getGroupMemberInfo(groupId, uid, true);
|
|
2392
|
+
return !!(info && (info.user_id || info.userId || info.nickname || info.card));
|
|
2393
|
+
}
|
|
2394
|
+
catch {
|
|
2395
|
+
return false;
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
async parseMuteArguments(session) {
|
|
2399
|
+
const usage = '禁言用法:群管 mute <@用户|QQ号> <时长>(时长示例:10、10分钟、1小时、1天)。';
|
|
2400
|
+
const { atIds, tokens } = this.extractMuteTokens(session);
|
|
2401
|
+
const withUnit = tokens.filter((t) => t.hasUnit);
|
|
2402
|
+
const bare = tokens.filter((t) => !t.hasUnit);
|
|
2403
|
+
let targetId = '';
|
|
2404
|
+
let duration = null;
|
|
2405
|
+
const fail = (message, id = '') => ({ ok: false, targetId: id, minutes: 0, message });
|
|
2406
|
+
if (atIds.length > 0) {
|
|
2407
|
+
targetId = atIds[0];
|
|
2408
|
+
if (withUnit.length === 1) {
|
|
2409
|
+
duration = withUnit[0];
|
|
2410
|
+
}
|
|
2411
|
+
else if (withUnit.length > 1) {
|
|
2412
|
+
return fail('检测到多个带单位的时长,无法确认要禁言多久。', targetId);
|
|
2413
|
+
}
|
|
2414
|
+
else if (bare.length === 0) {
|
|
2415
|
+
return fail(`已识别目标 ${targetId},但缺少禁言时长。${usage}`, targetId);
|
|
2416
|
+
}
|
|
2417
|
+
else {
|
|
2418
|
+
// 无单位数字:取最后一个看起来像时长的数字(≤30天分钟数),其余忽略
|
|
2419
|
+
const candidates = bare.filter((t) => t.minutes <= 43200);
|
|
2420
|
+
duration = candidates.length > 0 ? candidates[candidates.length - 1] : null;
|
|
2421
|
+
if (!duration)
|
|
2422
|
+
return fail('未识别到有效禁言时长(支持 1~43200 分钟)。', targetId);
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
else if (tokens.length === 0) {
|
|
2426
|
+
return fail(usage);
|
|
2427
|
+
}
|
|
2428
|
+
else if (tokens.length === 1 && !tokens[0].hasUnit) {
|
|
2429
|
+
// 单个数字且无 @:默认是目标 QQ 号,需要补充时长(避免把时长当 QQ 用)
|
|
2430
|
+
return fail(`已识别目标 QQ ${tokens[0].raw},请补充禁言时长。${usage}`, tokens[0].raw);
|
|
2431
|
+
}
|
|
2432
|
+
else {
|
|
2433
|
+
// 无 @:目标与时长都要从数字里区分
|
|
2434
|
+
if (withUnit.length === 1) {
|
|
2435
|
+
duration = withUnit[0];
|
|
2436
|
+
}
|
|
2437
|
+
else if (withUnit.length > 1) {
|
|
2438
|
+
return fail('检测到多个带单位的时长,无法确认要禁言多久。');
|
|
2439
|
+
}
|
|
2440
|
+
else {
|
|
2441
|
+
const candidates = bare.filter((t) => t.minutes <= 43200);
|
|
2442
|
+
duration = candidates.length > 0 ? candidates[candidates.length - 1] : null;
|
|
2443
|
+
if (!duration)
|
|
2444
|
+
return fail('未识别到有效禁言时长(支持 1~43200 分钟)。');
|
|
2445
|
+
}
|
|
2446
|
+
const others = tokens.filter((t) => t !== duration);
|
|
2447
|
+
if (others.length === 0) {
|
|
2448
|
+
return fail(`已识别禁言时长 ${duration.raw} 分钟,但缺少目标 QQ 号。${usage}`);
|
|
2449
|
+
}
|
|
2450
|
+
targetId = others[0].raw;
|
|
2451
|
+
// 两个数字且“时长在后”时,若第一个不像群成员而第二个像,则说明用户把时长写在了前面,交换一次
|
|
2452
|
+
if (others.length === 1 && bare.length === 2 && duration.raw !== targetId) {
|
|
2453
|
+
const firstIsMember = await this.looksLikeMemberInGroup(session, targetId);
|
|
2454
|
+
const secondIsMember = await this.looksLikeMemberInGroup(session, duration.raw);
|
|
2455
|
+
if (!firstIsMember && secondIsMember) {
|
|
2456
|
+
targetId = duration.raw;
|
|
2457
|
+
duration = others[0];
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
if (!targetId) {
|
|
2462
|
+
return fail(usage);
|
|
2463
|
+
}
|
|
2464
|
+
if (!duration) {
|
|
2465
|
+
return fail(`已识别目标 ${targetId},请补充禁言时长。${usage}`, targetId);
|
|
2466
|
+
}
|
|
2467
|
+
const minutes = Math.max(1, Math.floor(duration.minutes));
|
|
2468
|
+
if (minutes > 43200) {
|
|
2469
|
+
return fail('禁言时长不能超过 30 天(43200 分钟)。', targetId);
|
|
2470
|
+
}
|
|
2471
|
+
return { ok: true, targetId, minutes, message: '' };
|
|
2472
|
+
}
|
|
2473
|
+
isPrivilegedUser(session, role) {
|
|
2474
|
+
if (!session?.userId)
|
|
2475
|
+
return false;
|
|
2476
|
+
if (this.config.allowedUserIds.includes(session.userId))
|
|
2477
|
+
return true;
|
|
2478
|
+
return role === 'owner' || role === 'admin';
|
|
2479
|
+
}
|
|
2480
|
+
recordUnauthorizedMuteAttempt(session) {
|
|
2481
|
+
const key = `${session.guildId}:${session.userId}`;
|
|
2482
|
+
const now = Date.now();
|
|
2483
|
+
const windowMinutes = Math.max(1, this.config.unauthorizedMuteWindowMinutes || 10);
|
|
2484
|
+
const windowMs = windowMinutes * 60 * 1000;
|
|
2485
|
+
const threshold = Math.max(1, this.config.unauthorizedMuteAttemptThreshold || 2);
|
|
2486
|
+
const previous = this.unauthorizedMuteAttempts.get(key);
|
|
2487
|
+
if (!previous || now - previous.updatedAt > windowMs) {
|
|
2488
|
+
this.unauthorizedMuteAttempts.set(key, { count: 1, updatedAt: now });
|
|
2489
|
+
return false;
|
|
2490
|
+
}
|
|
2491
|
+
const next = { count: previous.count + 1, updatedAt: now };
|
|
2492
|
+
this.unauthorizedMuteAttempts.set(key, next);
|
|
2493
|
+
if (next.count < threshold)
|
|
2494
|
+
return false;
|
|
2495
|
+
this.unauthorizedMuteAttempts.delete(key);
|
|
2496
|
+
return true;
|
|
2497
|
+
}
|
|
2498
|
+
async isRegularMember(session) {
|
|
2499
|
+
if (!session?.userId || !session.guildId || session.platform !== 'onebot')
|
|
2500
|
+
return false;
|
|
2501
|
+
if (this.config.allowedUserIds.includes(session.userId))
|
|
2502
|
+
return false;
|
|
2503
|
+
const role = await this.resolveMemberRole(session);
|
|
2504
|
+
return role === 'member';
|
|
2505
|
+
}
|
|
2506
|
+
async shouldSelfGag(session, target) {
|
|
2507
|
+
if (!this.config.enableSelfGag)
|
|
2508
|
+
return false;
|
|
2509
|
+
if (!session?.userId || !session.guildId || session.platform !== 'onebot')
|
|
2510
|
+
return false;
|
|
2511
|
+
if (this.config.allowedUserIds.includes(session.userId))
|
|
2512
|
+
return false;
|
|
2513
|
+
const targetId = target ? this.parseTargetId(target) : session.userId;
|
|
2514
|
+
if (!targetId || targetId !== session.userId)
|
|
2515
|
+
return false;
|
|
2516
|
+
return this.isRegularMember(session);
|
|
2517
|
+
}
|
|
2518
|
+
async selfGag(session, reason) {
|
|
2519
|
+
const groupId = this.toGroupId(session);
|
|
2520
|
+
const userIdRaw = session.userId ?? '';
|
|
2521
|
+
const userId = Number(userIdRaw);
|
|
2522
|
+
if (!userIdRaw || !Number.isFinite(userId)) {
|
|
2523
|
+
return { ok: false, message: '无法识别当前账号。' };
|
|
2524
|
+
}
|
|
2525
|
+
if (groupId == null) {
|
|
2526
|
+
return { ok: false, message: '请在群聊中执行该命令。' };
|
|
2527
|
+
}
|
|
2528
|
+
const onebot = this.getOneBotApi(session);
|
|
2529
|
+
if (!onebot?.setGroupBan) {
|
|
2530
|
+
return { ok: false, message: '当前会话不支持 OneBot 禁言接口。' };
|
|
2531
|
+
}
|
|
2532
|
+
const minutes = this.randomMinutes(1, 60);
|
|
2533
|
+
const duration = minutes * 60;
|
|
2534
|
+
const plan = {
|
|
2535
|
+
action: 'gag',
|
|
2536
|
+
groupId: String(groupId),
|
|
2537
|
+
targetId: userIdRaw,
|
|
2538
|
+
reason: reason ? `${reason}; minutes=${minutes}` : `minutes=${minutes}`,
|
|
2539
|
+
};
|
|
2540
|
+
try {
|
|
2541
|
+
await onebot.setGroupBan(groupId, userId, duration);
|
|
2542
|
+
return { ok: true, message: `已触发口球:${userIdRaw},随机禁言 ${minutes} 分钟`, plan };
|
|
2543
|
+
}
|
|
2544
|
+
catch (error) {
|
|
2545
|
+
return { ok: false, message: `口球失败: ${String(error)}`, plan };
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
async punishUnauthorizedMuteAttempt(session, target) {
|
|
2549
|
+
if (!this.config.enableUnauthorizedMutePunish)
|
|
2550
|
+
return null;
|
|
2551
|
+
if (!session?.userId || !session.guildId || session.platform !== 'onebot')
|
|
2552
|
+
return null;
|
|
2553
|
+
const targetId = target ? this.parseTargetId(target) : '';
|
|
2554
|
+
if (!targetId || targetId === session.userId)
|
|
2555
|
+
return null;
|
|
2556
|
+
const role = await this.resolveMemberRole(session);
|
|
2557
|
+
if (this.isPrivilegedUser(session, role))
|
|
2558
|
+
return null;
|
|
2559
|
+
if (!this.recordUnauthorizedMuteAttempt(session))
|
|
2560
|
+
return null;
|
|
2561
|
+
const onebot = this.getOneBotApi(session);
|
|
2562
|
+
const groupId = this.toGroupId(session);
|
|
2563
|
+
const userId = Number(session.userId);
|
|
2564
|
+
if (!onebot?.setGroupBan || groupId == null || !Number.isFinite(userId)) {
|
|
2565
|
+
return null;
|
|
2566
|
+
}
|
|
2567
|
+
const minMinutes = Math.max(1, this.config.unauthorizedMutePunishMinMinutes || 1);
|
|
2568
|
+
const maxMinutes = Math.max(minMinutes, this.config.unauthorizedMutePunishMaxMinutes || 10);
|
|
2569
|
+
const minutes = this.randomMinutes(minMinutes, maxMinutes);
|
|
2570
|
+
const duration = minutes * 60;
|
|
2571
|
+
const plan = {
|
|
2572
|
+
action: 'punish-unauthorized-mute',
|
|
2573
|
+
groupId: String(groupId),
|
|
2574
|
+
targetId: session.userId,
|
|
2575
|
+
reason: `illegal-mute-attempt; minutes=${minutes}`,
|
|
2576
|
+
};
|
|
2577
|
+
try {
|
|
2578
|
+
await onebot.setGroupBan(groupId, userId, duration);
|
|
2579
|
+
return { ok: false, message: `喵~ 乱用禁言指令,已口球 ${minutes} 分钟。`, plan };
|
|
2580
|
+
}
|
|
2581
|
+
catch (error) {
|
|
2582
|
+
return { ok: false, message: `喵~ 检测到乱用禁言指令,但惩罚执行失败:${String(error)}`, plan };
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
toGroupId(session) {
|
|
2586
|
+
const groupId = Number(session.guildId);
|
|
2587
|
+
if (!Number.isFinite(groupId))
|
|
2588
|
+
return null;
|
|
2589
|
+
return groupId;
|
|
2590
|
+
}
|
|
2591
|
+
getOneBotApi(session) {
|
|
2592
|
+
if (session.platform !== 'onebot')
|
|
2593
|
+
return null;
|
|
2594
|
+
const onebot = session.onebot;
|
|
2595
|
+
return onebot ?? null;
|
|
2596
|
+
}
|
|
2597
|
+
extractTextForFilter(session) {
|
|
2598
|
+
const raw = (session.content ?? '').replace(/\[CQ:[^\]]+\]/g, ' ');
|
|
2599
|
+
return raw.trim();
|
|
2600
|
+
}
|
|
2601
|
+
hasCardMessage(session) {
|
|
2602
|
+
const content = session.content ?? '';
|
|
2603
|
+
if (/\[CQ:(json|xml),/i.test(content))
|
|
2604
|
+
return true;
|
|
2605
|
+
const elements = session.elements ?? [];
|
|
2606
|
+
return elements.some((el) => el.type === 'json' || el.type === 'xml');
|
|
2607
|
+
}
|
|
2608
|
+
hasForwardMessage(session) {
|
|
2609
|
+
const content = session.content ?? '';
|
|
2610
|
+
if (/\[CQ:forward,/i.test(content))
|
|
2611
|
+
return true;
|
|
2612
|
+
const elements = session.elements ?? [];
|
|
2613
|
+
return elements.some((el) => el.type === 'forward');
|
|
2614
|
+
}
|
|
2615
|
+
normalizeGuildId(guildId) {
|
|
2616
|
+
if (!guildId)
|
|
2617
|
+
return '';
|
|
2618
|
+
const matched = guildId.match(/\d+/)?.[0];
|
|
2619
|
+
return matched ?? guildId;
|
|
2620
|
+
}
|
|
2621
|
+
resolveModerationPolicy(session) {
|
|
2622
|
+
const currentGuild = this.normalizeGuildId(session.guildId);
|
|
2623
|
+
const rule = this.config.groupRules.find((item) => this.normalizeGuildId(item.guildId) === currentGuild);
|
|
2624
|
+
if (!rule) {
|
|
2625
|
+
return {
|
|
2626
|
+
bannedWords: this.config.bannedWords,
|
|
2627
|
+
blockCardMessage: this.config.blockCardMessage,
|
|
2628
|
+
blockForwardMessage: this.config.blockForwardMessage,
|
|
2629
|
+
autoDeleteViolation: this.config.autoDeleteViolation,
|
|
2630
|
+
sendViolationNotice: this.config.sendViolationNotice,
|
|
2631
|
+
enableAutoMute: this.config.enableAutoMute,
|
|
2632
|
+
autoMuteThreshold: this.config.autoMuteViolationThreshold,
|
|
2633
|
+
autoMuteMinutes: this.config.autoMuteMinutes,
|
|
2634
|
+
enableAutoKick: this.config.enableAutoKick,
|
|
2635
|
+
autoKickThreshold: this.config.autoKickViolationThreshold,
|
|
2636
|
+
violationWindowMinutes: this.config.autoViolationWindowMinutes,
|
|
2637
|
+
};
|
|
2638
|
+
}
|
|
2639
|
+
return {
|
|
2640
|
+
bannedWords: rule.bannedWords,
|
|
2641
|
+
blockCardMessage: rule.blockCardMessage,
|
|
2642
|
+
blockForwardMessage: rule.blockForwardMessage,
|
|
2643
|
+
autoDeleteViolation: rule.autoDeleteViolation,
|
|
2644
|
+
sendViolationNotice: rule.sendViolationNotice,
|
|
2645
|
+
enableAutoMute: rule.autoMuteEnabled ?? this.config.enableAutoMute,
|
|
2646
|
+
autoMuteThreshold: rule.autoMuteThreshold ?? this.config.autoMuteViolationThreshold,
|
|
2647
|
+
autoMuteMinutes: rule.autoMuteMinutes ?? this.config.autoMuteMinutes,
|
|
2648
|
+
enableAutoKick: rule.autoKickEnabled ?? this.config.enableAutoKick,
|
|
2649
|
+
autoKickThreshold: rule.autoKickThreshold ?? this.config.autoKickViolationThreshold,
|
|
2650
|
+
violationWindowMinutes: rule.autoViolationWindowMinutes ?? this.config.autoViolationWindowMinutes,
|
|
2651
|
+
};
|
|
2652
|
+
}
|
|
2653
|
+
detectViolation(session, policy) {
|
|
2654
|
+
if (policy.blockCardMessage && this.hasCardMessage(session)) {
|
|
2655
|
+
return { type: 'card', detail: '检测到卡片消息(json/xml)' };
|
|
2656
|
+
}
|
|
2657
|
+
if (policy.blockForwardMessage && this.hasForwardMessage(session)) {
|
|
2658
|
+
return { type: 'forward', detail: '检测到合并转发消息(forward)' };
|
|
2659
|
+
}
|
|
2660
|
+
const text = this.extractTextForFilter(session);
|
|
2661
|
+
if (!text)
|
|
2662
|
+
return null;
|
|
2663
|
+
for (const word of policy.bannedWords) {
|
|
2664
|
+
const keyword = word?.trim();
|
|
2665
|
+
if (!keyword)
|
|
2666
|
+
continue;
|
|
2667
|
+
if (text.includes(keyword)) {
|
|
2668
|
+
return { type: 'banned-word', detail: `命中违禁词: ${keyword}` };
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
return null;
|
|
2672
|
+
}
|
|
2673
|
+
async deleteMessage(session) {
|
|
2674
|
+
const onebot = this.getOneBotApi(session);
|
|
2675
|
+
const messageId = session.messageId;
|
|
2676
|
+
if (!onebot?.deleteMsg || !messageId)
|
|
2677
|
+
return false;
|
|
2678
|
+
try {
|
|
2679
|
+
await onebot.deleteMsg(messageId);
|
|
2680
|
+
return true;
|
|
2681
|
+
}
|
|
2682
|
+
catch (error) {
|
|
2683
|
+
this._logger.warn(`[moderation] 撤回失败: ${String(error)} | ${this.sessionTag(session)}`);
|
|
2684
|
+
return false;
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
async shouldExemptViolationPunish(session) {
|
|
2688
|
+
if (!session?.userId || !session.guildId || session.platform !== 'onebot')
|
|
2689
|
+
return true;
|
|
2690
|
+
if (session.bot?.selfId && session.userId === String(session.bot.selfId))
|
|
2691
|
+
return true;
|
|
2692
|
+
if (this.config.allowedUserIds.includes(session.userId))
|
|
2693
|
+
return true;
|
|
2694
|
+
const role = await this.resolveMemberRole(session);
|
|
2695
|
+
return role === 'owner' || role === 'admin';
|
|
2696
|
+
}
|
|
2697
|
+
recordViolation(session, policy) {
|
|
2698
|
+
const key = `${session.guildId}:${session.userId}`;
|
|
2699
|
+
const windowMs = Math.max(1, policy.violationWindowMinutes || 60) * 60 * 1000;
|
|
2700
|
+
const now = Date.now();
|
|
2701
|
+
const prev = this.violationRecords.get(key);
|
|
2702
|
+
let count = 1;
|
|
2703
|
+
if (prev && now - prev.lastAt <= windowMs) {
|
|
2704
|
+
count = (prev.count || 0) + 1;
|
|
2705
|
+
}
|
|
2706
|
+
this.violationRecords.set(key, { count, lastAt: now });
|
|
2707
|
+
return count;
|
|
2708
|
+
}
|
|
2709
|
+
clearViolationRecord(session) {
|
|
2710
|
+
this.violationRecords.delete(`${session.guildId}:${session.userId}`);
|
|
2711
|
+
}
|
|
2712
|
+
async autoModerationPunish(session, policy) {
|
|
2713
|
+
if (session.platform !== 'onebot')
|
|
2714
|
+
return null;
|
|
2715
|
+
if (await this.shouldExemptViolationPunish(session))
|
|
2716
|
+
return null;
|
|
2717
|
+
const count = this.recordViolation(session, policy);
|
|
2718
|
+
const onebot = this.getOneBotApi(session);
|
|
2719
|
+
const groupId = this.toGroupId(session);
|
|
2720
|
+
const userId = Number(session.userId);
|
|
2721
|
+
if (!onebot?.setGroupBan || groupId == null || !Number.isFinite(userId)) {
|
|
2722
|
+
return { ok: false, action: 'none', count, message: '当前会话不支持自动处理接口,仅记录违规计数。' };
|
|
2723
|
+
}
|
|
2724
|
+
const kickThreshold = Math.max(1, Math.floor(policy.autoKickThreshold || 5));
|
|
2725
|
+
const muteThreshold = Math.max(1, Math.floor(policy.autoMuteThreshold || 3));
|
|
2726
|
+
if (policy.enableAutoKick && count >= kickThreshold && onebot?.setGroupKick) {
|
|
2727
|
+
try {
|
|
2728
|
+
await onebot.setGroupKick(groupId, userId, false);
|
|
2729
|
+
this.clearViolationRecord(session);
|
|
2730
|
+
return { ok: true, action: 'auto-kick', count, message: `累计违规 ${count} 次,已自动移出群聊。` };
|
|
2731
|
+
}
|
|
2732
|
+
catch (error) {
|
|
2733
|
+
return { ok: false, action: 'auto-kick', count, message: `自动移出群聊失败: ${String(error)}` };
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
if (policy.enableAutoMute && count >= muteThreshold) {
|
|
2737
|
+
const requested = Math.max(1, Math.floor(policy.autoMuteMinutes || 10));
|
|
2738
|
+
const minutes = Math.min(requested, 43200);
|
|
2739
|
+
const duration = minutes * 60;
|
|
2740
|
+
try {
|
|
2741
|
+
await onebot.setGroupBan(groupId, userId, duration);
|
|
2742
|
+
return { ok: true, action: 'auto-mute', count, minutes, message: `累计违规 ${count} 次,已自动禁言 ${minutes} 分钟。` };
|
|
2743
|
+
}
|
|
2744
|
+
catch (error) {
|
|
2745
|
+
return { ok: false, action: 'auto-mute', count, message: `自动禁言失败: ${String(error)}` };
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
return { ok: true, action: 'none', count, message: `累计违规 ${count} 次(自动禁言线 ${muteThreshold} 次 / 自动踢人线 ${kickThreshold} 次)。` };
|
|
2749
|
+
}
|
|
2750
|
+
async handleGroupModeration(session) {
|
|
2751
|
+
if (!this.isPlatformAllowed(session.platform))
|
|
2752
|
+
return;
|
|
2753
|
+
if (await this.handleMenuPairing(session))
|
|
2754
|
+
return;
|
|
2755
|
+
if (await this.handleMemoryCommand(session))
|
|
2756
|
+
return;
|
|
2757
|
+
if (!session.guildId)
|
|
2758
|
+
return;
|
|
2759
|
+
if (session.userId && session.bot?.selfId && session.userId === session.bot.selfId)
|
|
2760
|
+
return;
|
|
2761
|
+
if (await this.handleJoinRequestReviewMessage(session))
|
|
2762
|
+
return;
|
|
2763
|
+
if (session.platform === 'onebot') {
|
|
2764
|
+
const policy = this.resolveModerationPolicy(session);
|
|
2765
|
+
const violation = this.detectViolation(session, policy);
|
|
2766
|
+
if (violation) {
|
|
2767
|
+
let deleted = false;
|
|
2768
|
+
if (policy.autoDeleteViolation) {
|
|
2769
|
+
deleted = await this.deleteMessage(session);
|
|
2770
|
+
}
|
|
2771
|
+
this._logger.warn(`[moderation] type=${violation.type} detail="${violation.detail}" deleted=${deleted} | ${this.sessionTag(session)}`);
|
|
2772
|
+
if (policy.sendViolationNotice) {
|
|
2773
|
+
const notice = violation.type === 'banned-word'
|
|
2774
|
+
? '检测到违禁词,消息已处理。'
|
|
2775
|
+
: violation.type === 'card'
|
|
2776
|
+
? '卡片消息不被允许,消息已处理。'
|
|
2777
|
+
: '合并转发消息不被允许,消息已处理。';
|
|
2778
|
+
try {
|
|
2779
|
+
await session.send(this.styleText(notice));
|
|
2780
|
+
}
|
|
2781
|
+
catch {
|
|
2782
|
+
// ignore notice failures
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
// 自动管控:累计违规达到阈值后自动禁言/自动踢人
|
|
2786
|
+
if (policy.enableAutoMute || policy.enableAutoKick) {
|
|
2787
|
+
try {
|
|
2788
|
+
const punish = await this.autoModerationPunish(session, policy);
|
|
2789
|
+
if (punish) {
|
|
2790
|
+
this._logger.info(`[auto-moderation] action=${punish.action} ok=${punish.ok} count=${punish.count} | ${this.sessionTag(session)}`);
|
|
2791
|
+
if (punish.action !== 'none') {
|
|
2792
|
+
if (policy.sendViolationNotice) {
|
|
2793
|
+
try {
|
|
2794
|
+
await session.send(this.styleText(punish.message));
|
|
2795
|
+
}
|
|
2796
|
+
catch {
|
|
2797
|
+
// ignore notice failures
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
else if (!punish.ok) {
|
|
2801
|
+
this._logger.warn(`[auto-moderation] ${punish.message} | ${this.sessionTag(session)}`);
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
catch (error) {
|
|
2807
|
+
this._logger.warn(`[auto-moderation] failed: ${String(error)} | ${this.sessionTag(session)}`);
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
try {
|
|
2814
|
+
await this.handleRepeater(session);
|
|
2815
|
+
}
|
|
2816
|
+
catch (error) {
|
|
2817
|
+
this._logger.warn(`[repeater] failed: ${String(error)} | ${this.sessionTag(session)}`);
|
|
2818
|
+
}
|
|
2819
|
+
await this.maybeReplyWithAi(session);
|
|
2820
|
+
}
|
|
2821
|
+
cleanupPendingJoinRequests() {
|
|
2822
|
+
const ttlMs = Math.max(1, this.config.joinRequestReviewTtlMinutes || 30) * 60 * 1000;
|
|
2823
|
+
const now = Date.now();
|
|
2824
|
+
for (const [code, item] of this.pendingJoinRequests) {
|
|
2825
|
+
if (now - item.createdAt <= ttlMs)
|
|
2826
|
+
continue;
|
|
2827
|
+
this.pendingJoinRequests.delete(code);
|
|
2828
|
+
this.pendingJoinRequestFlags.delete(item.flag);
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
generateJoinRequestCode() {
|
|
2832
|
+
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
2833
|
+
for (let i = 0; i < 16; i += 1) {
|
|
2834
|
+
let code = '';
|
|
2835
|
+
for (let j = 0; j < 6; j += 1) {
|
|
2836
|
+
code += chars[Math.floor(Math.random() * chars.length)];
|
|
2837
|
+
}
|
|
2838
|
+
if (!this.pendingJoinRequests.has(code))
|
|
2839
|
+
return code;
|
|
2840
|
+
}
|
|
2841
|
+
return `${Date.now().toString(36).toUpperCase().slice(-6)}`;
|
|
2842
|
+
}
|
|
2843
|
+
parseJoinRequestReviewText(text) {
|
|
2844
|
+
const source = text.trim();
|
|
2845
|
+
if (!source)
|
|
2846
|
+
return null;
|
|
2847
|
+
const approveMatch = source.match(/^(同意|通过|放行|批准|approve)\s*(?:入群|申请)?\s*#?([A-Za-z0-9]{4,12})(?:\s+(.+))?$/i);
|
|
2848
|
+
if (approveMatch) {
|
|
2849
|
+
return { approve: true, code: approveMatch[2].toUpperCase(), reason: (approveMatch[3] || '').trim() };
|
|
2850
|
+
}
|
|
2851
|
+
const rejectMatch = source.match(/^(拒绝|驳回|拒|deny|reject)\s*(?:入群|申请)?\s*#?([A-Za-z0-9]{4,12})(?:\s+(.+))?$/i);
|
|
2852
|
+
if (rejectMatch) {
|
|
2853
|
+
return { approve: false, code: rejectMatch[2].toUpperCase(), reason: (rejectMatch[3] || '').trim() };
|
|
2854
|
+
}
|
|
2855
|
+
return null;
|
|
2856
|
+
}
|
|
2857
|
+
async handleJoinRequestReviewMessage(session) {
|
|
2858
|
+
if (!this.config.enableJoinRequestReview)
|
|
2859
|
+
return false;
|
|
2860
|
+
if (session.platform !== 'onebot' || !session.guildId)
|
|
2861
|
+
return false;
|
|
2862
|
+
const parsed = this.parseJoinRequestReviewText(this.getMessageText(session));
|
|
2863
|
+
if (!parsed)
|
|
2864
|
+
return false;
|
|
2865
|
+
const auth = await this.authorizeCommand('review-join-request', session);
|
|
2866
|
+
if (!auth.ok)
|
|
2867
|
+
return false;
|
|
2868
|
+
const result = await this.reviewJoinRequestDecision(session, parsed.code, parsed.approve, parsed.reason);
|
|
2869
|
+
this.logCommandResult('review-join-request', result, session, { code: parsed.code, approve: parsed.approve, from: 'message' });
|
|
2870
|
+
await session.send(this.styleText(result.message));
|
|
2871
|
+
return true;
|
|
2872
|
+
}
|
|
2873
|
+
async handleGuildMemberRequest(session) {
|
|
2874
|
+
if (!this.config.enableJoinRequestReview)
|
|
2875
|
+
return;
|
|
2876
|
+
if (!this.isPlatformAllowed(session.platform))
|
|
2877
|
+
return;
|
|
2878
|
+
if (session.platform !== 'onebot')
|
|
2879
|
+
return;
|
|
2880
|
+
if (!session.guildId || !session.messageId || !session.userId)
|
|
2881
|
+
return;
|
|
2882
|
+
this.cleanupPendingJoinRequests();
|
|
2883
|
+
if (this.pendingJoinRequestFlags.has(session.messageId))
|
|
2884
|
+
return;
|
|
2885
|
+
const code = this.generateJoinRequestCode();
|
|
2886
|
+
const item = {
|
|
2887
|
+
code,
|
|
2888
|
+
flag: session.messageId,
|
|
2889
|
+
guildId: session.guildId,
|
|
2890
|
+
userId: session.userId,
|
|
2891
|
+
comment: (session.content || '').trim(),
|
|
2892
|
+
createdAt: Date.now(),
|
|
2893
|
+
};
|
|
2894
|
+
this.pendingJoinRequests.set(code, item);
|
|
2895
|
+
this.pendingJoinRequestFlags.set(item.flag, code);
|
|
2896
|
+
const prompt = [
|
|
2897
|
+
'检测到新的入群申请,请管理员审核:',
|
|
2898
|
+
`审核编号:${code}`,
|
|
2899
|
+
`申请账号:${item.userId}`,
|
|
2900
|
+
`验证信息:${item.comment || '(无)'}`,
|
|
2901
|
+
`同意:${this.config.command} 审核 ${code} 同意 [理由]`,
|
|
2902
|
+
`拒绝:${this.config.command} 审核 ${code} 拒绝 [理由]`,
|
|
2903
|
+
`快捷回复:同意入群 ${code} / 拒绝入群 ${code}`,
|
|
2904
|
+
].join('\n');
|
|
2905
|
+
try {
|
|
2906
|
+
await session.send(this.styleText(prompt));
|
|
2907
|
+
this.logCommandResult('join-request', { ok: true, message: 'join request notice sent' }, session, { code, target: item.userId });
|
|
2908
|
+
}
|
|
2909
|
+
catch (error) {
|
|
2910
|
+
this.logCommandResult('join-request', { ok: false, message: `join request notice failed: ${String(error)}` }, session, { code, target: item.userId });
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
async reviewJoinRequestDecision(session, code, approve, reason) {
|
|
2914
|
+
this.cleanupPendingJoinRequests();
|
|
2915
|
+
const key = code.trim().toUpperCase();
|
|
2916
|
+
const item = this.pendingJoinRequests.get(key);
|
|
2917
|
+
if (!item)
|
|
2918
|
+
return { ok: false, message: `未找到审核编号 ${key},可能已处理或已过期。` };
|
|
2919
|
+
if (session.guildId !== item.guildId)
|
|
2920
|
+
return { ok: false, message: `审核编号 ${key} 不属于当前群。` };
|
|
2921
|
+
const plan = {
|
|
2922
|
+
action: approve ? 'approve-join-request' : 'reject-join-request',
|
|
2923
|
+
groupId: item.guildId,
|
|
2924
|
+
targetId: item.userId,
|
|
2925
|
+
reason: `code=${item.code}${reason ? `; ${reason}` : ''}`,
|
|
2926
|
+
};
|
|
2927
|
+
if (this.config.dryRun)
|
|
2928
|
+
return this.createDryRunResult(plan);
|
|
2929
|
+
const bot = session.bot;
|
|
2930
|
+
if (!bot?.handleGuildMemberRequest) {
|
|
2931
|
+
return { ok: false, message: '当前适配器不支持处理入群申请。', plan };
|
|
2932
|
+
}
|
|
2933
|
+
try {
|
|
2934
|
+
await bot.handleGuildMemberRequest(item.flag, approve, reason || '');
|
|
2935
|
+
this.pendingJoinRequests.delete(key);
|
|
2936
|
+
this.pendingJoinRequestFlags.delete(item.flag);
|
|
2937
|
+
return {
|
|
2938
|
+
ok: true,
|
|
2939
|
+
message: approve
|
|
2940
|
+
? `已放行入群申请:${item.userId}(编号 ${key})`
|
|
2941
|
+
: `已拒绝入群申请:${item.userId}(编号 ${key})`,
|
|
2942
|
+
plan,
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2945
|
+
catch (error) {
|
|
2946
|
+
return { ok: false, message: `处理入群申请失败: ${String(error)}`, plan };
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
async formatTargetDisplay(session, targetId, rawTarget) {
|
|
2950
|
+
if (!targetId)
|
|
2951
|
+
return '';
|
|
2952
|
+
const fromAt = rawTarget?.match(/name="([^"]+)"/)?.[1]?.trim();
|
|
2953
|
+
if (!session?.guildId || session.platform !== 'onebot') {
|
|
2954
|
+
return fromAt ? `${fromAt}(${targetId})` : targetId;
|
|
2955
|
+
}
|
|
2956
|
+
const onebot = this.getOneBotApi(session);
|
|
2957
|
+
const groupId = this.toGroupId(session);
|
|
2958
|
+
const uid = Number(targetId);
|
|
2959
|
+
if (!onebot?.getGroupMemberInfo || groupId == null || !Number.isFinite(uid)) {
|
|
2960
|
+
return fromAt ? `${fromAt}(${targetId})` : targetId;
|
|
2961
|
+
}
|
|
2962
|
+
try {
|
|
2963
|
+
const info = await onebot.getGroupMemberInfo(groupId, uid, true);
|
|
2964
|
+
const name = info.card?.trim() || info.nickname?.trim() || fromAt;
|
|
2965
|
+
return name ? `${name}(${targetId})` : targetId;
|
|
2966
|
+
}
|
|
2967
|
+
catch {
|
|
2968
|
+
return fromAt ? `${fromAt}(${targetId})` : targetId;
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
async getTargetRole(session, targetId) {
|
|
2972
|
+
const onebot = this.getOneBotApi(session);
|
|
2973
|
+
const groupId = this.toGroupId(session);
|
|
2974
|
+
if (!onebot?.getGroupMemberInfo || groupId == null)
|
|
2975
|
+
return null;
|
|
2976
|
+
try {
|
|
2977
|
+
const info = await onebot.getGroupMemberInfo(groupId, targetId, true);
|
|
2978
|
+
if (info.role === 'owner' || info.role === 'admin' || info.role === 'member')
|
|
2979
|
+
return info.role;
|
|
2980
|
+
return null;
|
|
2981
|
+
}
|
|
2982
|
+
catch {
|
|
2983
|
+
return null;
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
createDryRunResult(plan) {
|
|
2987
|
+
return {
|
|
2988
|
+
ok: true,
|
|
2989
|
+
message: 'dry-run: no action executed',
|
|
2990
|
+
plan,
|
|
2991
|
+
};
|
|
2992
|
+
}
|
|
2993
|
+
async planDemoAction(groupId, reason) {
|
|
2994
|
+
const plan = {
|
|
2995
|
+
action: 'demo',
|
|
2996
|
+
groupId,
|
|
2997
|
+
reason,
|
|
2998
|
+
};
|
|
2999
|
+
return {
|
|
3000
|
+
ok: true,
|
|
3001
|
+
message: this.config.dryRun ? 'dry-run: no action executed' : 'action executor not implemented yet',
|
|
3002
|
+
plan,
|
|
3003
|
+
};
|
|
3004
|
+
}
|
|
3005
|
+
async kick(session, target, rejectAddRequest = false, reason) {
|
|
3006
|
+
const targetIdRaw = this.parseTargetId(target);
|
|
3007
|
+
const targetId = Number(targetIdRaw);
|
|
3008
|
+
const groupId = this.toGroupId(session);
|
|
3009
|
+
if (!targetIdRaw || !Number.isFinite(targetId)) {
|
|
3010
|
+
return { ok: false, message: '目标 QQ 号无效。' };
|
|
3011
|
+
}
|
|
3012
|
+
if (groupId == null) {
|
|
3013
|
+
return { ok: false, message: '请在群聊中执行该命令。' };
|
|
3014
|
+
}
|
|
3015
|
+
const plan = {
|
|
3016
|
+
action: 'kick',
|
|
3017
|
+
groupId: String(groupId),
|
|
3018
|
+
targetId: targetIdRaw,
|
|
3019
|
+
reason,
|
|
3020
|
+
};
|
|
3021
|
+
if (this.config.dryRun)
|
|
3022
|
+
return this.createDryRunResult(plan);
|
|
3023
|
+
const onebot = this.getOneBotApi(session);
|
|
3024
|
+
if (!onebot?.setGroupKick) {
|
|
3025
|
+
return { ok: false, message: '当前会话不支持 OneBot 踢人接口。' };
|
|
3026
|
+
}
|
|
3027
|
+
const role = await this.getTargetRole(session, targetId);
|
|
3028
|
+
if (role === 'owner') {
|
|
3029
|
+
return { ok: false, message: '不能对群主执行踢人操作。' };
|
|
3030
|
+
}
|
|
3031
|
+
try {
|
|
3032
|
+
await onebot.setGroupKick(groupId, targetId, rejectAddRequest);
|
|
3033
|
+
return { ok: true, message: `已执行踢人: ${targetIdRaw}`, plan };
|
|
3034
|
+
}
|
|
3035
|
+
catch (error) {
|
|
3036
|
+
return { ok: false, message: `踢人失败: ${String(error)}`, plan };
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
async mute(session, target, minutes, reason) {
|
|
3040
|
+
const targetIdRaw = this.parseTargetId(target);
|
|
3041
|
+
const targetId = Number(targetIdRaw);
|
|
3042
|
+
const groupId = this.toGroupId(session);
|
|
3043
|
+
if (!targetIdRaw || !Number.isFinite(targetId)) {
|
|
3044
|
+
return { ok: false, message: '目标 QQ 号无效。' };
|
|
3045
|
+
}
|
|
3046
|
+
if (groupId == null) {
|
|
3047
|
+
return { ok: false, message: '请在群聊中执行该命令。' };
|
|
3048
|
+
}
|
|
3049
|
+
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
3050
|
+
return { ok: false, message: '禁言时长必须为正整数分钟(上限 30 天)。' };
|
|
3051
|
+
}
|
|
3052
|
+
const requestedMinutes = Math.floor(minutes);
|
|
3053
|
+
const capped = requestedMinutes > 43200;
|
|
3054
|
+
const effectiveMinutes = Math.min(requestedMinutes, 43200);
|
|
3055
|
+
const duration = effectiveMinutes * 60;
|
|
3056
|
+
const plan = {
|
|
3057
|
+
action: 'mute',
|
|
3058
|
+
groupId: String(groupId),
|
|
3059
|
+
targetId: targetIdRaw,
|
|
3060
|
+
reason: reason ? `${reason}; minutes=${requestedMinutes}` : `minutes=${requestedMinutes}`,
|
|
3061
|
+
};
|
|
3062
|
+
if (this.config.dryRun)
|
|
3063
|
+
return this.createDryRunResult(plan);
|
|
3064
|
+
const onebot = this.getOneBotApi(session);
|
|
3065
|
+
if (!onebot?.setGroupBan) {
|
|
3066
|
+
return { ok: false, message: '当前会话不支持 OneBot 禁言接口。' };
|
|
3067
|
+
}
|
|
3068
|
+
if (this.config.allowedUserIds.includes(targetIdRaw)) {
|
|
3069
|
+
const actorRole = await this.resolveMemberRole(session);
|
|
3070
|
+
const bypass = this.config.allowAdminBypassWhitelistMute && (actorRole === 'owner' || actorRole === 'admin');
|
|
3071
|
+
if (!bypass) {
|
|
3072
|
+
return { ok: false, message: '目标账号在白名单保护中,禁止禁言。' };
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
const role = await this.getTargetRole(session, targetId);
|
|
3076
|
+
if (role === 'owner') {
|
|
3077
|
+
return { ok: false, message: '喵~ 群主无法被禁言。' };
|
|
3078
|
+
}
|
|
3079
|
+
if (role === 'admin' && !await this.isBotOwnerInGroup(session)) {
|
|
3080
|
+
return { ok: false, message: '喵~ bot 不是群主,不能对管理员禁言。' };
|
|
3081
|
+
}
|
|
3082
|
+
try {
|
|
3083
|
+
await onebot.setGroupBan(groupId, targetId, duration);
|
|
3084
|
+
const capNote = capped ? '(已按 30 天上限处理)' : '';
|
|
3085
|
+
return { ok: true, message: `已执行禁言: ${targetIdRaw}, ${effectiveMinutes} 分钟${capNote}`, plan };
|
|
3086
|
+
}
|
|
3087
|
+
catch (error) {
|
|
3088
|
+
return { ok: false, message: `禁言失败: ${String(error)}`, plan };
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
async unmute(session, target, reason) {
|
|
3092
|
+
const targetIdRaw = this.parseTargetId(target);
|
|
3093
|
+
const targetId = Number(targetIdRaw);
|
|
3094
|
+
const groupId = this.toGroupId(session);
|
|
3095
|
+
if (!targetIdRaw || !Number.isFinite(targetId)) {
|
|
3096
|
+
return { ok: false, message: '目标 QQ 号无效。' };
|
|
3097
|
+
}
|
|
3098
|
+
if (groupId == null) {
|
|
3099
|
+
return { ok: false, message: '请在群聊中执行该命令。' };
|
|
3100
|
+
}
|
|
3101
|
+
const plan = {
|
|
3102
|
+
action: 'unmute',
|
|
3103
|
+
groupId: String(groupId),
|
|
3104
|
+
targetId: targetIdRaw,
|
|
3105
|
+
reason,
|
|
3106
|
+
};
|
|
3107
|
+
if (this.config.dryRun)
|
|
3108
|
+
return this.createDryRunResult(plan);
|
|
3109
|
+
const onebot = this.getOneBotApi(session);
|
|
3110
|
+
if (!onebot?.setGroupBan) {
|
|
3111
|
+
return { ok: false, message: '当前会话不支持 OneBot 禁言接口。' };
|
|
3112
|
+
}
|
|
3113
|
+
const role = await this.getTargetRole(session, targetId);
|
|
3114
|
+
if (role === 'owner') {
|
|
3115
|
+
return { ok: false, message: '不能对群主执行解禁言操作。' };
|
|
3116
|
+
}
|
|
3117
|
+
try {
|
|
3118
|
+
await onebot.setGroupBan(groupId, targetId, 0);
|
|
3119
|
+
return { ok: true, message: `已执行解禁言: ${targetIdRaw}`, plan };
|
|
3120
|
+
}
|
|
3121
|
+
catch (error) {
|
|
3122
|
+
return { ok: false, message: `解禁言失败: ${String(error)}`, plan };
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
async setAdmin(session, target, enable, reason) {
|
|
3126
|
+
const targetIdRaw = this.parseTargetId(target);
|
|
3127
|
+
const targetId = Number(targetIdRaw);
|
|
3128
|
+
const groupId = this.toGroupId(session);
|
|
3129
|
+
if (!targetIdRaw || !Number.isFinite(targetId)) {
|
|
3130
|
+
return { ok: false, message: '目标 QQ 号无效。' };
|
|
3131
|
+
}
|
|
3132
|
+
if (groupId == null) {
|
|
3133
|
+
return { ok: false, message: '请在群聊中执行该命令。' };
|
|
3134
|
+
}
|
|
3135
|
+
const plan = {
|
|
3136
|
+
action: enable ? 'set-admin' : 'unset-admin',
|
|
3137
|
+
groupId: String(groupId),
|
|
3138
|
+
targetId: targetIdRaw,
|
|
3139
|
+
reason,
|
|
3140
|
+
};
|
|
3141
|
+
if (this.config.dryRun)
|
|
3142
|
+
return this.createDryRunResult(plan);
|
|
3143
|
+
const onebot = this.getOneBotApi(session);
|
|
3144
|
+
if (!onebot?.setGroupAdmin) {
|
|
3145
|
+
return { ok: false, message: '当前会话不支持 OneBot 设管理接口。' };
|
|
3146
|
+
}
|
|
3147
|
+
const role = await this.getTargetRole(session, targetId);
|
|
3148
|
+
if (role === 'owner') {
|
|
3149
|
+
return { ok: false, message: '群主不需要设置管理员。' };
|
|
3150
|
+
}
|
|
3151
|
+
try {
|
|
3152
|
+
await onebot.setGroupAdmin(groupId, targetId, enable);
|
|
3153
|
+
return { ok: true, message: enable ? `已设置管理员: ${targetIdRaw}` : `已取消管理员: ${targetIdRaw}`, plan };
|
|
3154
|
+
}
|
|
3155
|
+
catch (error) {
|
|
3156
|
+
return { ok: false, message: `设置管理员失败: ${String(error)}`, plan };
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
exports.QQGroupManagerService = QQGroupManagerService;
|