-
Notifications
You must be signed in to change notification settings - Fork 36
Expand file tree
/
Copy pathworker.js
More file actions
8158 lines (6971 loc) · 301 KB
/
Copy pathworker.js
File metadata and controls
8158 lines (6971 loc) · 301 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
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Next Gen Forward v1.6.2
// 基于 Cloudflare Workers 部署的 Telegram 双向私聊机器人。
// 通过群组话题管理私聊,人机验证模块支持 Cloudflare Turnstile & 本地题库 可随时切换。
// 项目地址 https://github.com/mole404/NextGenForward
// 本项目基于 https://github.com/jikssha/telegram_private_chatbot 修改
// 在此对原项目作者 Vaghr (Github@jikssha) ,以及我的好兄弟 打钱 表示特别感谢!
// Copyright (c) 2026 Darkstar
// Released under the MIT License. See LICENSE in the project root.
const BOT_VERSION = "v1.6.2";
// --- 配置常量 ---
const CONFIG = {
// 用户速率限制
RATE_LIMIT_VERIFY: 3, // 用户5分钟内最多可尝试人机验证次数,不可设为0
RATE_LIMIT_MESSAGE: 45, // 用户私聊消息发送速率限制,不可设为0
RATE_LIMIT_WINDOW: 60, // 用户私聊消息速率限制窗口(秒),不可设为0
// 人机验证配置
VERIFY_BUTTON_TEXT: "🤖 点击进行人机验证", // 人机验证按钮文本
VERIFY_EXPIRE_SECONDS: 300, // 人机验证链接有效期(秒)
VERIFY_FINALIZE_EXPIRE_SECONDS: 600, // 通过网页验证后,点击完成激活按钮的有效期(秒)
VERIFIED_GRACE_SECONDS: 300, // 完成验证后宽限窗口(秒),用于兜底 KV 跨 PoP 传播/负缓存
VERIFIED_TTL_SECONDS_DEFAULT: 0, // verified 键默认不过期;可用环境变量 VERIFIED_TTL_SECONDS 覆盖(>0 生效)
KV_CRITICAL_CACHE_TTL: 60, // 关键键 KV.get 的 cacheTtl(秒),Cloudflare KV 最小为 60;不要设为 0
TURNSTILE_ACTION: "tg_verify", // Turnstile action(前端 render + 服务端校验),可留空禁用
PENDING_MAX_MESSAGES: 10, // 人机验证期间最多暂存消息数量,不可设为0
PENDING_QUEUE_TTL_SECONDS: 86400, // 暂存消息队列 TTL(秒),用于跨验证会话保留首条触发消息
VERIFY_PROMPT_TTL_SECONDS: 86400, // 验证按钮消息ID列表 TTL(秒),用于验证完成后移除旧按钮
VERIFY_PROMPT_MAX_IDS: 6, // 每个用户最多记录多少条“验证按钮消息”message_id
// 媒体组消息处理配置
MEDIA_GROUP_EXPIRE_SECONDS: 60, // 媒体组消息过期时间(秒),用于清理KV中的相册/组图消息暂存数据
MEDIA_GROUP_DELAY_MS: 3000, // 媒体组消息发送延迟(毫秒),用于等待同一媒体组的所有消息到达
// 缓存配置
THREAD_HEALTH_TTL_MS: 60000, // 线程健康检查缓存时间(毫秒),减少频繁的话题探测
// API调用配置
API_TIMEOUT_MS: 10000, // Telegram API 调用超时时间(毫秒)
API_MAX_RETRY_ATTEMPTS: 3, // Telegram API最大重试次数
API_RETRY_BASE_DELAY_MS: 1000, // Telegram API重试基础延迟(毫秒),用于指数退避
API_RETRY_MAX_DELAY_MS: 5000, // Telegram API最大重试延迟(毫秒)
// 话题限制
MAX_TITLE_LENGTH: 128, // 话题标题最大长度,Telegram论坛话题标题限制
MAX_NAME_LENGTH: 30, // 用户名称最大长度,用于构建话题标题
MAX_RETRY_ATTEMPTS: 3, // 最大重试尝试次数,用于话题创建等操作
// Webhook路径配置
WEBHOOK_PATH: '/', // Webhook路径,Telegram webhook 的接收端点
VERIFY_PATH: '/verify', // 人机验证页面路径
VERIFY_CALLBACK_PATH: '/verify-callback', // 人机验证回调
VERIFY_EVENT_PATH: '/verify-event', // Turnstile 失败/超时等事件上报(用于让旧按钮消失 + 重新下发新按钮)路径,用于处理Turnstile验证结果
// KV存储操作配置
KV_LIST_BATCH_SIZE: 100, // KV存储列表操作批量大小,用于分页获取KV键
KV_SCAN_CONCURRENCY: 8, // 扫描 user:* 等键时的并发 GET 数量
KV_DELETE_BATCH_SIZE: 50, // KV存储删除操作批量大小,批量删除时的每批数量
KV_DELETE_DELAY_MS: 500, // KV存储删除操作延迟(毫秒),避免速率限制
KV_OPERATION_MAX_ITEMS: 1000, // KV操作最大项目数,防止一次操作过多数据
// 话题删除配置
TOPIC_DELETE_MAX_PER_BATCH: 10, // 批量删除话题时每批最大数量
TOPIC_DELETE_DELAY_MS: 500, // 批量删除话题时的延迟(毫秒),避免Telegram API速率限制
TOPIC_DELETE_RETRY_ATTEMPTS: 2, // 话题删除重试次数
TOPIC_DELETE_RETRY_DELAY_MS: 1000, // 话题删除重试延迟(毫秒)
};
const VERIFY_MODE_DEFAULT = "local_quiz"; // 默认:本地题库验证(Turnstile 可选)
// Turnstile 是否已配置(同时需要 Site Key 与 Secret Key)
function hasTurnstileBinding(env) {
const site = (env && env.CF_TURNSTILE_SITE_KEY ? String(env.CF_TURNSTILE_SITE_KEY) : "").trim();
const secret = (env && env.CF_TURNSTILE_SECRET_KEY ? String(env.CF_TURNSTILE_SECRET_KEY) : "").trim();
return !!(site && secret);
}
// KV key:全局验证模式(仅影响新会话)
const GLOBAL_VERIFY_MODE_KEY = "global_verify:mode";
// --- 垃圾消息过滤(v1.1b)---
// KV key:全局垃圾过滤开关(仅影响新消息;缺省=开启,v1.1.1b)
const GLOBAL_SPAM_FILTER_ENABLED_KEY = "global_spam_filter:enabled";
// KV key:全局垃圾过滤规则(JSON)
const GLOBAL_SPAM_FILTER_RULES_KEY = "global_spam_filter:rules";
// KV key:全局垃圾过滤规则提示词(可读可写文本,v1.1.1b)
const GLOBAL_SPAM_FILTER_RULES_PROMPT_KEY = "global_spam_filter:rules_prompt";
// KV key:管理员编辑规则会话
const SPAM_RULES_EDIT_SESSION_KEY_PREFIX = "spam_rules_edit_session:";
// 纯 Telegram 内联按钮本地题库(20 题),难度参照 worker.js
const LOCAL_QUIZ_QUESTIONS = [
{ q: "冰融化后会变成什么?", opts: ["水", "火", "石头", "空气"], a: 0 },
{ q: "星期一后面是星期几?", opts: ["星期二", "星期日", "星期五", "星期一"], a: 0 },
{ q: "2 + 3 等于几?", opts: ["4", "5", "6", "8"], a: 1 },
{ q: "太阳从哪边升起?", opts: ["东", "西", "南", "北"], a: 0 },
{ q: "1 分钟有多少秒?", opts: ["30", "60", "90", "120"], a: 1 },
{ q: "水的沸点在标准大气压下约是多少摄氏度?", opts: ["50℃", "80℃", "100℃", "120℃"], a: 2 },
{ q: "下列哪个是水果?", opts: ["土豆", "苹果", "黄瓜", "洋葱"], a: 1 },
{ q: "“上”与哪个方向相反?", opts: ["左", "右", "下", "前"], a: 2 },
{ q: "10 - 7 等于几?", opts: ["1", "2", "3", "4"], a: 2 },
{ q: "一周有几天?", opts: ["5", "6", "7", "8"], a: 2 },
{ q: "下列哪个不是颜色?", opts: ["红", "蓝", "快", "绿"], a: 2 },
{ q: "把灯关掉后,房间通常会变得?", opts: ["更亮", "更暗", "更热", "更冷"], a: 1 },
{ q: "猫通常有几条腿?", opts: ["2", "3", "4", "6"], a: 2 },
{ q: "地球绕着什么转?", opts: ["月亮", "太阳", "星星", "云朵"], a: 1 },
{ q: "下列哪个是交通工具?", opts: ["桌子", "汽车", "枕头", "雨伞"], a: 1 },
{ q: "“你好”的相反含义更接近?", opts: ["再见", "谢谢", "请", "对不起"], a: 0 },
{ q: "5 × 2 等于几?", opts: ["7", "8", "9", "10"], a: 3 },
{ q: "下列哪个是动物?", opts: ["石头", "杯子", "狗", "椅子"], a: 2 },
{ q: "水在 0℃ 附近会?", opts: ["结冰", "沸腾", "变油", "发光"], a: 0 },
{ q: "字母表中 A 的下一个字母是?", opts: ["B", "C", "D", "Z"], a: 0 },
];
// 本地题库:单题有效期与触发频率限制
const LOCAL_QUIZ_CHALLENGE_TTL_SECONDS = 60; // 单题 1 分钟有效期(KV 最小 60)
const LOCAL_QUIZ_CHALLENGE_VALID_MS = 60 * 1000; // 单题有效期(毫秒)
const LOCAL_QUIZ_TRIGGER_WINDOW_SECONDS = 300; // 5 分钟窗口
const LOCAL_QUIZ_TRIGGER_LIMIT = 3; // 5 分钟最多触发 3 次
const LOCAL_QUIZ_TRIGGER_KEY_PREFIX = "quiz_trig:"; // KV 记录:触发次数
// 触发频率限制:5分钟最多3次(写入频率很低,使用 KV 以跨 PoP 一致)
async function consumeLocalQuizTrigger(userId, env) {
const key = `${LOCAL_QUIZ_TRIGGER_KEY_PREFIX}${userId}`;
const now = Date.now();
const winMs = LOCAL_QUIZ_TRIGGER_WINDOW_SECONDS * 1000;
let arr = await kvGetJSON(env, key, [], {}); // 不使用 cacheTtl,避免边缘缓存导致误判
if (!Array.isArray(arr)) arr = [];
arr = arr
.map(x => Number(x))
.filter(x => Number.isFinite(x) && (now - x) < winMs);
if (arr.length >= LOCAL_QUIZ_TRIGGER_LIMIT) {
return { allowed: false, count: arr.length };
}
arr.push(now);
// TTL 取窗口期的两倍,防止边缘延迟与最小 TTL 影响
await kvPut(env, key, JSON.stringify(arr), { expirationTtl: Math.max(LOCAL_QUIZ_TRIGGER_WINDOW_SECONDS * 2, 600) });
return { allowed: true, count: arr.length };
}
// 读取全局验证模式(仅影响新会话)
async function getGlobalVerifyMode(env) {
const raw = await kvGetText(env, GLOBAL_VERIFY_MODE_KEY, CONFIG.KV_CRITICAL_CACHE_TTL);
const mode = (raw || "").toString().trim();
if (mode === "local_quiz") return "local_quiz";
if (mode === "turnstile") return hasTurnstileBinding(env) ? "turnstile" : "local_quiz";
// 未设置 / 非法值:回落默认值
return VERIFY_MODE_DEFAULT;
}
// 设置全局验证模式
async function setGlobalVerifyMode(env, mode) {
const m = (mode || "").toString().trim();
if (m !== "turnstile" && m !== "local_quiz") return false;
// turnstile 作为可选能力:未配置则拒绝切换
if (m === "turnstile" && !hasTurnstileBinding(env)) {
return false;
}
await kvPut(env, GLOBAL_VERIFY_MODE_KEY, m);
return true;
}
// 默认垃圾规则(可在 /settings 中编辑)
const DEFAULT_SPAM_RULES = {
version: 1,
max_links: 2, // 文本中链接数 >= max_links 判定为 spam;0 表示不启用
keywords: [
"加群", "进群", "推广", "广告", "返利", "博彩", "代投", "套利",
"USDT", "BTC", "ETH", "币圈", "空投", "交易所", "稳赚", "客服", "开户链接"
],
regexes: [
"\\b(?:usdt|btc|eth|trx|bnb)\\b",
"(?:t\\.me\\/\\w+|telegram\\.me\\/\\w+)",
"(?:免费|稳赚|日赚|高回报|带单|私聊我)"
],
allow_keywords: [],
allow_regexes: [],
ai: {
enabled: true,
model: "@cf/meta/llama-3.1-8b-instruct-fast",
// v1.6.1: AI 阈值默认更激进(更愿意拦截)
threshold: 0.65
}
};
function hasWorkersAIBinding(env) {
return !!(env && env.AI && typeof env.AI.run === "function");
}
async function getGlobalSpamFilterEnabled(env) {
const raw = await kvGetText(env, GLOBAL_SPAM_FILTER_ENABLED_KEY, CONFIG.KV_CRITICAL_CACHE_TTL);
if (raw === null || raw === undefined) return true; // 默认开启(v1.1.1b)
const v = String(raw).trim().toLowerCase();
if (v === "1" || v === "true" || v === "on") return true;
if (v === "0" || v === "false" || v === "off") return false;
return false;
}
async function setGlobalSpamFilterEnabled(env, enabled) {
await kvPut(env, GLOBAL_SPAM_FILTER_ENABLED_KEY, enabled ? "1" : "0");
}
function sanitizeStringArray(arr, maxLen = 50) {
if (!Array.isArray(arr)) return [];
const out = [];
for (const x of arr) {
if (typeof x !== "string") continue;
const s = x.trim();
if (!s) continue;
if (s.length > 256) continue;
out.push(s);
if (out.length >= maxLen) break;
}
return out;
}
// 数组工具:去重追加并限制长度(保留末尾 N 个)
function pushUniqueLimited(arr, value, limit) {
const out = Array.isArray(arr) ? arr.slice() : [];
if (value !== null && value !== undefined) {
if (!out.includes(value)) out.push(value);
}
const lim = Math.max(0, Math.floor(Number(limit || 0)));
if (lim > 0 && out.length > lim) return out.slice(-lim);
return out;
}
function sanitizeSpamRules(rules) {
const r = (rules && typeof rules === "object") ? rules : {};
const maxLinks = Math.floor(Number(r.max_links));
// v1.6.0: 统一使用新的默认阈值(0.65),避免旧 KV 配置残留导致阈值仍为 0.85
const AI_THRESHOLD = 0.65;
const safe = {
version: 1,
max_links: Number.isFinite(maxLinks) && maxLinks >= 0 && maxLinks <= 10 ? maxLinks : DEFAULT_SPAM_RULES.max_links,
keywords: sanitizeStringArray(r.keywords ?? DEFAULT_SPAM_RULES.keywords, 80),
regexes: sanitizeStringArray(r.regexes ?? DEFAULT_SPAM_RULES.regexes, 80),
allow_keywords: sanitizeStringArray(r.allow_keywords ?? DEFAULT_SPAM_RULES.allow_keywords, 80),
allow_regexes: sanitizeStringArray(r.allow_regexes ?? DEFAULT_SPAM_RULES.allow_regexes, 80),
ai: {
enabled: !!(r.ai && typeof r.ai === "object" ? r.ai.enabled : DEFAULT_SPAM_RULES.ai.enabled),
model: (r.ai && typeof r.ai === "object" && typeof r.ai.model === "string" && r.ai.model.trim())
? r.ai.model.trim()
: DEFAULT_SPAM_RULES.ai.model,
// v1.6.0: 统一阈值为 0.65(不再从 KV 读取旧值),避免升级后仍沿用 0.85
threshold: AI_THRESHOLD
}
};
return safe;
}
async function getGlobalSpamFilterRules(env) {
const raw = await kvGetText(env, GLOBAL_SPAM_FILTER_RULES_KEY, CONFIG.KV_CRITICAL_CACHE_TTL);
if (!raw) return sanitizeSpamRules(DEFAULT_SPAM_RULES);
try {
return sanitizeSpamRules(JSON.parse(raw));
} catch (_) {
return sanitizeSpamRules(DEFAULT_SPAM_RULES);
}
}
async function setGlobalSpamFilterRules(env, rulesObj) {
const safe = sanitizeSpamRules(rulesObj);
await kvPut(env, GLOBAL_SPAM_FILTER_RULES_KEY, JSON.stringify(safe));
return safe;
}
// --- 垃圾规则“提示词”编辑(v1.1.1b)---
// 说明:为了降低上手难度,管理员可在 /settings 里用“提示词”方式编辑规则。
// 我们把提示词解析成 JSON 规则并写入 GLOBAL_SPAM_FILTER_RULES_KEY,供规则引擎直接使用。
// 同时保存原始提示词到 GLOBAL_SPAM_FILTER_RULES_PROMPT_KEY 以便再次编辑。
async function getGlobalSpamFilterRulesPrompt(env) {
const raw = await kvGetText(env, GLOBAL_SPAM_FILTER_RULES_PROMPT_KEY, CONFIG.KV_CRITICAL_CACHE_TTL);
return raw ? String(raw) : "";
}
async function setGlobalSpamFilterRulesPrompt(env, promptText) {
const t = (promptText || "").trim();
await kvPut(env, GLOBAL_SPAM_FILTER_RULES_PROMPT_KEY, t);
return t;
}
function tokenizeLooseKeywords(line) {
if (!line) return [];
return String(line)
.split(/[,,、;;|\n\t]+/g)
.map(s => s.trim())
.filter(Boolean);
}
function parsePromptRegexToken(token) {
const t = String(token || "").trim();
if (!t) return null;
// 支持 /pattern/flags 形式
if (t.startsWith("/") && t.lastIndexOf("/") > 0) {
const last = t.lastIndexOf("/");
const body = t.slice(1, last);
const flags = t.slice(last + 1);
if (!body) return null;
try {
// 验证正则可编译(不实际使用返回对象,存字符串)
// eslint-disable-next-line no-new
new RegExp(body, flags);
return `/${body}/${flags}`;
} catch (_) {
return null;
}
}
// 允许直接写 pattern(默认 i)
try {
// eslint-disable-next-line no-new
new RegExp(t, "i");
return `/${t}/i`;
} catch (_) {
return null;
}
}
function mergeUnique(arr, add) {
const set = new Set((arr || []).filter(Boolean).map(x => String(x)));
for (const a of (add || [])) {
if (!a) continue;
set.add(String(a));
}
return Array.from(set);
}
/**
* 把“规则提示词”解析为 SpamRules JSON
* 支持的行格式(大小写不敏感):
* - max_links=2 / max_links:2
* - block: 兼职,加群,返利
* - allow: 你好,谢谢
* - block_re: /二维码|扫码/i
* - allow_re: /回执/i
* - 其他不带前缀的行:按关键词列表处理(用逗号/顿号分隔)
* - 写一行 “清空默认”/“CLEAR_DEFAULTS”:表示不使用默认规则(仅使用提示词解析出的规则)
*/
function promptToSpamRules(promptText, baseRules) {
const raw = (promptText || "").toString().replace(/\u200b/g, "").trim();
const lines = raw
? raw.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
: [];
// 仅当存在“单独一行”的清空指令时才不继承默认,避免说明文字里出现“清空默认”导致误判
const clearDefaults = lines.some(l => /^(清空默认|clear_defaults)$/i.test(String(l).trim()));
const useDefaults = !clearDefaults;
// v1.6.0: “清空默认”只清空本地规则(关键词/正则/链接数),不再误把 AI 一起关掉。
// 同时:AI 是否启用由是否存在 env.AI 绑定决定(见 classifySpamOptional / aiSpamVerdict),这里仅保留 model/threshold 配置。
const base = sanitizeSpamRules(baseRules || DEFAULT_SPAM_RULES);
let rules = sanitizeSpamRules(useDefaults ? base : {
version: 1,
max_links: 0,
keywords: [],
regexes: [],
allow_keywords: [],
allow_regexes: [],
ai: base.ai
});
if (!raw) return rules;
for (const line0 of lines) {
const line = String(line0 || "").trim();
if (!line) continue;
// 指令行:不参与规则内容(避免把“清空默认”本身当成关键词)
if (/^(清空默认|clear_defaults)$/i.test(line)) continue;
// 忽略注释/说明行(用户复制模板时常见),避免把说明文字当成关键词
if (line.startsWith("#") || /^[-•]\s*/.test(line)) continue;
// 额外忽略“xxx:”这种明显标题行
if ((/^(编辑说明|写法示例|当前规则提示词|说明)\s*[::]?/).test(line)) continue;
const low = line.toLowerCase();
// max_links
const ml = line.match(/max_links\s*[:=]\s*(\d+)/i);
if (ml) {
rules.max_links = Math.max(0, Math.min(20, parseInt(ml[1], 10)));
continue;
}
// allow keywords
if (low.startsWith("allow:") || low.startsWith("允许:") || low.startsWith("放行:")) {
const rest = line.split(/[::]/).slice(1).join(":").trim();
rules.allow_keywords = mergeUnique(rules.allow_keywords, tokenizeLooseKeywords(rest));
continue;
}
// block keywords
if (low.startsWith("block:") || low.startsWith("spam:") || low.startsWith("垃圾:") || low.startsWith("屏蔽:")) {
const rest = line.split(/[::]/).slice(1).join(":").trim();
rules.keywords = mergeUnique(rules.keywords, tokenizeLooseKeywords(rest));
continue;
}
// allow regex
if (low.startsWith("allow_re:") || low.startsWith("allow_regex:") || low.startsWith("允许正则:")) {
const rest = line.split(/[::]/).slice(1).join(":").trim();
const tokens = tokenizeLooseKeywords(rest);
const regs = tokens.map(parsePromptRegexToken).filter(Boolean);
rules.allow_regexes = mergeUnique(rules.allow_regexes, regs);
continue;
}
// block regex
if (low.startsWith("block_re:") || low.startsWith("block_regex:") || low.startsWith("正则:") || low.startsWith("垃圾正则:")) {
const rest = line.split(/[::]/).slice(1).join(":").trim();
const tokens = tokenizeLooseKeywords(rest);
const regs = tokens.map(parsePromptRegexToken).filter(Boolean);
rules.regexes = mergeUnique(rules.regexes, regs);
continue;
}
// 裸行:按关键词处理(可用逗号/顿号分隔)
rules.keywords = mergeUnique(rules.keywords, tokenizeLooseKeywords(line));
}
return sanitizeSpamRules(rules);
}
function rulesToFriendlyPrompt(rules) {
const r = sanitizeSpamRules(rules || DEFAULT_SPAM_RULES);
const lines = [];
lines.push(`📝 当前规则内容:`);
lines.push(``);
lines.push(`max_links=${r.max_links}`);
if ((r.keywords || []).length) lines.push(`block: ${(r.keywords || []).slice(0, 30).join("、")}`);
if ((r.allow_keywords || []).length) lines.push(`allow: ${(r.allow_keywords || []).slice(0, 30).join("、")}`);
if ((r.regexes || []).length) lines.push(`block_re: ${(r.regexes || []).slice(0, 10).join(", ")}`);
if ((r.allow_regexes || []).length) lines.push(`allow_re: ${(r.allow_regexes || []).slice(0, 10).join(", ")}`);
lines.push(``);
return lines.join("\n");
}
function extractTextFromTelegramMessage(msg) {
const text = (msg && (msg.text || msg.caption)) ? String(msg.text || msg.caption) : "";
return text.trim();
}
function countUrls(text) {
if (!text) return 0;
const m = text.match(/https?:\/\/\S+|t\.me\/\S+|telegram\.me\/\S+/gi);
return m ? m.length : 0;
}
function safeRegexTest(patternOrToken, text) {
try {
const s = String(patternOrToken ?? "");
let body = s;
let flags = "i";
// 支持管理员面板配置的 /body/flags 形式(例如 /hello/i)
const m = s.match(/^\/(.+)\/([a-z]*)$/i);
if (m) {
body = m[1];
flags = m[2] || "";
}
// 默认不区分大小写;并避免重复追加 i
if (!flags.includes("i")) flags += "i";
const re = new RegExp(body, flags);
return re.test(String(text ?? ""));
} catch (_) {
return false;
}
}
function ruleBasedSpamVerdict(text, rules) {
const t = (text || "").trim();
if (!t) return { is_spam: false, score: 0.0, reason: "empty" };
// allowlist 先过:一旦命中 allow,就直接放行
for (const kw of (rules.allow_keywords || [])) {
if (kw && t.toLowerCase().includes(kw.toLowerCase())) {
return { is_spam: false, score: 0.0, reason: `allow_keyword:${kw}` };
}
}
for (const pat of (rules.allow_regexes || [])) {
if (pat && safeRegexTest(pat, t)) {
return { is_spam: false, score: 0.0, reason: `allow_regex:${pat}` };
}
}
const urlCount = countUrls(t);
if (rules.max_links > 0 && urlCount >= rules.max_links) {
return { is_spam: true, score: 0.9, reason: `rule:max_links:${urlCount}` };
}
for (const kw of (rules.keywords || [])) {
if (kw && t.toLowerCase().includes(kw.toLowerCase())) {
return { is_spam: true, score: 0.7, reason: `rule:keyword:${kw}` };
}
}
for (const pat of (rules.regexes || [])) {
if (pat && safeRegexTest(pat, t)) {
return { is_spam: true, score: 0.75, reason: `rule:regex:${pat}` };
}
}
return { is_spam: false, score: 0.0, reason: "rule:no_match" };
}
async function aiSpamVerdict(env, text, rules) {
// v1.6.0: AI 是否启用只取决于是否绑定了 Workers AI(env.AI.run 可用),不再受 rules.ai.enabled 影响
if (!hasWorkersAIBinding(env)) return null;
const t = String(text || "").trim();
if (!t) return null;
const aiCfg = (rules && rules.ai && typeof rules.ai === "object") ? rules.ai : DEFAULT_SPAM_RULES.ai;
const model = (aiCfg && typeof aiCfg.model === "string" && aiCfg.model.trim()) ? aiCfg.model.trim() : DEFAULT_SPAM_RULES.ai.model;
// JSON Mode 可能无法稳定满足严格 schema(缺字段、格式错误、直接抛错),所以:
// 1) schema 只强制 is_spam,其他字段给默认值
// 2) json_schema 失败后回退 json_object,再失败则尝试从文本里提取 JSON
const schema = {
type: "object",
additionalProperties: true,
properties: {
is_spam: { type: "boolean" },
confidence: { type: "number", minimum: 0, maximum: 1 },
category: { type: "string" },
signals: { type: "array", items: { type: "string" }, maxItems: 8 }
},
required: ["is_spam"]
};
const systemPrompt =
"你是垃圾消息分类器。判断文本是否为垃圾消息(广告/引流/诈骗/推广/刷单/兼职/币圈/USDT 等)。" +
"必须只输出 JSON 对象,至少包含键 is_spam(boolean)。可选键:confidence(0-1), category(string), signals(string[]).";
const userPayload = { text: t.slice(0, 2000) };
function normalizeVerdict(obj) {
if (!obj || typeof obj !== "object") return null;
const isSpam = (typeof obj.is_spam === "boolean") ? obj.is_spam : null;
if (isSpam === null) return null;
const conf = Number(obj.confidence);
// 若模型未给出 confidence:根据 is_spam 给一个保守但可用的默认值,避免“只返回 is_spam 导致永远过不了阈值”
const confidence = (Number.isFinite(conf) && conf >= 0 && conf <= 1) ? conf : (isSpam ? 0.75 : 0.25);
const category = (typeof obj.category === "string" && obj.category.trim()) ? obj.category.trim() : "unknown";
const signals = Array.isArray(obj.signals) ? obj.signals.filter(x => typeof x === "string").slice(0, 8) : [];
return {
is_spam: !!isSpam,
score: confidence,
reason: `ai:${category}`,
signals
};
}
function tryParseJsonFromText(s) {
const str = String(s || "").trim();
if (!str) return null;
// 去掉 ```json ... ``` 包裹
const cleaned = str
.replace(/^```(?:json)?\s*/i, "")
.replace(/```\s*$/i, "")
.trim();
try {
return JSON.parse(cleaned);
} catch (_) {
// 尝试截取第一个 { ... }
const m = cleaned.match(/\{[\s\S]*\}/);
if (!m) return null;
try {
return JSON.parse(m[0]);
} catch (_) {
return null;
}
}
}
// 1) 首选:json_schema
try {
const out = await env.AI.run(model, {
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: JSON.stringify(userPayload) }
],
response_format: { type: "json_schema", json_schema: schema }
});
const r = out && out.response ? out.response : null;
const parsed = (typeof r === "string") ? tryParseJsonFromText(r) : r;
const verdict = normalizeVerdict(parsed);
if (verdict) return verdict;
} catch (e) {
try {
console.warn("[spam-ai] json_schema failed; fallback to json_object", String(e && (e.message || e)));
} catch (_) {}
}
// 2) 回退:json_object
try {
const out2 = await env.AI.run(model, {
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: JSON.stringify(userPayload) }
],
response_format: { type: "json_object" }
});
const r2 = out2 && out2.response ? out2.response : null;
const parsed2 = (typeof r2 === "string") ? tryParseJsonFromText(r2) : r2;
const verdict2 = normalizeVerdict(parsed2);
if (verdict2) return verdict2;
} catch (e2) {
try {
console.warn("[spam-ai] json_object failed; fallback to free-form parse", String(e2 && (e2.message || e2)));
} catch (_) {}
}
// 3) 最后兜底:不指定 response_format(模型可能输出自然语言,尽量提取 JSON)
try {
const out3 = await env.AI.run(model, {
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: JSON.stringify(userPayload) }
]
});
const r3 = out3 && out3.response ? out3.response : null;
const parsed3 = (typeof r3 === "string") ? tryParseJsonFromText(r3) : r3;
const verdict3 = normalizeVerdict(parsed3);
if (verdict3) return verdict3;
} catch (_) {
// 静默失败:最终回落为 null(放行)
}
return null;
}
async function classifySpamOptional(env, msg) {
const enabled = await getGlobalSpamFilterEnabled(env);
if (!enabled) return { is_spam: false, score: 0.0, reason: "spam_filter_disabled", ai_used: false };
const rules = await getGlobalSpamFilterRules(env);
const text = extractTextFromTelegramMessage(msg);
const ruleVerdict = ruleBasedSpamVerdict(text, rules);
if (ruleVerdict.is_spam) {
return { ...ruleVerdict, ai_used: false };
}
const ai = await aiSpamVerdict(env, text, rules);
if (ai) {
// v1.6.0: 阈值统一为 0.65(sanitizeSpamRules 已固定),这里继续沿用 rules.ai.threshold 以保持一致
const isSpam = ai.is_spam && ai.score >= (rules && rules.ai ? rules.ai.threshold : DEFAULT_SPAM_RULES.ai.threshold);
return { is_spam: !!isSpam, score: ai.score, reason: ai.reason, ai_used: true };
}
return { is_spam: false, score: 0.0, reason: "rule:no_match", ai_used: false };
}
async function notifyUserSpamDropped(env, userId) {
try {
await tgCall(env, "sendMessage", {
chat_id: userId,
text: "🗑️ 您刚发送的消息被系统识别为垃圾信息,已被拦截丢弃,您可联系管理员将您加入白名单即可绕过拦截。"
});
} catch (_) {}
}
async function getOrCreateUserTopicRecByUserId(env, userId) {
const userKey = `user:${userId}`;
let rec = await kvGetJSON(env, userKey, null);
if (rec && rec.thread_id) {
const probe = await probeForumThread(env, rec.thread_id, { userId, reason: "user_topic_probe" });
if (probe && probe.status === "ok") return rec;
}
// 取用户信息用于标题
let userInfo = null;
try {
const chatRes = await tgCall(env, "getChat", { chat_id: userId });
if (chatRes.ok && chatRes.result) userInfo = chatRes.result;
} catch (_) {}
const title = buildTopicTitle(userInfo || { id: userId });
const topicRes = await tgCall(env, "createForumTopic", {
chat_id: env.SUPERGROUP_ID,
name: title
});
if (!topicRes || !topicRes.ok || !topicRes.result) {
throw new Error(`createForumTopic failed: ${topicRes?.description || "unknown"}`);
}
rec = {
thread_id: topicRes.result.message_thread_id,
title
};
await kvPut(env, userKey, JSON.stringify(rec));
await kvPut(env, `thread:${rec.thread_id}`, String(userId));
return rec;
}
// 统一发起“人机验证”(根据:已有会话 provider > 全局模式)
async function sendHumanVerification(userId, env, pendingMsgId = null, origin = null, isStartCommand = false) {
const sessionKey = `verify_session:${userId}`;
// 仅用于判定“是否已有会话/使用哪个 provider”,不在此处读全局开关,避免引入额外状态。
// 若已有 verify_session,则按会话内 provider 继续完成(切换不影响正在验证的用户)。
let sessionData = await kvGetJSON(env, sessionKey, null, { cacheTtl: CONFIG.KV_CRITICAL_CACHE_TTL });
// 若存在会话但缺 provider(旧版本迁移),默认按 Turnstile 继续,确保不影响正在验证的用户
let provider = sessionData && sessionData.provider ? String(sessionData.provider) : null;
if (!provider && sessionData) {
provider = VERIFY_MODE_DEFAULT;
try {
sessionData.provider = provider;
await kvPut(env, sessionKey, JSON.stringify(sessionData), { expirationTtl: CONFIG.VERIFY_EXPIRE_SECONDS });
} catch (_) {}
}
if (!provider) {
provider = await getGlobalVerifyMode(env);
}
// turnstile 作为可选能力:未配置则自动回落到本地题库
if (provider === "turnstile" && !hasTurnstileBinding(env)) {
provider = "local_quiz";
}
if (provider === "local_quiz") {
return await sendLocalQuizVerification(userId, env, pendingMsgId, isStartCommand);
}
// turnstile
let workerOrigin = origin;
if (!workerOrigin) {
workerOrigin = await getWorkerOrigin(env);
}
if (!workerOrigin) {
Logger.error('sendHumanVerification_no_origin_for_turnstile', { userId });
await tgCall(env, "sendMessage", { chat_id: userId, text: ERROR_MESSAGES.worker_origin_error });
return;
}
return await sendTurnstileVerification(userId, env, pendingMsgId, workerOrigin, isStartCommand);
}
// 发送本地题库验证(纯 Telegram 内联按钮)
// 规则:单题 1 分钟有效;超时后用户再次发消息或 /start 才触发下一题;5 分钟内最多触发 3 次
async function sendLocalQuizVerification(userId, env, pendingMsgId = null, isStartCommand = false, opts = null) {
const forceNewQuestion = !!(opts && opts.forceNewQuestion);
let enableStorage;
const sessionKey = `verify_session:${userId}`;
// 不使用 cacheTtl,避免边缘缓存导致 pending_ids 丢失/读到旧值
let sessionData = await kvGetJSON(env, sessionKey, null, {});
enableStorage = true;
// 若已有会话但 provider 不是 local_quiz,则保持原会话完成(不受全局切换影响)
if (sessionData && sessionData.provider && sessionData.provider !== "local_quiz") {
const origin = await getWorkerOrigin(env);
if (origin) return await sendTurnstileVerification(userId, env, pendingMsgId, origin, isStartCommand);
}
const now = Date.now();
// 已存在本地题库会话:单题 1 分钟内不重复发题,只做消息暂存 + 提示一次
const existingVerifyId = sessionData?.quiz?.verifyId;
const issuedAt = Number(sessionData?.quiz?.issuedAt || 0);
const hasActiveQuestion = !!(existingVerifyId && Number.isFinite(issuedAt) && (now - issuedAt) < LOCAL_QUIZ_CHALLENGE_VALID_MS);
if (!forceNewQuestion && sessionData && sessionData.provider === "local_quiz" && hasActiveQuestion) {
if (enableStorage && pendingMsgId) {
sessionData.pending_ids = pushUniqueLimited(sessionData.pending_ids, pendingMsgId, CONFIG.PENDING_MAX_MESSAGES);
let shouldSendNotice = false;
if (!sessionData.hasSentStorageNotice) {
sessionData.hasSentStorageNotice = true;
shouldSendNotice = true;
}
await kvPut(env, sessionKey, JSON.stringify(sessionData), { expirationTtl: CONFIG.VERIFY_EXPIRE_SECONDS });
if (shouldSendNotice) {
await tgCall(env, "sendMessage", { chat_id: userId, text: USER_NOTIFICATIONS.first_message_stored });
}
return;
}
// 避免刷屏:同一题目有效期内最多提示一次
const noticeKey = `quiz_notice_sent:${userId}`;
const noticeSent = await cacheGetText(noticeKey);
if (!noticeSent) {
await tgCall(env, "sendMessage", { chat_id: userId, text: "⏳ 题目已发送,请在 1 分钟内作答。" });
await cachePutText(noticeKey, "1", 60);
}
return;
}
// 需要发新题(首次或上一题超时)
// 5分钟内最多触发 3 次;超过则提示频繁
const trig = await consumeLocalQuizTrigger(userId, env);
if (!trig.allowed) {
await tgCall(env, "sendMessage", { chat_id: userId, text: ERROR_MESSAGES.rate_limit });
return;
}
// 清理旧题(best-effort)
if (existingVerifyId) {
try { await kvDelete(env, `quiz_chal:${existingVerifyId}`); } catch (_) {}
}
if (!sessionData) {
sessionData = {
userId,
pending_ids: [],
timestamp: now,
sessionId: secureRandomId(16),
verificationSent: true,
enableStorage,
provider: "local_quiz",
quiz: {}
};
} else {
sessionData.verificationSent = true;
sessionData.enableStorage = enableStorage;
sessionData.provider = "local_quiz";
if (!sessionData.quiz) sessionData.quiz = {};
if (!Array.isArray(sessionData.pending_ids)) sessionData.pending_ids = [];
}
// 将触发验证的消息加入 pending_ids(KV 持久)
if (pendingMsgId && enableStorage) {
sessionData.pending_ids = pushUniqueLimited(sessionData.pending_ids, pendingMsgId, CONFIG.PENDING_MAX_MESSAGES);
}
const verifyId = secureRandomId(10);
sessionData.quiz.verifyId = verifyId;
sessionData.quiz.issuedAt = now;
await kvPut(env, sessionKey, JSON.stringify(sessionData), { expirationTtl: CONFIG.VERIFY_EXPIRE_SECONDS });
await kvPut(env, `pending_verify:${userId}`, "1", { expirationTtl: CONFIG.VERIFY_EXPIRE_SECONDS });
// 随机出题
const item = LOCAL_QUIZ_QUESTIONS[Math.floor(Math.random() * LOCAL_QUIZ_QUESTIONS.length)];
const chalKey = `quiz_chal:${verifyId}`;
const chal = {
userId,
q: item.q,
opts: item.opts,
a: item.a,
createdAt: now
};
await kvPut(env, chalKey, JSON.stringify(chal), { expirationTtl: LOCAL_QUIZ_CHALLENGE_TTL_SECONDS });
const keyboard = [];
for (let i = 0; i < item.opts.length; i += 2) {
const row = [{
text: item.opts[i],
callback_data: `vq|${verifyId}|${i}`
}];
if (i + 1 < item.opts.length) {
row.push({
text: item.opts[i + 1],
callback_data: `vq|${verifyId}|${i + 1}`
});
}
keyboard.push(row);
}
const intro = isStartCommand
? "🤖 请先完成一次人机验证。"
: "🤖 需要验证后才能继续,请回答下面的问题:";
// 去掉 Markdown 符号,避免出现多余的 **
await tgCall(env, "sendMessage", {
chat_id: userId,
text: `${intro}
📝 题目:${item.q}
请选择一个答案:`,
reply_markup: { inline_keyboard: keyboard }
});
}
// 处理本地题库回调(vq|verifyId|idx)
async function handleLocalQuizCallback(callbackQuery, env, ctx) {
const data = (callbackQuery && callbackQuery.data) ? String(callbackQuery.data) : "";
const userId = callbackQuery?.from?.id;
if (!userId) return;
// 立即 ACK
try {
const ack = tgCall(env, "answerCallbackQuery", { callback_query_id: callbackQuery.id });
if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(ack);
else await ack;
} catch (_) {}
const parts = data.split("|");
if (parts.length < 3) return;
const verifyId = parts[1];
const idx = parseInt(parts[2], 10);
if (!verifyId || !Number.isFinite(idx)) return;
// 幂等:若已验证,直接提示并移除按钮
const verifiedKey = `verified:${userId}`;
const alreadyVerified = await kvGetText(env, verifiedKey, CONFIG.KV_CRITICAL_CACHE_TTL);
if (alreadyVerified) {
try { await tgCall(env, "sendMessage", { chat_id: userId, text: USER_NOTIFICATIONS.verified_success }); } catch (_) {}
try {
if (callbackQuery.message) {
const chatId = callbackQuery.message.chat?.id;
const messageId = callbackQuery.message.message_id;
if (chatId && messageId) {
const p = tgCall(env, "editMessageReplyMarkup", { chat_id: chatId, message_id: messageId, reply_markup: { inline_keyboard: [] } });
if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(p); else await p;
}
}
} catch (_) {}
return;
}
const chalKey = `quiz_chal:${verifyId}`;
const chal = await kvGetJSON(env, chalKey, null, {});
if (!chal || chal.userId !== userId || !Array.isArray(chal.opts) || typeof chal.a !== "number") {
try { await tgCall(env, "sendMessage", { chat_id: userId, text: "⏳ 题目已过期,请重新验证。" }); } catch (_) {}
return;
}
// 单题 1 分钟有效期:即便 KV 还没过期,也按 createdAt 强制判过期
if (chal.createdAt && (Date.now() - Number(chal.createdAt) > LOCAL_QUIZ_CHALLENGE_VALID_MS)) {
try { await tgCall(env, "sendMessage", { chat_id: userId, text: "⏳ 题目已过期,请重新验证。" }); } catch (_) {}
return;
}
// 读会话,确保 provider 绑定(切换不影响正在验证的人)
const sessionKey = `verify_session:${userId}`;
const sessionData = await kvGetJSON(env, sessionKey, null, {});
// 每题仅 1 次作答机会:答错一次就换题
const correct = (idx === chal.a);
if (!correct) {
// 失效当前题目,避免重复点击
await kvDelete(env, chalKey);
// 移除按钮(best-effort)
try {
if (callbackQuery.message) {
const chatId = callbackQuery.message.chat?.id;
const messageId = callbackQuery.message.message_id;
if (chatId && messageId) {
const p = tgCall(env, "editMessageReplyMarkup", { chat_id: chatId, message_id: messageId, reply_markup: { inline_keyboard: [] } });
if (ctx && typeof ctx.waitUntil === "function") ctx.waitUntil(p); else await p;
}
}
} catch (_) {}
await tgCall(env, "sendMessage", { chat_id: userId, text: "❌ 答案不正确,已为您更换题目。" });
// 立即下发新题(强制跳过 1 分钟内不重复发题的逻辑)
await sendLocalQuizVerification(userId, env, null, false, { forceNewQuestion: true });
return;
}
// 正确:写 verified + grace,并清理会话/挑战
const verifiedTtl = getVerifiedTtlSeconds(env);
if (verifiedTtl > 0) await kvPut(env, verifiedKey, "1", { expirationTtl: verifiedTtl });
else await kvPut(env, verifiedKey, "1");