@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 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.replace(/\[CQ:[^\]]+\]/g, ' ').trim();
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.length ? ` [图片:${item.imageUrls.length}张]` : '';
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
- max_tokens: 48,
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: `请判断是否值得回复这条消息(从严,降低热度)。以下是消息与上下文:\n${contextPrompt}`,
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 content = payload?.choices?.[0]?.message?.content;
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
- const plain = typeof content === 'string'
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: `请判断是否值得回复这条消息(从严,降低热度)。以下是消息与上下文:\n${contextPrompt}` }] }],
701
+ contents: [{ role: 'user', parts: [{ text: `请判断是否值得回复这条消息。表情包/图片是群聊中正常的互动内容,不要因为「没有文字」就判定为无价值;仅在与本群无关且明显无意义刷屏时才不回复。以下是消息与上下文:\n${contextPrompt}` }] }],
675
702
  generationConfig: {
676
703
  temperature: 0.15,
677
- maxOutputTokens: 64,
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
- isImageSendableUrl(value) {
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
- extractMessageImageUrls(session, maxCount = 4) {
1009
- const urls = [];
1010
- const content = session.content ?? '';
1011
- const cqPattern = /\[CQ:image,[^\]]*\]/gi;
1012
- const cqList = content.match(cqPattern) ?? [];
1013
- for (const chunk of cqList) {
1014
- const match = chunk.match(/(?:url|src)=([^,\]]+)/i);
1015
- let value = match ? decodeURIComponent(match[1].trim()) : '';
1016
- if (!this.isImageSendableUrl(value)) {
1017
- // 部分适配器把 url 放在 file 字段
1018
- const fm = chunk.match(/file=([^,\]]+)/i);
1019
- if (fm) {
1020
- const f = decodeURIComponent(fm[1].trim());
1021
- if (this.isImageSendableUrl(f))
1022
- value = f;
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 (!this.isImageSendableUrl(value))
1026
- continue;
1027
- urls.push(value);
1028
- if (urls.length >= maxCount)
1029
- return urls;
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
- const elements = session.elements ?? [];
1032
- for (const element of elements) {
1033
- if (element.type !== 'image')
1034
- continue;
1035
- const maybeUrl = (typeof element.url === 'string' && element.url)
1036
- || (typeof element.src === 'string' && element.src)
1037
- || (typeof element.attrs?.url === 'string' && element.attrs.url)
1038
- || (typeof element.attrs?.src === 'string' && element.attrs.src);
1039
- if (!this.isImageSendableUrl(maybeUrl))
1040
- continue;
1041
- urls.push(maybeUrl);
1042
- if (urls.length >= maxCount)
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(/&amp;/gi, '&')
1272
+ .replace(/&lt;/gi, '<')
1273
+ .replace(/&gt;/gi, '>')
1274
+ .replace(/&quot;/gi, '"')
1275
+ .replace(/&#0?39;/g, "'")
1276
+ .replace(/&apos;/gi, "'")
1277
+ .replace(/&#x2f;/gi, '/')
1278
+ .replace(/&#0?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 [...new Set(urls)].slice(0, maxCount);
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,[^\]]+\]/gi) ?? []);
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
- if (el?.type !== 'image')
1392
+ const t = String(el?.type ?? '').toLowerCase();
1393
+ if (t !== 'image' && t !== 'img' && t !== 'face' && t !== 'sticker' && t !== 'mface')
1053
1394
  continue;
1054
- const fields = {
1395
+ const attrs = el.attrs ?? {};
1396
+ elems.push({
1055
1397
  type: el.type,
1056
- url: el.url || el.attrs?.url || '',
1057
- src: el.src || el.attrs?.src || '',
1058
- file: el.file || el.attrs?.file || '',
1059
- fileId: el.fileId || el.file_id || el.attrs?.file_id || '',
1060
- data: el.data ? String(el.data).slice(0, 40) : (el.attrs?.data ? String(el.attrs.data).slice(0, 40) : ''),
1061
- };
1062
- elems.push(fields);
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.extractMessageImageUrls(session, 10),
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} urls=${JSON.stringify(info.extractableUrls)} cqRaw=${JSON.stringify(info.rawCq.slice(0, 2))} elems=${JSON.stringify(info.elementImages).slice(0, 300)} | ${this.sessionTag(session)}`);
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 ?? []).filter((url) => /^https?:\/\//i.test(url));
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 hasImage = /\[CQ:image,/i.test(session.content ?? '')
1666
- || (session.elements ?? []).some((el) => el?.type === 'image');
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
- if (!text && !directMention && !followupForce && !relatedContextFollowup)
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) ? (text || '@bot') : undefined,
2119
+ focusText: (directMention || followupForce || relatedContextFollowup) ? (effectiveText || '@bot') : undefined,
1747
2120
  focusUserId: (directMention || followupForce || relatedContextFollowup) ? session.userId : undefined,
1748
- focusImages: messageImages,
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 ?? '').replace(/\[CQ:[^\]]+\]/g, ' ');
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
- return (session.elements ?? []).some((el) => el.type === 'image' || el.type === 'face' || el.type === 'record' || el.type === 'video' || el.type === 'flash');
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
- detectViolation(session, policy) {
2678
- // 防误判:纯图片/表情/贴纸(无实际文字内容)的消息不作为违规处理,避免表情包被当成违禁词
2679
- if (this.hasImageOrFace(session) && !this.hasTextContent(session)) {
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
- for (const word of policy.bannedWords) {
2692
- const keyword = word?.trim();
2693
- if (!keyword)
2694
- continue;
2695
- if (text.includes(keyword)) {
2696
- return { type: 'banned-word', detail: `命中违禁词: ${keyword}` };
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
- this._logger.warn(`[moderation] type=${violation.type} detail="${violation.detail}" deleted=${deleted} | ${this.sessionTag(session)}`);
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.5",
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
- # koishi-qq-group-manager
1
+ # koishi-qq-group-manager
2
2
 
3
- [![npm](https://img.shields.io/npm/v/%40meownestim%2Fkoishi-qq-group-manager?style=flat-square)](https://www.npmjs.com/package/@nestim/koishi-qq-group-manager)
3
+ [![npm](https://img.shields.io/npm/v/%40nestim%2Fkoishi-qq-group-manager?style=flat-square)](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>` 中的 `&amp;` 是 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 号」: