@nestim/koishi-plugin-qq-group-manager 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/lib/index.js +3 -2
- package/lib/service.js +650 -104
- package/package.json +1 -1
- package/readme.md +49 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Miseat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/lib/index.js
CHANGED
|
@@ -23,7 +23,8 @@ exports.Config = koishi_1.Schema.intersect([
|
|
|
23
23
|
dryRun: koishi_1.Schema.boolean().default(false).description('演练模式:仅输出计划动作,不执行群管理操作。'),
|
|
24
24
|
}).description('基础设置'),
|
|
25
25
|
koishi_1.Schema.object({
|
|
26
|
-
bannedWords: koishi_1.Schema.array(String).role('table').default([]).description('
|
|
26
|
+
bannedWords: koishi_1.Schema.array(String).role('table').default([]).description('违禁词列表。支持顺序模糊匹配(如“领红包”可命中“领xxx元红包”)。可用“关键词|分值”自定义权重,分值越高越容易单独触发。'),
|
|
27
|
+
bannedWordScoreThreshold: koishi_1.Schema.natural().min(1).max(100).default(70).description('违禁词评分触发阈值:整条消息累计得分达到该值才触发管控(默认 70)。'),
|
|
27
28
|
blockCardMessage: koishi_1.Schema.boolean().default(true).description('是否禁止卡片消息(json/xml)。'),
|
|
28
29
|
blockForwardMessage: koishi_1.Schema.boolean().default(true).description('是否禁止合并转发消息(forward)。'),
|
|
29
30
|
autoDeleteViolation: koishi_1.Schema.boolean().default(true).description('违规消息是否自动撤回。'),
|
|
@@ -31,7 +32,7 @@ exports.Config = koishi_1.Schema.intersect([
|
|
|
31
32
|
groupRules: koishi_1.Schema.array(koishi_1.Schema.object({
|
|
32
33
|
guildId: koishi_1.Schema.string().required().description('群号。'),
|
|
33
34
|
enableAiReply: koishi_1.Schema.boolean().description('该群是否启用 AI 回复(留空则跟随全局设置)。'),
|
|
34
|
-
bannedWords: koishi_1.Schema.array(String).role('table').default([]).description('
|
|
35
|
+
bannedWords: koishi_1.Schema.array(String).role('table').default([]).description('该群专属违禁词列表(同样支持“关键词|分值”与顺序模糊匹配)。'),
|
|
35
36
|
blockCardMessage: koishi_1.Schema.boolean().default(true).description('该群是否禁止卡片消息(json/xml)。'),
|
|
36
37
|
blockForwardMessage: koishi_1.Schema.boolean().default(true).description('该群是否禁止合并转发消息(forward)。'),
|
|
37
38
|
autoDeleteViolation: koishi_1.Schema.boolean().default(true).description('该群违规消息是否自动撤回。'),
|
package/lib/service.js
CHANGED
|
@@ -29,6 +29,7 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
29
29
|
pendingJoinRequests = new Map();
|
|
30
30
|
pendingJoinRequestFlags = new Map();
|
|
31
31
|
violationRecords = new Map();
|
|
32
|
+
_imageDataUrlCache = new Map();
|
|
32
33
|
constructor(ctx, config) {
|
|
33
34
|
super(ctx, 'nestimQqGroupManager', true);
|
|
34
35
|
this.ctx = ctx;
|
|
@@ -181,7 +182,11 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
181
182
|
}
|
|
182
183
|
getMessageText(session) {
|
|
183
184
|
const raw = session?.content ?? '';
|
|
184
|
-
return raw
|
|
185
|
+
return raw
|
|
186
|
+
.replace(/\[CQ:[^\]]+\]/g, ' ')
|
|
187
|
+
.replace(/<[^>]+>/g, ' ')
|
|
188
|
+
.replace(/\s+/g, ' ')
|
|
189
|
+
.trim();
|
|
185
190
|
}
|
|
186
191
|
aiGroupKey(session) {
|
|
187
192
|
if (!session?.guildId)
|
|
@@ -582,20 +587,28 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
582
587
|
return { reply, score, reason: 'parse-fallback' };
|
|
583
588
|
}
|
|
584
589
|
}
|
|
585
|
-
buildInterestContextPrompt(groupKey, currentText, window = 8) {
|
|
590
|
+
buildInterestContextPrompt(groupKey, currentText, window = 8, extras = {}) {
|
|
586
591
|
const size = Math.max(4, Math.min(16, window));
|
|
587
592
|
const records = (this.aiBuffers.get(groupKey) ?? []).slice(-size);
|
|
588
593
|
const lines = records.map((item, idx) => {
|
|
589
|
-
const imageHint = item.imageUrls
|
|
594
|
+
const imageHint = item.imageUrls?.length ? ` [图片:${item.imageUrls.length}张]` : '';
|
|
590
595
|
const content = item.text?.trim() || '[图片消息]';
|
|
591
596
|
return `${idx + 1}. ${item.name}(${item.userId}): ${content}${imageHint}`;
|
|
592
597
|
});
|
|
593
598
|
const topicHint = (this.aiTopicMemory.get(groupKey)?.text || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
599
|
+
const currentHint = extras.hasSticker
|
|
600
|
+
? '(当前消息是表情包/贴纸,没有文字。表情包是常见的互动方式,可以顺势接梗或回应其情绪,不要因为"没有文字"就直接判定为无价值。)'
|
|
601
|
+
: (extras.hasImage
|
|
602
|
+
? '(当前消息含图片,没有文字。图片内容是重要的可回应素材,可以描述或调侃画面内容。)'
|
|
603
|
+
: '');
|
|
594
604
|
const prompt = [
|
|
595
605
|
`当前消息:${currentText || '[空文本]'}`,
|
|
606
|
+
currentHint,
|
|
596
607
|
topicHint ? `当前群话题参考:${topicHint}` : '',
|
|
597
608
|
`最近上下文(最多${size}条):`,
|
|
598
609
|
...(lines.length ? lines : ['(无历史上下文)']),
|
|
610
|
+
'判定要点:表情包/图片本身就是群聊中正常的互动内容,尤其是当对方在回应你刚才的发言时,应当积极接话。',
|
|
611
|
+
'只有在明显与你无关、且属于无意义刷屏时才选择不回复。不要仅因为「没有文字」就压低评分。',
|
|
599
612
|
].filter(Boolean).join('\n');
|
|
600
613
|
return { prompt, ctxSize: lines.length, topicHint };
|
|
601
614
|
}
|
|
@@ -619,7 +632,9 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
619
632
|
body: JSON.stringify({
|
|
620
633
|
model,
|
|
621
634
|
temperature: 0.15,
|
|
622
|
-
|
|
635
|
+
// deepseek-flash 等带思考的模型会先消耗 reasoning tokens,
|
|
636
|
+
// 额度太小会导致 content 为空(finish_reason=length),判定直接失效
|
|
637
|
+
max_tokens: 512,
|
|
623
638
|
messages: [
|
|
624
639
|
{
|
|
625
640
|
role: 'system',
|
|
@@ -627,21 +642,33 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
627
642
|
},
|
|
628
643
|
{
|
|
629
644
|
role: 'user',
|
|
630
|
-
content:
|
|
645
|
+
content: `请判断是否值得回复这条消息。表情包/图片是群聊中正常的互动内容,不要因为「没有文字」就判定为无价值;仅在与本群无关且明显无意义刷屏时才不回复。以下是消息与上下文:\n${contextPrompt}`,
|
|
631
646
|
},
|
|
632
647
|
],
|
|
633
648
|
}),
|
|
634
649
|
signal: controller.signal,
|
|
635
650
|
});
|
|
636
651
|
const payload = await response.json().catch(() => ({}));
|
|
637
|
-
const
|
|
652
|
+
const choice = payload?.choices?.[0];
|
|
653
|
+
const content = choice?.message?.content;
|
|
638
654
|
if (!response.ok)
|
|
639
655
|
return { reply: false, score: 0, reason: `http-${response.status}` };
|
|
640
|
-
|
|
656
|
+
let plain = typeof content === 'string'
|
|
641
657
|
? content
|
|
642
658
|
: Array.isArray(content)
|
|
643
659
|
? content.map((item) => item?.text || '').join('')
|
|
644
660
|
: '';
|
|
661
|
+
// 兜底:若仍为空(思考模型额度被截断),从 reasoning_content 里捞 JSON
|
|
662
|
+
if (!plain.trim()) {
|
|
663
|
+
const reasoning = String(choice?.message?.reasoning_content || '');
|
|
664
|
+
const jsonLike = reasoning.match(/\{[\s\S]*\}/);
|
|
665
|
+
if (jsonLike)
|
|
666
|
+
plain = jsonLike[0];
|
|
667
|
+
if (!plain.trim()) {
|
|
668
|
+
this._logger.warn(`[ai-interest] 判定返回空内容 finish=${choice?.finish_reason} reasoningTokens=${payload?.usage?.completion_tokens_details?.reasoning_tokens} | ${this.sessionTag?.('') ?? ''}`);
|
|
669
|
+
return { reply: false, score: 0, reason: `empty(finish=${choice?.finish_reason || 'unknown'})` };
|
|
670
|
+
}
|
|
671
|
+
}
|
|
645
672
|
return this.parseInterestDecision(plain);
|
|
646
673
|
}
|
|
647
674
|
catch {
|
|
@@ -671,10 +698,11 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
671
698
|
systemInstruction: {
|
|
672
699
|
parts: [{ text: '你是群聊回复开关决策器。只输出 JSON: {"reply":boolean,"score":0-100,"reason":"..."}。除 JSON 外不输出任何文字。' }],
|
|
673
700
|
},
|
|
674
|
-
contents: [{ role: 'user', parts: [{ text:
|
|
701
|
+
contents: [{ role: 'user', parts: [{ text: `请判断是否值得回复这条消息。表情包/图片是群聊中正常的互动内容,不要因为「没有文字」就判定为无价值;仅在与本群无关且明显无意义刷屏时才不回复。以下是消息与上下文:\n${contextPrompt}` }] }],
|
|
675
702
|
generationConfig: {
|
|
676
703
|
temperature: 0.15,
|
|
677
|
-
|
|
704
|
+
// 带思考的模型会先消耗思考 token,额度太小会导致正文为空
|
|
705
|
+
maxOutputTokens: 512,
|
|
678
706
|
},
|
|
679
707
|
}),
|
|
680
708
|
signal: controller.signal,
|
|
@@ -695,7 +723,10 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
695
723
|
}
|
|
696
724
|
}
|
|
697
725
|
async shouldReplyByInterest(params) {
|
|
698
|
-
const context = this.buildInterestContextPrompt(params.groupKey, params.text, this.config.aiInterestContextWindow || 8
|
|
726
|
+
const context = this.buildInterestContextPrompt(params.groupKey, params.text, this.config.aiInterestContextWindow || 8, {
|
|
727
|
+
hasSticker: !!params.hasSticker,
|
|
728
|
+
hasImage: !!params.hasImage,
|
|
729
|
+
});
|
|
699
730
|
const decision = this.config.aiProvider === 'gemini'
|
|
700
731
|
? await this.decideInterestByGemini(context.prompt)
|
|
701
732
|
: await this.decideInterestByOpenAI(context.prompt);
|
|
@@ -995,82 +1026,396 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
995
1026
|
return [];
|
|
996
1027
|
return this.extractMessageImageUrls(session, maxCount);
|
|
997
1028
|
}
|
|
998
|
-
|
|
1029
|
+
isImageReference(value) {
|
|
999
1030
|
if (!value || typeof value !== 'string')
|
|
1000
1031
|
return false;
|
|
1001
1032
|
const v = value.trim();
|
|
1033
|
+
if (!v)
|
|
1034
|
+
return false;
|
|
1002
1035
|
if (/^https?:\/\//i.test(v))
|
|
1003
1036
|
return true;
|
|
1004
1037
|
if (/^data:image\//i.test(v))
|
|
1005
1038
|
return true;
|
|
1039
|
+
if (/^base64:\/\//i.test(v))
|
|
1040
|
+
return true;
|
|
1041
|
+
if (/^file:\/\//i.test(v))
|
|
1042
|
+
return true;
|
|
1043
|
+
if (/^[A-Za-z]:[\\/]/.test(v) || v.startsWith('/'))
|
|
1044
|
+
return true;
|
|
1045
|
+
// NapCat 常见:纯文件名 / hash.jpg
|
|
1046
|
+
if (/^[0-9A-Fa-f]{16,}\.[A-Za-z0-9]{2,5}$/.test(v))
|
|
1047
|
+
return true;
|
|
1006
1048
|
return false;
|
|
1007
1049
|
}
|
|
1008
|
-
|
|
1009
|
-
const
|
|
1010
|
-
const
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1050
|
+
extractImageCandidates(session, maxCount = 6) {
|
|
1051
|
+
const out = [];
|
|
1052
|
+
for (const item of this.extractImageElements(session)) {
|
|
1053
|
+
for (const candidate of [item.url, item.ref]) {
|
|
1054
|
+
if (!this.isImageReference(candidate))
|
|
1055
|
+
continue;
|
|
1056
|
+
if (!out.includes(candidate))
|
|
1057
|
+
out.push(candidate);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
return out.slice(0, Math.max(1, maxCount));
|
|
1061
|
+
}
|
|
1062
|
+
collectReplyAwareImageCandidates(session, maxCount = 2) {
|
|
1063
|
+
const own = this.extractImageCandidates(session, maxCount);
|
|
1064
|
+
const quotedSession = session?.quote;
|
|
1065
|
+
const quoted = quotedSession ? this.extractImageCandidates(quotedSession, maxCount) : [];
|
|
1066
|
+
const merged = [];
|
|
1067
|
+
for (const item of [...own, ...quoted]) {
|
|
1068
|
+
if (!merged.includes(item))
|
|
1069
|
+
merged.push(item);
|
|
1070
|
+
}
|
|
1071
|
+
return { own, quoted, merged: merged.slice(0, Math.max(1, maxCount)) };
|
|
1072
|
+
}
|
|
1073
|
+
detectImageMime(buffer, fallback = 'image/jpeg') {
|
|
1074
|
+
if (!buffer || buffer.length < 12)
|
|
1075
|
+
return fallback;
|
|
1076
|
+
const b = buffer;
|
|
1077
|
+
if (b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF)
|
|
1078
|
+
return 'image/jpeg';
|
|
1079
|
+
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4E && b[3] === 0x47)
|
|
1080
|
+
return 'image/png';
|
|
1081
|
+
if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46)
|
|
1082
|
+
return 'image/gif';
|
|
1083
|
+
if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46
|
|
1084
|
+
&& b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50)
|
|
1085
|
+
return 'image/webp';
|
|
1086
|
+
if (b[0] === 0x42 && b[1] === 0x4D)
|
|
1087
|
+
return 'image/bmp';
|
|
1088
|
+
return fallback;
|
|
1089
|
+
}
|
|
1090
|
+
async resolveImageReferenceToDataUrl(ref, session) {
|
|
1091
|
+
const value = String(ref ?? '').trim();
|
|
1092
|
+
if (!value)
|
|
1093
|
+
return '';
|
|
1094
|
+
// 已经是 data URL,直接沿用
|
|
1095
|
+
if (/^data:image\//i.test(value)) {
|
|
1096
|
+
return value.length > 64 * 1024 * 1024 ? '' : value;
|
|
1097
|
+
}
|
|
1098
|
+
try {
|
|
1099
|
+
let finalUrl = value;
|
|
1100
|
+
let headers = {};
|
|
1101
|
+
if (/^base64:\/\//i.test(value)) {
|
|
1102
|
+
// Koishi/OneBot 常见形态:base64://<裸base64>
|
|
1103
|
+
const raw = value.replace(/^base64:\/\//i, '');
|
|
1104
|
+
const buffer = Buffer.from(raw, 'base64');
|
|
1105
|
+
if (!buffer.length)
|
|
1106
|
+
return '';
|
|
1107
|
+
return `data:${this.detectImageMime(buffer)};base64,${buffer.toString('base64')}`;
|
|
1108
|
+
}
|
|
1109
|
+
if (/^https?:\/\//i.test(value)) {
|
|
1110
|
+
// OneBot 的 get_image 能把 file_id 换成可访问链接
|
|
1111
|
+
const direct = await this.fetchImageAsDataUrl(value, headers);
|
|
1112
|
+
if (direct)
|
|
1113
|
+
return direct;
|
|
1114
|
+
return '';
|
|
1115
|
+
}
|
|
1116
|
+
// 其余形态(file_id / 文件名 / 本地路径)交给 get_image 解析
|
|
1117
|
+
const onebot = this.getOneBotApi(session);
|
|
1118
|
+
if (onebot?.getImage) {
|
|
1119
|
+
const resolved = await onebot.getImage(value).catch(() => null);
|
|
1120
|
+
const url = resolved?.url || resolved?.file || resolved?.filename || '';
|
|
1121
|
+
const isLocal = url && !/^https?:\/\//i.test(url) && !/^data:/i.test(url);
|
|
1122
|
+
if (isLocal) {
|
|
1123
|
+
try {
|
|
1124
|
+
finalUrl = String(url).replace(/^file:\/\//i, '');
|
|
1125
|
+
}
|
|
1126
|
+
catch {
|
|
1127
|
+
finalUrl = '';
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
else if (url) {
|
|
1131
|
+
const dataUrl = await this.fetchImageAsDataUrl(url, headers);
|
|
1132
|
+
if (dataUrl)
|
|
1133
|
+
return dataUrl;
|
|
1134
|
+
}
|
|
1135
|
+
if (finalUrl && finalUrl !== value && !/^https?:\/\//i.test(finalUrl)) {
|
|
1136
|
+
try {
|
|
1137
|
+
const buffer = await node_fs_1.default.promises.readFile(finalUrl);
|
|
1138
|
+
if (buffer.length)
|
|
1139
|
+
return `data:${this.detectImageMime(buffer)};base64,${buffer.toString('base64')}`;
|
|
1140
|
+
}
|
|
1141
|
+
catch {
|
|
1142
|
+
return '';
|
|
1143
|
+
}
|
|
1023
1144
|
}
|
|
1024
1145
|
}
|
|
1025
|
-
if (
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1146
|
+
if (/^file:\/\//i.test(value) || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('/')) {
|
|
1147
|
+
const localPath = value.replace(/^file:\/\//i, '');
|
|
1148
|
+
const buffer = await node_fs_1.default.promises.readFile(localPath).catch(() => null);
|
|
1149
|
+
if (buffer?.length)
|
|
1150
|
+
return `data:${this.detectImageMime(buffer)};base64,${buffer.toString('base64')}`;
|
|
1151
|
+
}
|
|
1152
|
+
return '';
|
|
1030
1153
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1154
|
+
catch (error) {
|
|
1155
|
+
this._logger.warn(`[img-fetch] resolve failed: ${String(error)} ref=${value.slice(0, 120)} | ${this.sessionTag(session)}`);
|
|
1156
|
+
return '';
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
async fetchImageAsDataUrl(url, extraHeaders = {}) {
|
|
1160
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
1161
|
+
const cached = this._imageDataUrlCache?.get(url);
|
|
1162
|
+
if (cached !== undefined)
|
|
1163
|
+
return cached;
|
|
1164
|
+
if (!this._imageDataUrlCache)
|
|
1165
|
+
this._imageDataUrlCache = new Map();
|
|
1166
|
+
const controller = new AbortController();
|
|
1167
|
+
const timeout = setTimeout(() => controller.abort(), 12_000);
|
|
1168
|
+
try {
|
|
1169
|
+
const response = await fetch(url, {
|
|
1170
|
+
headers: {
|
|
1171
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) NapCat/QQ',
|
|
1172
|
+
Accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
|
1173
|
+
...extraHeaders,
|
|
1174
|
+
},
|
|
1175
|
+
redirect: 'follow',
|
|
1176
|
+
signal: controller.signal,
|
|
1177
|
+
});
|
|
1178
|
+
if (!response.ok) {
|
|
1179
|
+
this._logger.warn(`[img-fetch] http ${response.status} url=${url.slice(0, 140)}`);
|
|
1180
|
+
this.rememberImageDataUrl(url, '');
|
|
1181
|
+
return '';
|
|
1182
|
+
}
|
|
1183
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
1184
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
1185
|
+
if (!buffer.length) {
|
|
1186
|
+
this.rememberImageDataUrl(url, '');
|
|
1187
|
+
return '';
|
|
1188
|
+
}
|
|
1189
|
+
if (buffer.length > MAX_BYTES) {
|
|
1190
|
+
this._logger.warn(`[img-fetch] image too large (${buffer.length} bytes) url=${url.slice(0, 140)}`);
|
|
1191
|
+
this.rememberImageDataUrl(url, '');
|
|
1192
|
+
return '';
|
|
1193
|
+
}
|
|
1194
|
+
const mime = this.detectImageMime(buffer, response.headers.get('content-type')?.split(';')[0] || 'image/jpeg');
|
|
1195
|
+
const dataUrl = `data:${mime};base64,${buffer.toString('base64')}`;
|
|
1196
|
+
this.rememberImageDataUrl(url, dataUrl);
|
|
1197
|
+
return dataUrl;
|
|
1198
|
+
}
|
|
1199
|
+
catch (error) {
|
|
1200
|
+
this._logger.warn(`[img-fetch] download failed: ${String(error)} url=${url.slice(0, 140)}`);
|
|
1201
|
+
this.rememberImageDataUrl(url, '');
|
|
1202
|
+
return '';
|
|
1203
|
+
}
|
|
1204
|
+
finally {
|
|
1205
|
+
clearTimeout(timeout);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
rememberImageDataUrl(url, dataUrl) {
|
|
1209
|
+
if (!this._imageDataUrlCache)
|
|
1210
|
+
this._imageDataUrlCache = new Map();
|
|
1211
|
+
if (this._imageDataUrlCache.size > 64)
|
|
1212
|
+
this._imageDataUrlCache.clear();
|
|
1213
|
+
this._imageDataUrlCache.set(url, dataUrl);
|
|
1214
|
+
}
|
|
1215
|
+
async resolveImagesForModel(refs, session, maxCount) {
|
|
1216
|
+
const limit = Math.max(1, maxCount || 1);
|
|
1217
|
+
const out = [];
|
|
1218
|
+
for (const ref of refs) {
|
|
1219
|
+
if (out.length >= limit)
|
|
1043
1220
|
break;
|
|
1221
|
+
const candidates = Array.isArray(ref) ? ref : [ref];
|
|
1222
|
+
for (const candidate of candidates) {
|
|
1223
|
+
const dataUrl = await this.resolveImageReferenceToDataUrl(candidate, session);
|
|
1224
|
+
if (dataUrl) {
|
|
1225
|
+
out.push(dataUrl);
|
|
1226
|
+
break;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return out;
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* 把 extractImageElements 的结构化条目解析为可直接喂给模型的 data URL。
|
|
1234
|
+
* 每个条目会依次尝试:原始 URL -> 本地文件名(走 OneBot get_image)。
|
|
1235
|
+
*/
|
|
1236
|
+
async resolveVisualItemsForModel(items, session, maxCount) {
|
|
1237
|
+
const limit = Math.max(1, maxCount || 1);
|
|
1238
|
+
const out = [];
|
|
1239
|
+
for (const item of items) {
|
|
1240
|
+
if (out.length >= limit)
|
|
1241
|
+
break;
|
|
1242
|
+
const attempts = [];
|
|
1243
|
+
if (item.url)
|
|
1244
|
+
attempts.push(item.url);
|
|
1245
|
+
if (item.ref && item.ref !== item.url)
|
|
1246
|
+
attempts.push(item.ref);
|
|
1247
|
+
for (const candidate of attempts) {
|
|
1248
|
+
const dataUrl = await this.resolveImageReferenceToDataUrl(candidate, session);
|
|
1249
|
+
if (dataUrl) {
|
|
1250
|
+
out.push(dataUrl);
|
|
1251
|
+
break;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
return out;
|
|
1256
|
+
}
|
|
1257
|
+
isImageSendableUrl(value) {
|
|
1258
|
+
if (!value || typeof value !== 'string')
|
|
1259
|
+
return false;
|
|
1260
|
+
const v = value.trim();
|
|
1261
|
+
if (/^https?:\/\//i.test(v))
|
|
1262
|
+
return true;
|
|
1263
|
+
if (/^data:image\//i.test(v))
|
|
1264
|
+
return true;
|
|
1265
|
+
return false;
|
|
1266
|
+
}
|
|
1267
|
+
decodeHtmlEntities(value) {
|
|
1268
|
+
if (typeof value !== 'string' || !value)
|
|
1269
|
+
return '';
|
|
1270
|
+
return value
|
|
1271
|
+
.replace(/&/gi, '&')
|
|
1272
|
+
.replace(/</gi, '<')
|
|
1273
|
+
.replace(/>/gi, '>')
|
|
1274
|
+
.replace(/"/gi, '"')
|
|
1275
|
+
.replace(/�?39;/g, "'")
|
|
1276
|
+
.replace(/'/gi, "'")
|
|
1277
|
+
.replace(///gi, '/')
|
|
1278
|
+
.replace(/�?38;/g, '&');
|
|
1279
|
+
}
|
|
1280
|
+
parseHtmlTagParams(tag) {
|
|
1281
|
+
const params = {};
|
|
1282
|
+
const re = /([a-zA-Z][\w-]*)\s*=\s*"([^"]*)"|([a-zA-Z][\w-]*)\s*=\s*'([^']*)'/g;
|
|
1283
|
+
let m;
|
|
1284
|
+
while ((m = re.exec(tag))) {
|
|
1285
|
+
const key = (m[1] || m[3] || '').toLowerCase();
|
|
1286
|
+
const raw = m[2] !== undefined ? m[2] : m[4];
|
|
1287
|
+
if (!key)
|
|
1288
|
+
continue;
|
|
1289
|
+
params[key.replace(/-/g, '')] = this.decodeHtmlEntities(raw);
|
|
1044
1290
|
}
|
|
1045
|
-
return
|
|
1291
|
+
return params;
|
|
1292
|
+
}
|
|
1293
|
+
extractImageElements(session) {
|
|
1294
|
+
const items = [];
|
|
1295
|
+
const seen = new Set();
|
|
1296
|
+
const push = (url, ref, sticker) => {
|
|
1297
|
+
const u = this.decodeHtmlEntities(url || '').trim();
|
|
1298
|
+
const r = (ref || '').trim();
|
|
1299
|
+
const key = u || r;
|
|
1300
|
+
if (!key || seen.has(key))
|
|
1301
|
+
return;
|
|
1302
|
+
seen.add(key);
|
|
1303
|
+
items.push({
|
|
1304
|
+
url: u,
|
|
1305
|
+
ref: r,
|
|
1306
|
+
sticker: !!sticker,
|
|
1307
|
+
sendable: this.isImageSendableUrl(u),
|
|
1308
|
+
});
|
|
1309
|
+
};
|
|
1310
|
+
const isStickerParams = (params) => {
|
|
1311
|
+
const sub = String(params.subtype ?? params.subType ?? params['sub-type'] ?? '');
|
|
1312
|
+
const summary = String(params.summary ?? '');
|
|
1313
|
+
return sub === '1' || /动画表情|表情|贴纸|sticker|mface/i.test(summary);
|
|
1314
|
+
};
|
|
1315
|
+
// 1) CQ 码形态
|
|
1316
|
+
const content = session?.content ?? '';
|
|
1317
|
+
for (const chunk of content.match(/\[CQ:image,[^\]]*\]/gi) ?? []) {
|
|
1318
|
+
const params = this.parseCqImageParams(chunk);
|
|
1319
|
+
push(params.url || params.src || params.file, params.file_id || params.file || params.id, isStickerParams(params));
|
|
1320
|
+
}
|
|
1321
|
+
// 2) koishi onebot 适配器的 HTML 形态:<img src="..." summary="[动画表情]" .../>
|
|
1322
|
+
for (const tag of content.match(/<img\b[^>]*\/?>/gi) ?? []) {
|
|
1323
|
+
const attrs = this.parseHtmlTagParams(tag);
|
|
1324
|
+
push(attrs.src || attrs.url || attrs.file, attrs.file || attrs.fileid, isStickerParams(attrs));
|
|
1325
|
+
}
|
|
1326
|
+
// 3) 元素形态:type 可能是 image / img / face / sticker / mface
|
|
1327
|
+
for (const element of session?.elements ?? []) {
|
|
1328
|
+
const type = String(element?.type ?? '').toLowerCase();
|
|
1329
|
+
const attrs = element?.attrs ?? {};
|
|
1330
|
+
const rawAttrs = {};
|
|
1331
|
+
for (const k of Object.keys(attrs)) rawAttrs[String(k).toLowerCase().replace(/-/g, '')] = attrs[k];
|
|
1332
|
+
const isImgLike = type === 'img' || type === 'image';
|
|
1333
|
+
const isStickerLike = type === 'face' || type === 'sticker' || type === 'mface';
|
|
1334
|
+
if (!isImgLike && !isStickerLike)
|
|
1335
|
+
continue;
|
|
1336
|
+
const url = element?.url || element?.src
|
|
1337
|
+
|| attrs.url || attrs.src
|
|
1338
|
+
|| attrs.image || attrs.href || '';
|
|
1339
|
+
const ref = element?.file || element?.fileId || element?.file_id || element?.id
|
|
1340
|
+
|| attrs.file || attrs.fileid || attrs.file_id || attrs.id || '';
|
|
1341
|
+
const sticker = isStickerLike || isStickerParams(rawAttrs) || isStickerParams(attrs);
|
|
1342
|
+
push(url, ref, sticker);
|
|
1343
|
+
}
|
|
1344
|
+
return items;
|
|
1345
|
+
}
|
|
1346
|
+
extractImageUrlsFromSession(session, maxCount = 4) {
|
|
1347
|
+
return this.extractImageElements(session)
|
|
1348
|
+
.filter((item) => item.sendable)
|
|
1349
|
+
.map((item) => item.url)
|
|
1350
|
+
.slice(0, Math.max(1, maxCount));
|
|
1351
|
+
}
|
|
1352
|
+
hasAnyImageElement(session) {
|
|
1353
|
+
if (!session)
|
|
1354
|
+
return false;
|
|
1355
|
+
if (this.extractImageElements(session).length)
|
|
1356
|
+
return true;
|
|
1357
|
+
const content = session.content ?? '';
|
|
1358
|
+
return /\[CQ:image,/i.test(content)
|
|
1359
|
+
|| /<img\b/i.test(content)
|
|
1360
|
+
|| (session.elements ?? []).some((el) => {
|
|
1361
|
+
const t = String(el?.type ?? '').toLowerCase();
|
|
1362
|
+
return t === 'img' || t === 'image' || t === 'face' || t === 'sticker' || t === 'mface';
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
isStickerElement(element) {
|
|
1366
|
+
if (!element || typeof element !== 'object')
|
|
1367
|
+
return false;
|
|
1368
|
+
const type = String(element.type ?? '').toLowerCase();
|
|
1369
|
+
if (type === 'face' || type === 'sticker' || type === 'mface' || type === 'img' || type === 'image')
|
|
1370
|
+
return true;
|
|
1371
|
+
const attrs = element.attrs ?? {};
|
|
1372
|
+
const sub = String(attrs.subType ?? attrs.subtype ?? attrs['sub-type'] ?? '');
|
|
1373
|
+
return type === 'image' && sub === '1';
|
|
1374
|
+
}
|
|
1375
|
+
hasStickerContent(session) {
|
|
1376
|
+
if (!session)
|
|
1377
|
+
return false;
|
|
1378
|
+
if (/\[CQ:(face|mface|sticker),/i.test(session.content ?? ''))
|
|
1379
|
+
return true;
|
|
1380
|
+
return this.extractImageElements(session).some((item) => item.sticker);
|
|
1381
|
+
}
|
|
1382
|
+
extractMessageImageUrls(session, maxCount = 4) {
|
|
1383
|
+
return this.extractImageUrlsFromSession(session, maxCount);
|
|
1046
1384
|
}
|
|
1047
1385
|
describeImageMessage(session) {
|
|
1048
1386
|
const content = session.content ?? '';
|
|
1049
|
-
const rawCq = (content.match(/\[CQ:image,[^\]]
|
|
1387
|
+
const rawCq = (content.match(/\[CQ:image,[^\]]*\]/gi) ?? []);
|
|
1388
|
+
const rawHtml = (content.match(/<img\b[^>]*\/?>/gi) ?? []);
|
|
1389
|
+
const items = this.extractImageElements(session);
|
|
1050
1390
|
const elems = [];
|
|
1051
1391
|
for (const el of session.elements ?? []) {
|
|
1052
|
-
|
|
1392
|
+
const t = String(el?.type ?? '').toLowerCase();
|
|
1393
|
+
if (t !== 'image' && t !== 'img' && t !== 'face' && t !== 'sticker' && t !== 'mface')
|
|
1053
1394
|
continue;
|
|
1054
|
-
const
|
|
1395
|
+
const attrs = el.attrs ?? {};
|
|
1396
|
+
elems.push({
|
|
1055
1397
|
type: el.type,
|
|
1056
|
-
url: el.url ||
|
|
1057
|
-
src: el.src ||
|
|
1058
|
-
file: el.file ||
|
|
1059
|
-
fileId: el.fileId || el.file_id ||
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1398
|
+
url: el.url || attrs.url || '',
|
|
1399
|
+
src: el.src || attrs.src || '',
|
|
1400
|
+
file: el.file || attrs.file || '',
|
|
1401
|
+
fileId: el.fileId || el.file_id || attrs.fileId || attrs.fileid || attrs.file_id || '',
|
|
1402
|
+
subType: attrs.subType ?? attrs.subtype ?? attrs['sub-type'] ?? '',
|
|
1403
|
+
summary: attrs.summary || '',
|
|
1404
|
+
});
|
|
1063
1405
|
}
|
|
1064
1406
|
return {
|
|
1065
1407
|
hasCqImage: rawCq.length > 0,
|
|
1066
1408
|
rawCq,
|
|
1409
|
+
rawHtml,
|
|
1410
|
+
items,
|
|
1067
1411
|
elementImages: elems,
|
|
1068
|
-
extractableUrls: this.
|
|
1412
|
+
extractableUrls: this.extractImageUrlsFromSession(session, 10),
|
|
1413
|
+
candidates: this.extractImageCandidates(session, 10),
|
|
1069
1414
|
};
|
|
1070
1415
|
}
|
|
1071
1416
|
logImageDebug(session) {
|
|
1072
1417
|
const info = this.describeImageMessage(session);
|
|
1073
|
-
this._logger.info(`[img-debug] cq=${info.rawCq.length} elements=${info.elementImages.length}
|
|
1418
|
+
this._logger.info(`[img-debug] cq=${info.rawCq.length} html=${info.rawHtml.length} elements=${info.elementImages.length} sendable=${JSON.stringify(info.extractableUrls)} candidates=${JSON.stringify(info.candidates)} items=${JSON.stringify(info.items).slice(0, 400)} | ${this.sessionTag(session)}`);
|
|
1074
1419
|
}
|
|
1075
1420
|
extractImageFingerprintKeys(session, maxCount = 6) {
|
|
1076
1421
|
const keys = [];
|
|
@@ -1362,11 +1707,18 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
1362
1707
|
extractImageSendRefs(session, maxCount = 6) {
|
|
1363
1708
|
const refs = [];
|
|
1364
1709
|
const pushRef = (value) => {
|
|
1365
|
-
const v = value.trim();
|
|
1710
|
+
const v = String(value ?? '').trim();
|
|
1366
1711
|
if (!v)
|
|
1367
1712
|
return;
|
|
1368
1713
|
refs.push(v);
|
|
1369
1714
|
};
|
|
1715
|
+
// 统一走 extractImageElements:同时覆盖 CQ 与 koishi HTML(<img>) 两种形态
|
|
1716
|
+
for (const item of this.extractImageElements(session)) {
|
|
1717
|
+
pushRef(item.ref);
|
|
1718
|
+
pushRef(item.url);
|
|
1719
|
+
if (refs.length >= maxCount)
|
|
1720
|
+
return [...new Set(refs)].slice(0, maxCount);
|
|
1721
|
+
}
|
|
1370
1722
|
const content = session.content ?? '';
|
|
1371
1723
|
const cqPattern = /\[CQ:image,[^\]]*\]/gi;
|
|
1372
1724
|
for (const chunk of content.match(cqPattern) ?? []) {
|
|
@@ -1384,27 +1736,6 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
1384
1736
|
if (refs.length >= maxCount)
|
|
1385
1737
|
return [...new Set(refs)].slice(0, maxCount);
|
|
1386
1738
|
}
|
|
1387
|
-
const elements = session.elements ?? [];
|
|
1388
|
-
for (const element of elements) {
|
|
1389
|
-
if (element.type !== 'image')
|
|
1390
|
-
continue;
|
|
1391
|
-
const candidates = [
|
|
1392
|
-
typeof element.id === 'string' ? element.id : '',
|
|
1393
|
-
typeof element.file === 'string' ? element.file : '',
|
|
1394
|
-
typeof element.url === 'string' ? element.url : '',
|
|
1395
|
-
typeof element.src === 'string' ? element.src : '',
|
|
1396
|
-
typeof element.attrs?.file_id === 'string' ? element.attrs.file_id : '',
|
|
1397
|
-
typeof element.attrs?.id === 'string' ? element.attrs.id : '',
|
|
1398
|
-
typeof element.attrs?.file === 'string' ? element.attrs.file : '',
|
|
1399
|
-
typeof element.attrs?.url === 'string' ? element.attrs.url : '',
|
|
1400
|
-
typeof element.attrs?.src === 'string' ? element.attrs.src : '',
|
|
1401
|
-
].filter(Boolean);
|
|
1402
|
-
for (const item of candidates) {
|
|
1403
|
-
pushRef(item);
|
|
1404
|
-
if (refs.length >= maxCount)
|
|
1405
|
-
return [...new Set(refs)].slice(0, maxCount);
|
|
1406
|
-
}
|
|
1407
|
-
}
|
|
1408
1739
|
return [...new Set(refs)].slice(0, maxCount);
|
|
1409
1740
|
}
|
|
1410
1741
|
async fetchMessageImagePayload(session, maxCount = 6) {
|
|
@@ -1626,7 +1957,9 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
1626
1957
|
return { ok: false, message: '无可用聊天上下文。' };
|
|
1627
1958
|
let prompt = this.buildAiUserPrompt(records, options);
|
|
1628
1959
|
prompt = await this.decoratePromptWithMemory(prompt, options);
|
|
1629
|
-
const focusImages = (options?.focusImages ?? [])
|
|
1960
|
+
const focusImages = (options?.focusImages ?? [])
|
|
1961
|
+
.filter((url) => typeof url === 'string' && (/^https?:\/\//i.test(url) || /^data:image\//i.test(url)))
|
|
1962
|
+
.slice(0, Math.max(1, this.config.aiImageMaxCount || 2));
|
|
1630
1963
|
const result = this.config.aiProvider === 'gemini'
|
|
1631
1964
|
? await this.callGemini(focusImages.length
|
|
1632
1965
|
? `${prompt}\n\n点名消息图片链接(如可访问请一并识别):\n${focusImages.map((item, i) => `${i + 1}. ${item}`).join('\n')}`
|
|
@@ -1656,16 +1989,40 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
1656
1989
|
if (!groupKey)
|
|
1657
1990
|
return;
|
|
1658
1991
|
const text = this.extractAiMessageText(session);
|
|
1659
|
-
// Always record context first, including other users during follow-up windows.
|
|
1660
|
-
this.pushAiRecord(session, text || '@bot');
|
|
1661
1992
|
const atBot = this.isMentionBot(session);
|
|
1662
1993
|
const messageImages = this.extractImageUrls(session);
|
|
1663
|
-
//
|
|
1994
|
+
// 识图:当前消息没有图时,回退到「被回复的那条消息」里的图(适配器已把原消息放在 session.quote)
|
|
1995
|
+
const ownItems = this.extractImageElements(session);
|
|
1996
|
+
const quoteItems = session.quote ? this.extractImageElements(session.quote) : [];
|
|
1997
|
+
const itemsForModel = ownItems.length ? ownItems : quoteItems;
|
|
1998
|
+
const imageCandidates = this.collectReplyAwareImageCandidates(session, Math.max(1, this.config.aiImageMaxCount || 2));
|
|
1999
|
+
const replyImageRefs = messageImages.length ? [] : imageCandidates.quoted;
|
|
2000
|
+
const rawImageRefs = messageImages.length ? [] : imageCandidates.merged;
|
|
2001
|
+
// 注意:图片下载(http -> data URL)延迟到"确认要回复"之后,避免每条含图消息都产生网络开销
|
|
2002
|
+
let focusImages = messageImages;
|
|
2003
|
+
const hasSticker = this.hasStickerContent(session)
|
|
2004
|
+
|| !!(session.quote && this.hasStickerContent(session.quote));
|
|
2005
|
+
// 识图调试:消息里带图/表情但提取不到可发送图片时,记录原始字段,便于排查
|
|
1664
2006
|
if (this.config.aiEnableImageRecognition) {
|
|
1665
|
-
const
|
|
1666
|
-
|
|
1667
|
-
if (hasImage && !messageImages.length) {
|
|
2007
|
+
const quotedHasImage = this.hasAnyImageElement(session.quote);
|
|
2008
|
+
if ((this.hasAnyImageElement(session) || quotedHasImage) && !messageImages.length) {
|
|
1668
2009
|
this.logImageDebug(session);
|
|
2010
|
+
this._logger.info(`[img-debug] reply-aware ownItems=${ownItems.length} quoteItems=${quoteItems.length} own=${JSON.stringify(imageCandidates.own)} quoted=${JSON.stringify(replyImageRefs)} hasQuote=${!!session.quote} | ${this.sessionTag(session)}`);
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
// 纯图片/表情包消息也参与 AI 判定(此前会被下面的空文本早退直接丢弃)
|
|
2014
|
+
const hasVisualContent = hasSticker || !!rawImageRefs.length || focusImages.length > 0;
|
|
2015
|
+
const visualText = hasVisualContent ? '[图片/表情]' : '';
|
|
2016
|
+
const effectiveText = text || visualText;
|
|
2017
|
+
// 记录上下文(放在视觉变量计算之后,避免 const 暂时性死区)
|
|
2018
|
+
this.pushAiRecord(session, text || visualText || '@bot');
|
|
2019
|
+
if (hasVisualContent) {
|
|
2020
|
+
// 用视觉占位文本刷新上下文,避免 buffer 里该条为空文本
|
|
2021
|
+
const buffered = this.aiBuffers.get(groupKey) ?? [];
|
|
2022
|
+
const last = buffered[buffered.length - 1];
|
|
2023
|
+
if (last && !String(last.text ?? '').trim()) {
|
|
2024
|
+
last.text = visualText;
|
|
2025
|
+
last.imageUrls = last.imageUrls?.length ? last.imageUrls : (hasSticker ? ['sticker'] : ['image']);
|
|
1669
2026
|
}
|
|
1670
2027
|
}
|
|
1671
2028
|
const directMention = this.config.aiEnableDirectMentionTrigger && (this.containsAgentName(text) || atBot);
|
|
@@ -1689,7 +2046,8 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
1689
2046
|
}
|
|
1690
2047
|
if (followup && !isFollowupUser && !directMention && !relatedContextFollowup)
|
|
1691
2048
|
return;
|
|
1692
|
-
|
|
2049
|
+
// 纯图片/表情消息也允许进入随机/阈值/兴趣判定(visualText 已给出占位文本)
|
|
2050
|
+
if (!effectiveText && !hasVisualContent && !directMention && !followupForce && !relatedContextFollowup)
|
|
1693
2051
|
return;
|
|
1694
2052
|
if (this.config.aiIgnoreCommandMessage && !directMention && !followupForce && !relatedContextFollowup && this.isCommandLikeMessage(text))
|
|
1695
2053
|
return;
|
|
@@ -1702,7 +2060,7 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
1702
2060
|
return;
|
|
1703
2061
|
const topicHint = (this.aiTopicMemory.get(groupKey)?.text || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
1704
2062
|
if (trigger.reason === 'interest-check') {
|
|
1705
|
-
const interest = await this.shouldReplyByInterest({ groupKey, text });
|
|
2063
|
+
const interest = await this.shouldReplyByInterest({ groupKey, text: effectiveText, hasSticker, hasImage: focusImages.length > 0 });
|
|
1706
2064
|
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 });
|
|
1707
2065
|
if (!interest.ok)
|
|
1708
2066
|
return;
|
|
@@ -1725,7 +2083,7 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
1725
2083
|
}
|
|
1726
2084
|
}
|
|
1727
2085
|
if (thresholdMode) {
|
|
1728
|
-
const interest = await this.shouldReplyByInterest({ groupKey, text });
|
|
2086
|
+
const interest = await this.shouldReplyByInterest({ groupKey, text: effectiveText, hasSticker, hasImage: focusImages.length > 0 });
|
|
1729
2087
|
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 });
|
|
1730
2088
|
if (!interest.ok) {
|
|
1731
2089
|
this.logCommandResult('ai-threshold-skip-by-interest', { ok: true, message: 'threshold hit but skipped by low interest' }, session, {
|
|
@@ -1738,14 +2096,29 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
1738
2096
|
}
|
|
1739
2097
|
this.aiInFlight.add(groupKey);
|
|
1740
2098
|
const startedAt = Date.now();
|
|
2099
|
+
// 已确认要回复,此时才把图片下载/转换为 base64(模型需要可直接读取的图片数据)
|
|
2100
|
+
const maxImg = Math.max(1, this.config.aiImageMaxCount || 2);
|
|
2101
|
+
let modelImages = focusImages.filter((u) => /^data:image\//i.test(u)).slice(0, maxImg);
|
|
2102
|
+
if (this.config.aiEnableImageRecognition && modelImages.length < maxImg) {
|
|
2103
|
+
const pendingItems = itemsForModel.slice();
|
|
2104
|
+
if (pendingItems.length) {
|
|
2105
|
+
const resolved = await this.resolveVisualItemsForModel(pendingItems, session, maxImg - modelImages.length);
|
|
2106
|
+
modelImages = [...modelImages, ...resolved].slice(0, maxImg);
|
|
2107
|
+
}
|
|
2108
|
+
else if (rawImageRefs.length) {
|
|
2109
|
+
const resolved = await this.resolveImagesForModel(rawImageRefs, session, maxImg - modelImages.length);
|
|
2110
|
+
modelImages = [...modelImages, ...resolved].slice(0, maxImg);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
this._logger.info(`[img-probe] trigger=${trigger.reason} ownItems=${ownItems.length} quoteItems=${quoteItems.length} candidates=${rawImageRefs.length} modelImages=${modelImages.length} sticker=${hasSticker} | ${this.sessionTag(session)}`);
|
|
1741
2114
|
try {
|
|
1742
2115
|
const result = await this.generateAiReply(session, {
|
|
1743
2116
|
directMention,
|
|
1744
2117
|
followup: (followupForce || relatedContextFollowup),
|
|
1745
2118
|
thresholdMode,
|
|
1746
|
-
focusText: (directMention || followupForce || relatedContextFollowup) ? (
|
|
2119
|
+
focusText: (directMention || followupForce || relatedContextFollowup) ? (effectiveText || '@bot') : undefined,
|
|
1747
2120
|
focusUserId: (directMention || followupForce || relatedContextFollowup) ? session.userId : undefined,
|
|
1748
|
-
focusImages:
|
|
2121
|
+
focusImages: modelImages,
|
|
1749
2122
|
focusUserName: (directMention || followupForce || relatedContextFollowup) ? this.getDisplayName(session) : undefined,
|
|
1750
2123
|
topicTokens: thresholdMode ? topicTokens : undefined,
|
|
1751
2124
|
selectedRecord,
|
|
@@ -2602,7 +2975,9 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
2602
2975
|
return onebot ?? null;
|
|
2603
2976
|
}
|
|
2604
2977
|
extractTextForFilter(session) {
|
|
2605
|
-
const raw = (session.content ?? '')
|
|
2978
|
+
const raw = (session.content ?? '')
|
|
2979
|
+
.replace(/<[^>]+>/g, ' ')
|
|
2980
|
+
.replace(/\[CQ:[^\]]+\]/g, ' ');
|
|
2606
2981
|
return raw.trim();
|
|
2607
2982
|
}
|
|
2608
2983
|
hasTextContent(session) {
|
|
@@ -2620,7 +2995,12 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
2620
2995
|
const content = session.content ?? '';
|
|
2621
2996
|
if (/\[CQ:(image|face|record|video|flash),/i.test(content))
|
|
2622
2997
|
return true;
|
|
2623
|
-
|
|
2998
|
+
if (/<img\b/i.test(content))
|
|
2999
|
+
return true;
|
|
3000
|
+
return (session.elements ?? []).some((el) => {
|
|
3001
|
+
const t = String(el?.type ?? '').toLowerCase();
|
|
3002
|
+
return t === 'img' || t === 'image' || t === 'face' || t === 'record' || t === 'video' || t === 'flash';
|
|
3003
|
+
});
|
|
2624
3004
|
}
|
|
2625
3005
|
hasCardMessage(session) {
|
|
2626
3006
|
const content = session.content ?? '';
|
|
@@ -2674,9 +3054,168 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
2674
3054
|
violationWindowMinutes: rule.autoViolationWindowMinutes ?? this.config.autoViolationWindowMinutes,
|
|
2675
3055
|
};
|
|
2676
3056
|
}
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
3057
|
+
/**
|
|
3058
|
+
* 违禁词分档默认分值(可通过配置用 "关键词|分值" 或 {keyword,score} 覆盖)
|
|
3059
|
+
* 设计原则:单独出现时属于强广告特征的词给高分(可独立触发),
|
|
3060
|
+
* 需要组合才能确认的弱特征词给低分(需多条累计)。
|
|
3061
|
+
*/
|
|
3062
|
+
static DEFAULT_BANNED_WORD_SCORES = {
|
|
3063
|
+
// 显式广告行为(单条精确命中即可接近/达到阈值)
|
|
3064
|
+
'加群': 50, '拉人': 50, '拉群': 50, '互推': 50, '引流': 55,
|
|
3065
|
+
'加微信': 55, '加v': 50, '加V': 50, '加qq': 50, '加QQ': 50,
|
|
3066
|
+
'扫码进群': 80, '进群领': 60, '加我好友': 50, '私信我': 50,
|
|
3067
|
+
'点击链接': 50, '刷单': 60, '躺赚': 60, '日结': 50,
|
|
3068
|
+
// 需要组合确认的运营/营销词
|
|
3069
|
+
'邀请你': 35, '问卷': 30, '兼职': 40, '赚钱': 35, '赚米': 35,
|
|
3070
|
+
'返利': 45, '代理': 30, '招人': 35, '零成本': 40, '速来': 30,
|
|
3071
|
+
'福利群': 45, '红包群': 45, '活动群': 40, '转发群': 40,
|
|
3072
|
+
'免费领': 50, '免费送': 45, '领红包': 40, '抢红包': 35, '领福利': 40,
|
|
3073
|
+
'点击查看': 35, '名额有限': 35,
|
|
3074
|
+
};
|
|
3075
|
+
static DEFAULT_BANNED_WORD_SCORE = 40;
|
|
3076
|
+
normalizeBannedWordEntry(entry) {
|
|
3077
|
+
const raw = String(entry ?? '').trim();
|
|
3078
|
+
if (!raw)
|
|
3079
|
+
return null;
|
|
3080
|
+
let keyword = raw;
|
|
3081
|
+
let score = null;
|
|
3082
|
+
// 支持 "关键词|分值"、全角竖线、逗号分隔
|
|
3083
|
+
const m = raw.match(/^(.+?)\s*[||,,::]\s*(\d{1,3})$/);
|
|
3084
|
+
if (m) {
|
|
3085
|
+
keyword = m[1].trim();
|
|
3086
|
+
const parsed = parseInt(m[2], 10);
|
|
3087
|
+
if (Number.isFinite(parsed))
|
|
3088
|
+
score = Math.max(0, Math.min(100, parsed));
|
|
3089
|
+
}
|
|
3090
|
+
if (!keyword)
|
|
3091
|
+
return null;
|
|
3092
|
+
if (score === null) {
|
|
3093
|
+
const table = this.constructor.DEFAULT_BANNED_WORD_SCORES || {};
|
|
3094
|
+
const hit = Object.keys(table).find((k) => k.toLowerCase() === keyword.toLowerCase());
|
|
3095
|
+
score = hit ? table[hit] : this.constructor.DEFAULT_BANNED_WORD_SCORE;
|
|
3096
|
+
}
|
|
3097
|
+
return { keyword, score };
|
|
3098
|
+
}
|
|
3099
|
+
normalizeBannedWordList(list) {
|
|
3100
|
+
const out = [];
|
|
3101
|
+
const seen = new Set();
|
|
3102
|
+
for (const entry of list ?? []) {
|
|
3103
|
+
let keyword;
|
|
3104
|
+
let score;
|
|
3105
|
+
if (entry && typeof entry === 'object') {
|
|
3106
|
+
keyword = String(entry.keyword ?? entry.word ?? entry.text ?? '').trim();
|
|
3107
|
+
const parsed = Number(entry.score ?? entry.weight ?? NaN);
|
|
3108
|
+
score = Number.isFinite(parsed) ? Math.max(0, Math.min(100, parsed)) : null;
|
|
3109
|
+
if (keyword && score === null) {
|
|
3110
|
+
const table = this.constructor.DEFAULT_BANNED_WORD_SCORES || {};
|
|
3111
|
+
const hit = Object.keys(table).find((k) => k.toLowerCase() === keyword.toLowerCase());
|
|
3112
|
+
score = hit ? table[hit] : this.constructor.DEFAULT_BANNED_WORD_SCORE;
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
else {
|
|
3116
|
+
const norm = this.normalizeBannedWordEntry(entry);
|
|
3117
|
+
if (!norm)
|
|
3118
|
+
continue;
|
|
3119
|
+
keyword = norm.keyword;
|
|
3120
|
+
score = norm.score;
|
|
3121
|
+
}
|
|
3122
|
+
if (!keyword)
|
|
3123
|
+
continue;
|
|
3124
|
+
const key = keyword.toLowerCase();
|
|
3125
|
+
if (seen.has(key))
|
|
3126
|
+
continue;
|
|
3127
|
+
seen.add(key);
|
|
3128
|
+
out.push({ keyword, score });
|
|
3129
|
+
}
|
|
3130
|
+
return out;
|
|
3131
|
+
}
|
|
3132
|
+
/**
|
|
3133
|
+
* 顺序子序列匹配:关键词的字符按顺序出现在文本中即命中,允许中间隔词。
|
|
3134
|
+
* 返回匹配跨度与紧凑度(跨度越接近词长,置信度越高)。
|
|
3135
|
+
*/
|
|
3136
|
+
/**
|
|
3137
|
+
* 模糊命中的最低置信度门槛。
|
|
3138
|
+
* 短关键词(2~4 字)极易被无关文本凑齐(如「领导说…红色包装袋」凑出「领红包」),
|
|
3139
|
+
* 因此对短词要求更高的置信度;长词本身信息量足,门槛可放宽。
|
|
3140
|
+
*/
|
|
3141
|
+
minBannedWordConfidence(keywordLength) {
|
|
3142
|
+
if (keywordLength <= 1)
|
|
3143
|
+
return 1;
|
|
3144
|
+
if (keywordLength <= 2)
|
|
3145
|
+
return 0.6;
|
|
3146
|
+
if (keywordLength <= 4)
|
|
3147
|
+
return 0.65;
|
|
3148
|
+
if (keywordLength <= 6)
|
|
3149
|
+
return 0.55;
|
|
3150
|
+
return 0.45;
|
|
3151
|
+
}
|
|
3152
|
+
subsequenceMatch(text, keyword) {
|
|
3153
|
+
if (!text || !keyword)
|
|
3154
|
+
return null;
|
|
3155
|
+
const first = text.indexOf(keyword);
|
|
3156
|
+
if (first >= 0) {
|
|
3157
|
+
return { start: first, end: first + keyword.length, span: keyword.length, ratio: 1, exact: true };
|
|
3158
|
+
}
|
|
3159
|
+
const chars = Array.from(keyword);
|
|
3160
|
+
let pos = 0;
|
|
3161
|
+
let start = -1;
|
|
3162
|
+
let end = -1;
|
|
3163
|
+
for (let i = 0; i < chars.length; i++) {
|
|
3164
|
+
const idx = text.indexOf(chars[i], pos);
|
|
3165
|
+
if (idx < 0) {
|
|
3166
|
+
// 单个字符找不到即判定不命中
|
|
3167
|
+
return null;
|
|
3168
|
+
}
|
|
3169
|
+
if (i === 0)
|
|
3170
|
+
start = idx;
|
|
3171
|
+
end = idx;
|
|
3172
|
+
pos = idx + 1;
|
|
3173
|
+
}
|
|
3174
|
+
const span = end - start + 1;
|
|
3175
|
+
const overflow = span - chars.length;
|
|
3176
|
+
// 分散过滤:隔得太开(跨度超过词长 + 8 或 3 倍词长)视为偶然凑齐
|
|
3177
|
+
if (span > chars.length * 3 || overflow > 8) {
|
|
3178
|
+
return null;
|
|
3179
|
+
}
|
|
3180
|
+
// 置信度按「词长归一化的溢出」衰减:短词对间隔更敏感
|
|
3181
|
+
// 例:领红包(len3) 在「领xxx元红包」(span6,overflow3) → 1-0.25*1 = 0.75
|
|
3182
|
+
const confidence = Math.max(0.25, 1 - 0.25 * (overflow / chars.length));
|
|
3183
|
+
// 短词需要更高置信度才算命中,避免无关文本偶然凑齐字符
|
|
3184
|
+
if (confidence < this.minBannedWordConfidence(chars.length)) {
|
|
3185
|
+
return null;
|
|
3186
|
+
}
|
|
3187
|
+
return { start, end: end + 1, span, overflow, ratio: chars.length / span, confidence, exact: false };
|
|
3188
|
+
}
|
|
3189
|
+
evaluateBannedWords(text, policy) {
|
|
3190
|
+
const words = this.normalizeBannedWordList(policy?.bannedWords ?? []);
|
|
3191
|
+
const evaluations = [];
|
|
3192
|
+
let total = 0;
|
|
3193
|
+
for (const { keyword, score } of words) {
|
|
3194
|
+
if (!keyword || keyword.length < 1)
|
|
3195
|
+
continue;
|
|
3196
|
+
const match = this.subsequenceMatch(text, keyword);
|
|
3197
|
+
if (!match)
|
|
3198
|
+
continue;
|
|
3199
|
+
const confidence = match.exact ? 1 : (match.confidence ?? 0.3);
|
|
3200
|
+
const gained = Math.round(score * Math.min(1, confidence) * 100) / 100;
|
|
3201
|
+
if (gained <= 0)
|
|
3202
|
+
continue;
|
|
3203
|
+
total += gained;
|
|
3204
|
+
evaluations.push({
|
|
3205
|
+
keyword,
|
|
3206
|
+
score,
|
|
3207
|
+
confidence: Math.round(Math.min(1, confidence) * 100) / 100,
|
|
3208
|
+
gained,
|
|
3209
|
+
span: match.span,
|
|
3210
|
+
exact: !!match.exact,
|
|
3211
|
+
});
|
|
3212
|
+
}
|
|
3213
|
+
evaluations.sort((a, b) => b.gained - a.gained);
|
|
3214
|
+
return { total: Math.round(total * 100) / 100, evaluations };
|
|
3215
|
+
}
|
|
3216
|
+
detectViolation(session, policy, type = 'message') {
|
|
3217
|
+
// 纯图片/表情/贴纸(无实际文字内容)的消息不作为违规处理,避免表情包被当成违禁词
|
|
3218
|
+
if (type === 'message' && this.hasImageOrFace(session) && !this.hasTextContent(session)) {
|
|
2680
3219
|
return null;
|
|
2681
3220
|
}
|
|
2682
3221
|
if (policy.blockCardMessage && this.hasCardMessage(session)) {
|
|
@@ -2688,13 +3227,17 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
2688
3227
|
const text = this.extractTextForFilter(session);
|
|
2689
3228
|
if (!text)
|
|
2690
3229
|
return null;
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
3230
|
+
const assessment = this.evaluateBannedWords(text, policy);
|
|
3231
|
+
const threshold = Math.max(1, Math.min(100, Number(this.config.bannedWordScoreThreshold ?? 70)));
|
|
3232
|
+
if (assessment.total >= threshold) {
|
|
3233
|
+
const top = assessment.evaluations.slice(0, 4)
|
|
3234
|
+
.map((item) => `${item.keyword}(+${item.gained}${item.exact ? '' : `/${item.confidence}`})`)
|
|
3235
|
+
.join(' ');
|
|
3236
|
+
return {
|
|
3237
|
+
type: 'banned-word',
|
|
3238
|
+
detail: `违禁词评分 ${assessment.total}/${threshold}:${top}`,
|
|
3239
|
+
assessment,
|
|
3240
|
+
};
|
|
2698
3241
|
}
|
|
2699
3242
|
return null;
|
|
2700
3243
|
}
|
|
@@ -2796,10 +3339,13 @@ class QQGroupManagerService extends koishi_1.Service {
|
|
|
2796
3339
|
if (policy.autoDeleteViolation) {
|
|
2797
3340
|
deleted = await this.deleteMessage(session);
|
|
2798
3341
|
}
|
|
2799
|
-
|
|
3342
|
+
const scoreInfo = violation.assessment
|
|
3343
|
+
? ` score=${violation.assessment.total} hits=${violation.assessment.evaluations.map((item) => `${item.keyword}:${item.gained}${item.exact ? '' : '/fuzzy'}`).join(',')}`
|
|
3344
|
+
: '';
|
|
3345
|
+
this._logger.warn(`[moderation] type=${violation.type} detail="${violation.detail}"${scoreInfo} deleted=${deleted} | ${this.sessionTag(session)}`);
|
|
2800
3346
|
if (policy.sendViolationNotice) {
|
|
2801
3347
|
const notice = violation.type === 'banned-word'
|
|
2802
|
-
? '
|
|
3348
|
+
? `检测到违禁内容(评分 ${violation.assessment?.total ?? '-'}),消息已处理。`
|
|
2803
3349
|
: violation.type === 'card'
|
|
2804
3350
|
? '卡片消息不被允许,消息已处理。'
|
|
2805
3351
|
: '合并转发消息不被允许,消息已处理。';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nestim/koishi-plugin-qq-group-manager",
|
|
3
3
|
"description": "Koishi QQ群管理插件(OneBot/LLOneBot):群管命令、权限校验、图片菜单、状态卡片、复读、AI群聊回复、记忆库、识图与自动管控(禁言/踢人)。",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.6",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"typings": "lib/index.d.ts",
|
|
7
7
|
"files": [
|
package/readme.md
CHANGED
|
@@ -1,9 +1,56 @@
|
|
|
1
|
-
|
|
1
|
+
# koishi-qq-group-manager
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/@nestim/koishi-plugin-qq-group-manager)
|
|
4
4
|
|
|
5
5
|
QQ群管理插件(OneBot/LLOneBot):群管命令、权限校验、图片菜单、状态卡片、复读、AI群聊回复、记忆库、识图与自动管控(禁言/踢人)。
|
|
6
6
|
|
|
7
|
+
## v0.1.6 重点更新
|
|
8
|
+
|
|
9
|
+
### 1. 识图彻底修复(此前完全不可用)
|
|
10
|
+
|
|
11
|
+
早前版本的图片检测对 NapCat/Koishi 实际下发的消息结构**完全不匹配**,导致 AI 永远「看不到图」:
|
|
12
|
+
|
|
13
|
+
| 旧实现假设 | 实际情况 |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `session.content` 含 `[CQ:image,...]` | 实际是 HTML 形态 `<img src="..." summary="[动画表情]" sub-type="1"/>` |
|
|
16
|
+
| 元素 `type === 'image'` | 实际是 `type === 'img'` |
|
|
17
|
+
| 图片地址在 `url` / `src` | 实际在 `attrs.src` |
|
|
18
|
+
| 表情包靠 `[CQ:face]` | 实际靠 `attrs.subType === 1` / `summary="[动画表情]"` |
|
|
19
|
+
|
|
20
|
+
此外 `<img src>` 中的 `&` 是 HTML 实体,**未解码直接请求必然 404**。
|
|
21
|
+
|
|
22
|
+
修复后:
|
|
23
|
+
- 新增统一视觉解析器,同时兼容 CQ 码、HTML `<img>`、element 三种形态,并自动解码 HTML 实体;
|
|
24
|
+
- 图片会**下载并转为 base64 data URL** 再交给模型,不再依赖模型侧能否访问 QQ 图链(QQ 图链需 `rkey` 鉴权且有时效);
|
|
25
|
+
- 支持**「回复某张图片再 @bot」**:适配器已把被回复消息放在 `session.quote`,现在会从中取图;
|
|
26
|
+
- 表情包(`subType=1`)同样会被识别与理解。
|
|
27
|
+
|
|
28
|
+
### 2. 修复随机/阈值触发对纯图片、表情包无效
|
|
29
|
+
|
|
30
|
+
- 纯图片/表情消息此前会被「无文本」守卫直接丢弃,现改为参与兴趣/阈值判定;
|
|
31
|
+
- 兴趣判定提示词不再把「没有文字」等同于「无价值」,表情包/图片可作为正常互动被接话。
|
|
32
|
+
|
|
33
|
+
### 3. 修复兴趣判定长期失效(`reason=empty`)
|
|
34
|
+
|
|
35
|
+
`deepseek-flash` 等**带思考的模型**会先消耗 reasoning tokens。原兴趣判定只给 `max_tokens: 48`,额度被思考过程耗尽后正文为空,导致判定恒为 `score=0 reason=empty`、随机回复形同虚设。
|
|
36
|
+
|
|
37
|
+
- 额度提升至 512(OpenAI 兼容与 Gemini 两条路径同步修复);
|
|
38
|
+
- 正文仍为空时,回退从 `reasoning_content` 中提取 JSON。
|
|
39
|
+
|
|
40
|
+
### 4. 违禁词改为「整条消息评分」制
|
|
41
|
+
|
|
42
|
+
原实现是逐词 `includes` 精确包含,广告把词隔开即可绕过(如 `领红包` 无法命中 `领xxx元红包`)。
|
|
43
|
+
|
|
44
|
+
新机制:
|
|
45
|
+
- **顺序子序列匹配**:关键词字符按序出现即命中,允许中间隔词;
|
|
46
|
+
- **置信度**:按「词长归一化的间隔溢出」衰减,跨度超过词长 3 倍或溢出超过 8 字则判定为偶然凑齐、直接拒绝(可拦住 `领 导 强 调 红 色 包 装` 这类);
|
|
47
|
+
- **词长自适应门槛**:短词(2~4 字)需要更高置信度,降低无关文本误伤;
|
|
48
|
+
- **可配置权重**:`"关键词|分值"` 形式,未指定时按内置分档表取值(显式广告词如 `扫码进群` 80 分,营销组合词如 `赚钱` 35 分);
|
|
49
|
+
- **整条消息累计得分 ≥ `bannedWordScoreThreshold`(默认 70)** 才触发管控(撤回/禁言/踢人),日志会输出命中明细与得分。
|
|
50
|
+
|
|
51
|
+
> 局限说明:字符级匹配无法区分「字符顺序与间隔完全一致」的两个串。
|
|
52
|
+
> 例如 `领xxx元红包`(广告)与 `领导说把红色包装袋收好`(正常)跨度均为 7、置信度均为 0.667,属于原理性边界,可通过调整阈值与权重缓解。
|
|
53
|
+
|
|
7
54
|
## 禁言指令优化(v0.0.4)
|
|
8
55
|
|
|
9
56
|
`群管 mute` 不再依赖固定顺序解析参数,改为智能解析,避免「禁言时长被识别成 QQ 号」:
|