-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.js
More file actions
428 lines (405 loc) · 16.2 KB
/
Copy pathindex.js
File metadata and controls
428 lines (405 loc) · 16.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
const WebSocket = require('ws');
const fs = require('fs');
const path = require('path');
const http = require('http');
// 新增:加载配置文件并构建配置
const CONFIG_FILE = process.env.CONFIG_FILE || path.join(__dirname, 'config.json');
function loadConfig() {
let cfg = {};
try {
const buf = fs.readFileSync(CONFIG_FILE, 'utf8');
cfg = JSON.parse(buf);
console.log(`[config] loaded from ${CONFIG_FILE}`);
} catch (e) {
console.log('[config] no config file, using env/defaults');
}
const dataDirRaw = cfg.dataDir ?? process.env.DATA_DIR ?? path.join(__dirname, 'data');
const dataDir = path.isAbsolute(dataDirRaw) ? dataDirRaw : path.join(__dirname, dataDirRaw);
const storageFileRaw = cfg.storageFile ?? process.env.STORAGE_FILE ?? path.join(dataDirRaw, 'counters.json');
const storageFile = path.isAbsolute(storageFileRaw) ? storageFileRaw : path.join(__dirname, storageFileRaw);
return {
wsHost: cfg.wsHost ?? process.env.WS_HOST ?? '127.0.0.1',
wsPort: Number(cfg.wsPort ?? process.env.WS_PORT ?? 3001),
wsToken: cfg.wsToken ?? process.env.WS_TOKEN ?? 'PCedy>,a_c|nMLFp',
targetGroupId: Number(cfg.targetGroupId ?? process.env.TARGET_GROUP_ID ?? 496434599),
keywords: Array.isArray(cfg.keywords) ? cfg.keywords.filter(Boolean) : ((process.env.KEYWORDS || '白月光').split(',').map(s => s.trim()).filter(Boolean)),
httpHost: cfg.httpHost ?? process.env.HTTP_HOST ?? '127.0.0.1',
httpPort: Number(cfg.httpPort ?? process.env.HTTP_PORT ?? 3000),
httpToken: cfg.httpToken ?? process.env.HTTP_TOKEN ?? '_qKBVOK@Xs{0y#I}',
adminUserId: Number(cfg.adminUserId ?? process.env.ADMIN_USER_ID ?? 228676723),
dataDir,
storageFile,
};
}
const CONFIG = loadConfig();
// 配置常量(优先使用配置文件)
const WS_HOST = CONFIG.wsHost;
const WS_PORT = CONFIG.wsPort;
const WS_TOKEN = CONFIG.wsToken;
const TARGET_GROUP_ID = CONFIG.targetGroupId;
const KEYWORDS = CONFIG.keywords;
// HTTP 客户端配置
const HTTP_HOST = CONFIG.httpHost;
const HTTP_PORT = CONFIG.httpPort;
const HTTP_TOKEN = CONFIG.httpToken;
const ADMIN_USER_ID = CONFIG.adminUserId;
// In-memory counter: { [user_id]: { total:number, per: { [keyword]: number } } }
const counters = new Map();
// Keyword thresholds: { [keyword]: number }
let thresholds = {};
let triggeredKeywords = {};
// Persistence configuration
const DATA_DIR = CONFIG.dataDir;
const STORAGE_FILE = CONFIG.storageFile;
let saveTimer = null
function ensureDir() {
try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch (e) {}
}
function loadCounters() {
try {
const buf = fs.readFileSync(STORAGE_FILE, 'utf8');
const obj = JSON.parse(buf);
if (obj && obj.counters) {
// New schema
thresholds = obj.thresholds || {};
triggeredKeywords = obj.triggeredKeywords || {};
const entries = Object.entries(obj.counters);
for (const [userId, data] of entries) {
const total = typeof data.total === 'number' ? data.total : 0;
const per = data.per && typeof data.per === 'object' ? data.per : {};
counters.set(Number(userId), { total, per });
}
} else {
// Legacy: { userId: count }
for (const [userId, count] of Object.entries(obj || {})) {
counters.set(Number(userId), { total: Number(count) || 0, per: {} });
}
thresholds = {};
triggeredKeywords = {};
}
// Merge thresholds keywords into KEYWORDS list if not present
for (const kw of Object.keys(thresholds)) {
if (kw && !KEYWORDS.includes(kw)) KEYWORDS.push(kw);
}
console.log(`[storage] loaded ${counters.size} users, ${Object.keys(thresholds).length} thresholds from ${STORAGE_FILE}`);
} catch (e) {
console.log('[storage] no existing counters file, starting fresh');
}
}
function writeCountersSync() {
ensureDir();
const tmp = STORAGE_FILE + '.tmp';
const countersObj = {};
for (const [userId, data] of counters.entries()) {
countersObj[userId] = { total: data.total || 0, per: data.per || {} };
}
const payload = JSON.stringify({ counters: countersObj, thresholds, triggeredKeywords }, null, 2);
fs.writeFileSync(tmp, payload);
try {
fs.renameSync(tmp, STORAGE_FILE);
} catch (err) {
// Windows 上如果目标文件存在或被占用,rename 可能失败;尝试删除后重命名;最终回退到拷贝
try { fs.unlinkSync(STORAGE_FILE); } catch (_) {}
try {
fs.renameSync(tmp, STORAGE_FILE);
} catch (err2) {
try { fs.copyFileSync(tmp, STORAGE_FILE); } catch (err3) { console.error('[storage] copy fallback error', err3); }
try { fs.unlinkSync(tmp); } catch (_) {}
}
}
console.log(`[storage] saved ${Object.keys(countersObj).length} users, ${Object.keys(thresholds).length} thresholds to ${STORAGE_FILE}`);
}
function scheduleSave() {
if (saveTimer) return;
saveTimer = setTimeout(() => {
saveTimer = null;
try { writeCountersSync(); } catch (err) { console.error('[storage] save error', err); }
}, 1000);
}
function countByKeyword(text) {
const result = {};
if (!text) return result;
const activeKeywords = KEYWORDS.slice();
for (const kw of activeKeywords) {
if (!kw) continue;
const regex = new RegExp(kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
const matches = text.match(regex);
const n = matches ? matches.length : 0;
if (n > 0) result[kw] = n;
}
return result;
}
function incrementCounter(userId, keyword, amount) {
if (amount <= 0) return;
const data = counters.get(userId) || { total: 0, per: {} };
const prevPer = data.per[keyword] || 0;
const nextPer = prevPer + amount;
data.per[keyword] = nextPer;
data.total = (data.total || 0) + amount;
counters.set(userId, data);
console.log(`[counter] user_id=${userId} keyword=${keyword} +${amount} => ${nextPer} (total=${data.total})`);
scheduleSave();
// Threshold notification: only when crossing threshold
const th = thresholds[keyword];
if (typeof th === 'number' && th > 0 && prevPer < th && nextPer >= th) {
// If keyword already triggered by someone, do not notify again
if (triggeredKeywords[keyword]) {
return;
}
const text = `用户 ${userId} 的关键词「${keyword}」累计达到 ${nextPer},已触发阈值 ${th}`;
sendPrivateText(ADMIN_USER_ID, text).catch(err => console.error('[http] notify error', err));
// 新增:群聊同步提醒
sendGroupText(TARGET_GROUP_ID, text).catch(err => console.error('[http] group notify error', err));
// Mark keyword as triggered globally
triggeredKeywords[keyword] = true;
scheduleSave();
}
}
function sendPrivateText(userId, text) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({
user_id: String(userId),
message: [ { type: 'text', data: { text } } ]
});
const options = {
host: HTTP_HOST,
port: HTTP_PORT,
path: `/send_private_msg?access_token=${encodeURIComponent(HTTP_TOKEN)}`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
// 兼容不同服务的鉴权方式:同时传入 token 头
'token': HTTP_TOKEN
}
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
console.log(`[http] sent to ${userId} status=${res.statusCode}`);
resolve({ statusCode: res.statusCode, body: data });
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
// 新增:群聊发送文本
function sendGroupText(groupId, text) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({
group_id: String(groupId),
message: [ { type: 'text', data: { text } } ]
});
const options = {
host: HTTP_HOST,
port: HTTP_PORT,
path: `/send_group_msg?access_token=${encodeURIComponent(HTTP_TOKEN)}`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
// 兼容不同服务的鉴权方式:同时传入 token 头
'token': HTTP_TOKEN
}
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
console.log(`[http] group ${groupId} status=${res.statusCode}`);
resolve({ statusCode: res.statusCode, body: data });
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
// 帮助:格式化关键词列表(带序号与阈值)
function formatKeywordList() {
if (!KEYWORDS.length) return '关键词列表为空';
const lines = KEYWORDS.map((kw, idx) => {
const th = thresholds[kw];
const mark = triggeredKeywords[kw] ? '(已触发)' : '';
const thStr = typeof th === 'number' && th > 0 ? `(阈值:${th})` : '(无阈值)';
return `${idx + 1}. ${kw}${thStr}${mark}`;
});
return lines.join('\n');
}
function deleteKeywordByIndex(indexOneBased) {
const i = indexOneBased - 1;
if (i < 0 || i >= KEYWORDS.length) {
return { ok: false, msg: `删除失败:序号 ${indexOneBased} 无效` };
}
const kw = KEYWORDS[i];
KEYWORDS.splice(i, 1);
delete thresholds[kw];
delete triggeredKeywords[kw];
// 同步移除各用户该词计数并调整总计
for (const [uid, data] of counters.entries()) {
const n = (data.per && data.per[kw]) || 0;
if (n > 0) {
data.total = Math.max(0, (data.total || 0) - n);
delete data.per[kw];
counters.set(uid, data);
}
}
scheduleSave();
return { ok: true, msg: `已删除第 ${indexOneBased} 个关键词「${kw}」` };
}
function handleAdminPrivateMessage(payload) {
const raw = (payload.raw_message || '').trim();
const lines = raw.split('\n').map(s => s.trim()).filter(s => s.length > 0);
if (lines.length === 0) {
return sendPrivateText(ADMIN_USER_ID, '设置失败:内容为空\n用法:\n1) 每两行一组:关键词\n阈值\n2) 单行命令:查看 / -n(删除第n个词) / 全部清空 / 清空已触发 / 清空计数\n3) 单行非命令:添加该关键词');
}
if (lines.length === 1) {
const cmd = lines[0];
// 删除命令:-n
const delMatch = cmd.match(/^-(\d+)$/);
if (delMatch) {
const n = Number(delMatch[1]);
const res = deleteKeywordByIndex(n);
const list = formatKeywordList();
return sendPrivateText(ADMIN_USER_ID, `${res.msg}\n\n当前关键词:\n${list}`);
}
// 全部清空:删除存储文件并清空内存结构
if (cmd === '全部清空') {
try { fs.rmSync(STORAGE_FILE, { force: true }); } catch (e) {}
counters.clear();
thresholds = {};
triggeredKeywords = {};
return sendPrivateText(ADMIN_USER_ID, `已全部清空(已删除存储文件:${STORAGE_FILE})。\n\n当前关键词:\n${formatKeywordList()}`);
}
// 清空已触发:仅重置已触发标记
if (cmd === '清空已触发') {
triggeredKeywords = {};
scheduleSave();
return sendPrivateText(ADMIN_USER_ID, `已清空已触发标记。\n\n当前关键词:\n${formatKeywordList()}`);
}
// 清空计数:清空所有人的计数并重置已触发标记
if (cmd === '清空计数') {
for (const [uid, data] of counters.entries()) {
data.total = 0;
data.per = {};
counters.set(uid, data);
}
triggeredKeywords = {};
scheduleSave();
return sendPrivateText(ADMIN_USER_ID, `已清空所有人的计数并重置已触发标记。\n\n当前关键词:\n${formatKeywordList()}`);
}
// 兼容旧命令:清空 -> 提示使用新命令
if (cmd === '清空') {
return sendPrivateText(ADMIN_USER_ID, '请使用以下命令:\n- 全部清空(删除存储文件)\n- 清空已触发(仅重置已触发标记)\n- 清空计数(清空所有人的计数并重置已触发)');
}
if (cmd === '查看') {
const list = formatKeywordList();
return sendPrivateText(ADMIN_USER_ID, list);
} else {
// 默认:添加关键词(若已存在则提示不重复添加)
const kw = cmd;
if (!kw) return sendPrivateText(ADMIN_USER_ID, '添加失败:关键词为空');
if (KEYWORDS.includes(kw)) {
return sendPrivateText(ADMIN_USER_ID, `关键词「${kw}」已存在,未重复添加。\n\n当前关键词:\n${formatKeywordList()}`);
}
KEYWORDS.push(kw);
// 不设置阈值,保持无阈值状态
scheduleSave();
return sendPrivateText(ADMIN_USER_ID, `已添加关键词「${kw}」。\n\n当前关键词:\n${formatKeywordList()}`);
}
}
if (lines.length % 2 === 0) {
const updated = [];
const skipped = [];
for (let i = 0; i < lines.length; i += 2) {
const kw = lines[i];
const thStr = lines[i + 1];
const th = Number(thStr);
if (!kw) continue;
if (!Number.isInteger(th) || th <= 0) {
return sendPrivateText(ADMIN_USER_ID, `设置失败:阈值必须是正整数。问题关键词「${kw}」阈值=${thStr}`);
}
if (KEYWORDS.includes(kw)) {
// 改为默认添加,不覆盖:已存在则跳过
skipped.push(`「${kw}」已存在,未覆盖阈值`);
continue;
}
thresholds[kw] = th;
KEYWORDS.push(kw);
delete triggeredKeywords[kw];
updated.push(`「${kw}」 => 阈值 ${th}`);
}
scheduleSave();
const msgParts = [];
if (updated.length) msgParts.push(`已新增阈值:\n${updated.join('\n')}`);
if (skipped.length) msgParts.push(`以下关键词已存在,未覆盖:\n${skipped.join('\n')}`);
msgParts.push(`\n当前关键词:\n${formatKeywordList()}`);
return sendPrivateText(ADMIN_USER_ID, msgParts.join('\n'));
}
// Odd lines (>1): invalid format
return sendPrivateText(ADMIN_USER_ID, '设置失败:行数必须为偶数或一行命令/关键词。\n用法示例:\n魔法\n3\n白月光\n4\n或:查看 / -2(删除第2个) / 全部清空 / 清空已触发 / 清空计数\n或:单行输入关键词进行添加');
}
function connect() {
const url = `ws://${WS_HOST}:${WS_PORT}/?access_token=${encodeURIComponent(WS_TOKEN)}`;
console.log(`[ws] connecting ws://${WS_HOST}:${WS_PORT}`);
const ws = new WebSocket(url);
ws.on('open', () => {
console.log('[ws] connected');
});
ws.on('message', (data) => {
try {
const payload = JSON.parse(data.toString());
// Admin private messages for configuration and commands
if (payload && payload.post_type === 'message' && payload.message_type === 'private') {
const uid = payload?.sender?.user_id ?? payload.user_id;
if (uid === ADMIN_USER_ID) {
handleAdminPrivateMessage(payload);
return; // admin messages handled separately
}
}
// Only process group messages in target group for counting
if (payload && payload.post_type === 'message' && payload.message_type === 'group' && payload.group_id === TARGET_GROUP_ID) {
const text = payload.raw_message || '';
const byKw = countByKeyword(text);
const userId = payload?.sender?.user_id ?? payload.user_id;
for (const [kw, n] of Object.entries(byKw)) {
incrementCounter(userId, kw, n);
}
}
} catch (err) {
console.error('[ws] message parse error:', err);
}
});
ws.on('close', (code, reason) => {
console.log(`[ws] closed code=${code} reason=${reason}`);
setTimeout(connect, 2000); // auto-reconnect
});
ws.on('error', (err) => {
console.error('[ws] error', err);
});
// Expose simple HTTP endpoint to show counters (optional)
}
loadCounters();
connect();
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\n[counters] final:');
for (const [userId, data] of counters.entries()) {
console.log(`user_id=${userId} total=${data.total}`);
for (const [kw, c] of Object.entries(data.per || {})) {
console.log(` 「${kw}」=${c}`);
}
}
try { writeCountersSync(); } catch (e) { console.error('[storage] save on exit error', e); }
process.exit(0);
});
// 全局错误处理,避免未捕获异常导致进程退出
process.on('uncaughtException', (err) => {
console.error('[fatal] uncaughtException', err);
});
process.on('unhandledRejection', (reason) => {
console.error('[fatal] unhandledRejection', reason);
});