koishi-plugin-ban-qrcode 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/detect.d.ts CHANGED
@@ -9,6 +9,7 @@ export type RoleLike = string | {
9
9
  };
10
10
  export interface ModerateInput {
11
11
  guildId?: string;
12
+ channelId?: string;
12
13
  isDirect?: boolean;
13
14
  userId?: string;
14
15
  selfId?: string;
@@ -22,6 +23,7 @@ export interface QrHit {
22
23
  text: string;
23
24
  }
24
25
  export type HitReason = 'qrcode' | 'group-invite' | 'ad-doc';
26
+ export type SkipReason = 'direct' | 'no-guild' | 'no-user' | 'guild-filter' | 'self' | 'ignored' | 'admin';
25
27
  export interface FileRef {
26
28
  src: string;
27
29
  name: string;
@@ -39,9 +41,17 @@ export declare function collectImageSrcs(nodes: readonly ImageNode[]): string[];
39
41
  export declare function isOfficeFile(name: string): boolean;
40
42
  export declare function collectMessageParts(nodes: readonly ImageNode[], extra?: string): MessageParts;
41
43
  export declare function collectUrls(text: string): string[];
44
+ export declare function unescapePayload(text: string): string;
42
45
  export declare function decodeEntities(text: string): string;
46
+ export declare function stringifyShare(raw: unknown): string | undefined;
47
+ export declare function isDownloadableSrc(src: string): boolean;
48
+ export declare function summarizeSrc(src: string): string;
49
+ export declare function elementTypes(nodes: readonly ImageNode[]): string[];
50
+ export declare function resolveGuildId(input: Pick<ModerateInput, 'guildId' | 'channelId' | 'isDirect'>): string | undefined;
51
+ export declare function skipModerateReason(input: ModerateInput): SkipReason | null;
43
52
  export declare function shouldModerate(input: ModerateInput): boolean;
44
53
  export declare function muteDurationMs(seconds: number): number;
45
54
  export declare function parseDataUrl(src: string): Buffer | null;
46
55
  export declare function defaultNotify(muteSeconds: number, reason?: HitReason): string;
47
- export declare function findQrInImages(srcs: readonly string[], download: (src: string) => Promise<Buffer>, decode: (buffer: Buffer) => Promise<string | null>): Promise<QrHit | null>;
56
+ export type QrImageStatus = 'hit' | 'miss' | 'error';
57
+ export declare function findQrInImages(srcs: readonly string[], download: (src: string) => Promise<Buffer>, decode: (buffer: Buffer) => Promise<string | null>, onImage?: (src: string, status: QrImageStatus) => void): Promise<QrHit | null>;
package/lib/detect.js CHANGED
@@ -6,7 +6,14 @@ exports.collectImageSrcs = collectImageSrcs;
6
6
  exports.isOfficeFile = isOfficeFile;
7
7
  exports.collectMessageParts = collectMessageParts;
8
8
  exports.collectUrls = collectUrls;
9
+ exports.unescapePayload = unescapePayload;
9
10
  exports.decodeEntities = decodeEntities;
11
+ exports.stringifyShare = stringifyShare;
12
+ exports.isDownloadableSrc = isDownloadableSrc;
13
+ exports.summarizeSrc = summarizeSrc;
14
+ exports.elementTypes = elementTypes;
15
+ exports.resolveGuildId = resolveGuildId;
16
+ exports.skipModerateReason = skipModerateReason;
10
17
  exports.shouldModerate = shouldModerate;
11
18
  exports.muteDurationMs = muteDurationMs;
12
19
  exports.parseDataUrl = parseDataUrl;
@@ -38,7 +45,16 @@ function roleLabels(roles) {
38
45
  function isAdminRole(roles) {
39
46
  return roleLabels(roles).some(role => ADMIN_ROLES.has(role) || role.toLowerCase() === 'owner');
40
47
  }
41
- const SHARE_TYPES = new Set(['json', 'xml', 'share', 'ark', 'lightapp']);
48
+ const SHARE_TYPES = new Set([
49
+ 'json',
50
+ 'xml',
51
+ 'share',
52
+ 'ark',
53
+ 'lightapp',
54
+ 'onebot:json',
55
+ 'onebot:xml',
56
+ 'onebot:share',
57
+ ]);
42
58
  const FILE_TYPES = new Set(['file']);
43
59
  const OFFICE_FILE = /\.(docx?|txt)$/i;
44
60
  function collectImageSrcs(nodes) {
@@ -52,17 +68,17 @@ function collectMessageParts(nodes, extra = '') {
52
68
  const shares = [];
53
69
  const files = [];
54
70
  const texts = [];
71
+ const extraUrls = [];
55
72
  const seenImage = new Set();
56
73
  const seenShare = new Set();
57
74
  const seenFile = new Set();
58
75
  const addShare = (raw) => {
59
- if (typeof raw !== 'string' || !raw.trim())
60
- return;
61
- const value = raw.trim();
62
- if (seenShare.has(value))
76
+ const value = stringifyShare(raw);
77
+ if (!value || seenShare.has(value))
63
78
  return;
64
79
  seenShare.add(value);
65
80
  shares.push(value);
81
+ extraUrls.push(...collectUrlsFromJson(raw), ...collectUrls(value));
66
82
  };
67
83
  const visit = (items) => {
68
84
  for (const node of items) {
@@ -96,27 +112,30 @@ function collectMessageParts(nodes, extra = '') {
96
112
  visit(nodes);
97
113
  if (extra) {
98
114
  texts.push(extra);
115
+ const normalized = unescapePayload(extra);
99
116
  for (const match of extra.matchAll(/\[(?:CQ:)?json(?:,data=|:data=)([\s\S]+?)\]/gi))
100
117
  addShare(match[1]);
101
118
  for (const match of extra.matchAll(/\[(?:CQ:)?xml(?:,data=|:data=)([\s\S]+?)\]/gi))
102
119
  addShare(match[1]);
103
- for (const match of extra.matchAll(/<(?:json|xml)\s[^>]*\bdata="([^"]+)"/gi))
120
+ for (const match of extra.matchAll(/<(?:json|xml|onebot:json|onebot:xml)\s[^>]*\bdata="([^"]+)"/gi)) {
104
121
  addShare(decodeEntities(match[1]));
122
+ }
123
+ if (/^\s*\{[\s\S]*"app"\s*:/.test(normalized))
124
+ addShare(normalized);
105
125
  }
106
126
  const text = texts.join('\n');
107
- return {
108
- images,
109
- shares,
110
- files,
111
- urls: collectUrls([text, ...shares].join('\n')),
112
- text,
113
- };
127
+ const urls = uniqueUrls([
128
+ ...collectUrls(text),
129
+ ...extraUrls,
130
+ ...shares.flatMap(collectUrls),
131
+ ]);
132
+ return { images, shares, files, urls, text };
114
133
  }
115
134
  function collectUrls(text) {
116
135
  const urls = [];
117
136
  const seen = new Set();
118
- for (const match of text.matchAll(/https?:\/\/[^\s<>"'`]+/gi)) {
119
- const url = decodeEntities(match[0]).replace(/[),.;]+$/, '');
137
+ for (const match of unescapePayload(text).matchAll(/https?:\/\/[^\s<>"'`\\]+/gi)) {
138
+ const url = match[0].replace(/[),.;]+$/, '');
120
139
  if (!url || seen.has(url))
121
140
  continue;
122
141
  seen.add(url);
@@ -124,6 +143,9 @@ function collectUrls(text) {
124
143
  }
125
144
  return urls;
126
145
  }
146
+ function unescapePayload(text) {
147
+ return decodeEntities(text).replace(/\\\//g, '/');
148
+ }
127
149
  function decodeEntities(text) {
128
150
  return text
129
151
  .replace(/&quot;/g, '"')
@@ -132,24 +154,103 @@ function decodeEntities(text) {
132
154
  .replace(/&lt;/g, '<')
133
155
  .replace(/&gt;/g, '>');
134
156
  }
157
+ function stringifyShare(raw) {
158
+ if (raw && typeof raw === 'object') {
159
+ try {
160
+ return JSON.stringify(raw);
161
+ }
162
+ catch {
163
+ return undefined;
164
+ }
165
+ }
166
+ if (typeof raw !== 'string' || !raw.trim())
167
+ return undefined;
168
+ return unescapePayload(raw.trim());
169
+ }
170
+ function isDownloadableSrc(src) {
171
+ return /^(https?:|data:|file:)/i.test(src);
172
+ }
173
+ function summarizeSrc(src) {
174
+ if (src.startsWith('data:'))
175
+ return `data:(${src.length})`;
176
+ return src.length > 160 ? `${src.slice(0, 157)}...` : src;
177
+ }
178
+ function elementTypes(nodes) {
179
+ return nodes.map(node => node.type);
180
+ }
135
181
  function pickSrc(attrs) {
136
- const src = attrs?.src ?? attrs?.url;
137
- return typeof src === 'string' && src.trim() ? src.trim() : undefined;
182
+ const values = [attrs?.src, attrs?.url, attrs?.file]
183
+ .filter((value) => typeof value === 'string' && Boolean(value.trim()))
184
+ .map(value => value.trim());
185
+ return values.find(isDownloadableSrc) ?? values[0];
138
186
  }
139
- function shouldModerate(input) {
187
+ function collectUrlsFromJson(raw) {
188
+ let value = raw;
189
+ if (typeof raw === 'string') {
190
+ try {
191
+ value = JSON.parse(unescapePayload(raw));
192
+ }
193
+ catch {
194
+ return [];
195
+ }
196
+ }
197
+ const urls = [];
198
+ walkJson(value, urls);
199
+ return urls;
200
+ }
201
+ function walkJson(value, urls) {
202
+ if (typeof value === 'string') {
203
+ urls.push(...collectUrls(value));
204
+ return;
205
+ }
206
+ if (Array.isArray(value)) {
207
+ for (const item of value)
208
+ walkJson(item, urls);
209
+ return;
210
+ }
211
+ if (value && typeof value === 'object') {
212
+ for (const item of Object.values(value))
213
+ walkJson(item, urls);
214
+ }
215
+ }
216
+ function uniqueUrls(urls) {
217
+ const seen = new Set();
218
+ const result = [];
219
+ for (const url of urls) {
220
+ if (!url || seen.has(url))
221
+ continue;
222
+ seen.add(url);
223
+ result.push(url);
224
+ }
225
+ return result;
226
+ }
227
+ function resolveGuildId(input) {
228
+ if (input.guildId)
229
+ return input.guildId;
230
+ if (!input.isDirect && input.channelId && !input.channelId.startsWith('private:'))
231
+ return input.channelId;
232
+ return undefined;
233
+ }
234
+ function skipModerateReason(input) {
140
235
  if (input.isDirect)
141
- return false;
142
- if (!input.guildId || !input.userId)
143
- return false;
144
- if (input.guilds.length && !input.guilds.includes(input.guildId))
145
- return false;
236
+ return 'direct';
237
+ if (!resolveGuildId(input))
238
+ return 'no-guild';
239
+ if (!input.userId)
240
+ return 'no-user';
241
+ const guildId = resolveGuildId(input);
242
+ if (input.guilds.length && !input.guilds.includes(guildId))
243
+ return 'guild-filter';
146
244
  if (input.userId === input.selfId)
147
- return false;
245
+ return 'self';
148
246
  if (input.ignoreUsers.includes(input.userId))
149
- return false;
247
+ return 'ignored';
150
248
  if (input.skipAdmins && isAdminRole(input.roles))
151
- return false;
152
- return true;
249
+ return 'admin';
250
+ return null;
251
+ }
252
+ function shouldModerate(input) {
253
+ return skipModerateReason(input) === null;
153
254
  }
154
255
  function muteDurationMs(seconds) {
155
256
  return Math.max(0, Math.floor(seconds)) * 1000;
@@ -176,15 +277,18 @@ function defaultNotify(muteSeconds, reason = 'qrcode') {
176
277
  return `检测到${label},已撤回。`;
177
278
  return `检测到${label},已撤回并禁言 ${muteSeconds} 秒。`;
178
279
  }
179
- async function findQrInImages(srcs, download, decode) {
280
+ async function findQrInImages(srcs, download, decode, onImage) {
180
281
  for (const src of srcs) {
181
282
  try {
182
283
  const text = await decode(await download(src));
183
- if (text !== null)
284
+ if (text !== null) {
285
+ onImage?.(src, 'hit');
184
286
  return { src, text };
287
+ }
288
+ onImage?.(src, 'miss');
185
289
  }
186
290
  catch {
187
- // skip a single failed image
291
+ onImage?.(src, 'error');
188
292
  }
189
293
  }
190
294
  return null;
package/lib/document.js CHANGED
@@ -26,7 +26,7 @@ function collectTencentDocUrls(text) {
26
26
  const urls = [];
27
27
  const seen = new Set();
28
28
  const re = new RegExp(DOC_RE.source, 'gi');
29
- for (const match of text.matchAll(re)) {
29
+ for (const match of (0, detect_1.unescapePayload)(text).matchAll(re)) {
30
30
  const parsed = parseTencentDocUrl(match[0]);
31
31
  if (!parsed || seen.has(parsed.pageUrl))
32
32
  continue;
package/lib/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Context, Schema } from 'koishi';
2
2
  export declare const name = "ban-qrcode";
3
3
  export declare const inject: string[];
4
- export declare const usage = "\n\u7FA4\u5185\u68C0\u6D4B\u5230\u56FE\u7247\u4E8C\u7EF4\u7801\u3001\u62C9\u7FA4\u5206\u4EAB\u5361\uFF0C\u6216\u817E\u8BAF\u6587\u6863 / Word \u91CC\u7684\u5356\u8D27\u5E7F\u544A\u65F6\uFF0C\u81EA\u52A8\u64A4\u56DE\u5E76\u7981\u8A00\u53D1\u9001\u8005\uFF08\u9ED8\u8BA4 60 \u79D2\uFF09\u3002\n\n\u673A\u5668\u4EBA\u9700\u8981\u64A4\u56DE\u6D88\u606F\u548C\u7981\u8A00\u6210\u5458\u7684\u6743\u9650\u3002\u7FA4\u4E3B / \u7BA1\u7406\u5458\u9ED8\u8BA4\u8DF3\u8FC7\u3002\n";
4
+ export declare const usage = "\n\u7FA4\u5185\u68C0\u6D4B\u5230\u56FE\u7247\u4E8C\u7EF4\u7801\u3001\u62C9\u7FA4\u5206\u4EAB\u5361\uFF0C\u6216\u817E\u8BAF\u6587\u6863 / Word \u91CC\u7684\u5356\u8D27\u5E7F\u544A\u65F6\uFF0C\u81EA\u52A8\u64A4\u56DE\u5E76\u7981\u8A00\u53D1\u9001\u8005\uFF08\u9ED8\u8BA4 60 \u79D2\uFF09\u3002\n\n\u673A\u5668\u4EBA\u9700\u8981\u64A4\u56DE\u6D88\u606F\u548C\u7981\u8A00\u6210\u5458\u7684\u6743\u9650\u3002\u7FA4\u4E3B / \u7BA1\u7406\u5458\u9ED8\u8BA4\u8DF3\u8FC7\u3002\n\n\u81EA\u6D4B\u6CA1\u53CD\u5E94\u65F6\u6253\u5F00 debug\uFF0C\u770B\u65E5\u5FD7\u91CC\u662F skip admin\u3001\u56FE\u7247\u6CA1\u4E0B\u4E0B\u6765\uFF0C\u8FD8\u662F\u626B\u7801/\u6587\u6863\u672A\u547D\u4E2D\u3002\n";
5
5
  export interface Config {
6
6
  muteSeconds: number;
7
7
  recall: boolean;
@@ -14,6 +14,7 @@ export interface Config {
14
14
  scanGroupInvite: boolean;
15
15
  scanDocs: boolean;
16
16
  adKeywords: string[];
17
+ debug: boolean;
17
18
  }
18
19
  export declare const Config: Schema<Config>;
19
20
  export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -14,26 +14,31 @@ exports.usage = `
14
14
  群内检测到图片二维码、拉群分享卡,或腾讯文档 / Word 里的卖货广告时,自动撤回并禁言发送者(默认 60 秒)。
15
15
 
16
16
  机器人需要撤回消息和禁言成员的权限。群主 / 管理员默认跳过。
17
+
18
+ 自测没反应时打开 debug,看日志里是 skip admin、图片没下下来,还是扫码/文档未命中。
17
19
  `;
18
20
  exports.Config = koishi_1.Schema.object({
19
21
  muteSeconds: koishi_1.Schema.number().min(0).default(60).description('禁言秒数。0 表示只撤回不禁言。'),
20
22
  recall: koishi_1.Schema.boolean().default(true).description('撤回违规消息。'),
21
23
  notify: koishi_1.Schema.boolean().default(true).description('处理后在群内发送提示。'),
22
24
  notifyText: koishi_1.Schema.string().default('').description('自定义提示。留空则按原因和禁言秒数生成。'),
23
- skipAdmins: koishi_1.Schema.boolean().default(true).description('跳过群主和管理员。'),
25
+ skipAdmins: koishi_1.Schema.boolean().default(true).description('跳过群主和管理员。自测请先关掉,或用普通成员号发。'),
24
26
  ignoreUsers: koishi_1.Schema.array(koishi_1.Schema.string()).role('table').default([]).description('忽略的用户 ID。'),
25
27
  guilds: koishi_1.Schema.array(koishi_1.Schema.string()).role('table').default([]).description('只在这些群生效。留空表示全部群。'),
26
28
  scanQrcode: koishi_1.Schema.boolean().default(true).description('扫描图片二维码。'),
27
29
  scanGroupInvite: koishi_1.Schema.boolean().default(true).description('拦截邀请 / 推荐群聊分享卡。'),
28
30
  scanDocs: koishi_1.Schema.boolean().default(true).description('检查腾讯文档和 Word / 文本附件里的广告。'),
29
31
  adKeywords: koishi_1.Schema.array(koishi_1.Schema.string()).role('table').default([]).description('额外广告关键词。命中即撤回。'),
32
+ debug: koishi_1.Schema.boolean().default(true).description('输出调试日志:跳过原因、消息结构、下载/扫码/文档结果。'),
30
33
  });
31
34
  function apply(ctx, config) {
32
35
  const logger = ctx.logger('ban-qrcode');
33
36
  const docHttp = createDocHttp(ctx.http);
37
+ void (0, qrcode_1.warmupQrDecoder)().catch(error => logger.warn(error));
34
38
  ctx.on('message', async (session) => {
35
- if (!(0, detect_1.shouldModerate)({
39
+ const moderate = {
36
40
  guildId: session.guildId,
41
+ channelId: session.channelId,
37
42
  isDirect: session.isDirect,
38
43
  userId: session.userId,
39
44
  selfId: session.selfId,
@@ -41,41 +46,80 @@ function apply(ctx, config) {
41
46
  guilds: config.guilds,
42
47
  roles: session.event.member?.roles,
43
48
  skipAdmins: config.skipAdmins,
44
- }))
45
- return;
49
+ };
46
50
  const parts = (0, detect_1.collectMessageParts)(session.elements ?? [], session.content ?? '');
47
- const docUrls = config.scanDocs ? (0, document_1.collectTencentDocUrls)([parts.text, ...parts.shares, ...parts.urls].join('\n')) : [];
51
+ const skip = (0, detect_1.skipModerateReason)(moderate);
52
+ if (skip) {
53
+ if (config.debug && looksRelevant(parts, config)) {
54
+ logger.info('skip %s user=%s guild=%s roles=%j images=%d shares=%d files=%d types=%j', skip, session.userId, (0, detect_1.resolveGuildId)(moderate), (0, detect_1.roleLabels)(moderate.roles), parts.images.length, parts.shares.length, parts.files.length, (0, detect_1.elementTypes)(session.elements ?? []));
55
+ }
56
+ return;
57
+ }
58
+ const docUrls = config.scanDocs
59
+ ? (0, document_1.collectTencentDocUrls)([parts.text, ...parts.shares, ...parts.urls].join('\n'))
60
+ : [];
48
61
  const officeFiles = config.scanDocs ? parts.files.filter(file => (0, detect_1.isOfficeFile)(file.name)) : [];
62
+ const docCards = config.scanDocs ? parts.shares.filter(share_1.isTencentDocCard) : [];
63
+ const docCardText = docCards.map(share_1.extractShareCardText).filter(Boolean).join('\n');
49
64
  const needInvite = config.scanGroupInvite && parts.shares.some(share_1.isGroupInviteCard);
50
65
  const needQr = config.scanQrcode && parts.images.length > 0;
51
- const needDocs = Boolean(docUrls.length || officeFiles.length);
66
+ const needDocs = Boolean(docUrls.length || officeFiles.length || docCards.length);
67
+ if (config.debug) {
68
+ logger.info('seen user=%s guild=%s types=%j images=%d shares=%d files=%d docs=%d invite=%s', session.userId, (0, detect_1.resolveGuildId)(moderate), (0, detect_1.elementTypes)(session.elements ?? []), parts.images.length, parts.shares.length, parts.files.length, docUrls.length + officeFiles.length, needInvite);
69
+ }
52
70
  if (!needInvite && !needQr && !needDocs)
53
71
  return;
72
+ const resolveFile = (0, qrcode_1.createFileResolver)(session.bot);
73
+ const download = (src) => (0, qrcode_1.downloadImage)(ctx.http, src, resolveFile);
54
74
  try {
55
75
  if (needInvite) {
56
76
  await enforce(session, config, logger, 'group-invite');
57
77
  return;
58
78
  }
59
79
  if (needQr) {
60
- const hit = await (0, detect_1.findQrInImages)(parts.images, src => (0, qrcode_1.downloadImage)(ctx.http, src), qrcode_1.decodeQr);
61
- if (hit) {
62
- await enforce(session, config, logger, 'qrcode');
63
- return;
80
+ try {
81
+ const hit = await (0, detect_1.findQrInImages)(parts.images, download, qrcode_1.decodeQr, (src, status) => {
82
+ if (config.debug)
83
+ logger.info('qr %s %s', status, (0, detect_1.summarizeSrc)(src));
84
+ });
85
+ if (hit) {
86
+ await enforce(session, config, logger, 'qrcode');
87
+ return;
88
+ }
89
+ }
90
+ catch (error) {
91
+ logger.warn(error);
64
92
  }
65
93
  }
66
- if (!needDocs)
94
+ if (!needDocs) {
95
+ if (config.debug)
96
+ logger.info('no-hit user=%s guild=%s', session.userId, (0, detect_1.resolveGuildId)(moderate));
67
97
  return;
98
+ }
99
+ let fetchedDoc = false;
68
100
  for (const url of docUrls) {
69
101
  try {
70
102
  const doc = await (0, document_1.fetchTencentDoc)(docHttp, url);
71
- if (!doc)
103
+ if (!doc) {
104
+ if (config.debug)
105
+ logger.info('doc empty %s', url);
72
106
  continue;
73
- if ((0, ad_1.detectAdContent)(doc.text, doc.title, config.adKeywords)) {
107
+ }
108
+ if (doc.text.trim())
109
+ fetchedDoc = true;
110
+ const ad = (0, ad_1.detectAdContent)(doc.text, doc.title, config.adKeywords);
111
+ if (config.debug) {
112
+ logger.info('doc %s title=%s ad=%s images=%d', url, doc.title || '-', Boolean(ad), doc.images.length);
113
+ }
114
+ if (ad) {
74
115
  await enforce(session, config, logger, 'ad-doc');
75
116
  return;
76
117
  }
77
118
  if (config.scanQrcode && doc.images.length) {
78
- const qr = await (0, detect_1.findQrInImages)(doc.images, src => (0, qrcode_1.downloadImage)(ctx.http, src), qrcode_1.decodeQr);
119
+ const qr = await (0, detect_1.findQrInImages)(doc.images, download, qrcode_1.decodeQr, (src, status) => {
120
+ if (config.debug)
121
+ logger.info('doc-qr %s %s', status, (0, detect_1.summarizeSrc)(src));
122
+ });
79
123
  if (qr) {
80
124
  await enforce(session, config, logger, 'qrcode');
81
125
  return;
@@ -83,21 +127,39 @@ function apply(ctx, config) {
83
127
  }
84
128
  }
85
129
  catch (error) {
130
+ if (config.debug)
131
+ logger.info('doc error %s', url);
86
132
  logger.warn(error);
87
133
  }
88
134
  }
135
+ if (!fetchedDoc && docCardText) {
136
+ const ad = (0, ad_1.detectAdContent)(docCardText, '', config.adKeywords);
137
+ if (config.debug)
138
+ logger.info('doc card ad=%s', Boolean(ad));
139
+ if (ad) {
140
+ await enforce(session, config, logger, 'ad-doc');
141
+ return;
142
+ }
143
+ }
89
144
  for (const file of officeFiles) {
90
145
  try {
91
- const doc = (0, document_1.extractOfficeText)(await (0, qrcode_1.downloadImage)(ctx.http, file.src), file.name);
92
- if (doc && (0, ad_1.detectAdContent)(doc.text, doc.title || file.name, config.adKeywords)) {
146
+ const doc = (0, document_1.extractOfficeText)(await download(file.src), file.name);
147
+ const ad = doc ? (0, ad_1.detectAdContent)(doc.text, doc.title || file.name, config.adKeywords) : null;
148
+ if (config.debug)
149
+ logger.info('office %s ad=%s', file.name, Boolean(ad));
150
+ if (doc && ad) {
93
151
  await enforce(session, config, logger, 'ad-doc');
94
152
  return;
95
153
  }
96
154
  }
97
155
  catch (error) {
156
+ if (config.debug)
157
+ logger.info('office error %s', file.name);
98
158
  logger.warn(error);
99
159
  }
100
160
  }
161
+ if (config.debug)
162
+ logger.info('no-hit user=%s guild=%s', session.userId, (0, detect_1.resolveGuildId)(moderate));
101
163
  }
102
164
  catch (error) {
103
165
  logger.warn(error);
@@ -105,28 +167,51 @@ function apply(ctx, config) {
105
167
  });
106
168
  }
107
169
  async function enforce(session, config, logger, reason) {
108
- logger.info('%s from %s in %s', reason, session.userId, session.guildId);
170
+ const guildId = (0, detect_1.resolveGuildId)(session);
171
+ logger.info('%s from %s in %s', reason, session.userId, guildId);
109
172
  if (config.recall && session.messageId && session.channelId) {
110
173
  try {
111
174
  await session.bot.deleteMessage(session.channelId, session.messageId);
175
+ if (config.debug)
176
+ logger.info('recall ok %s', session.messageId);
112
177
  }
113
178
  catch (error) {
114
179
  logger.warn(error);
115
180
  }
116
181
  }
182
+ else if (config.debug && config.recall) {
183
+ logger.info('recall skipped messageId=%s channelId=%s', session.messageId, session.channelId);
184
+ }
117
185
  const duration = (0, detect_1.muteDurationMs)(config.muteSeconds);
118
- if (duration > 0 && session.guildId && session.userId) {
186
+ if (duration > 0 && guildId && session.userId) {
119
187
  try {
120
- await session.bot.muteGuildMember(session.guildId, session.userId, duration);
188
+ await session.bot.muteGuildMember(guildId, session.userId, duration);
189
+ if (config.debug)
190
+ logger.info('mute ok %s %sms', session.userId, duration);
121
191
  }
122
192
  catch (error) {
123
193
  logger.warn(error);
124
194
  }
125
195
  }
196
+ else if (config.debug && duration > 0) {
197
+ logger.info('mute skipped guild=%s user=%s', guildId, session.userId);
198
+ }
126
199
  if (config.notify) {
127
200
  await session.send(config.notifyText || (0, detect_1.defaultNotify)(config.muteSeconds, reason));
128
201
  }
129
202
  }
203
+ function looksRelevant(parts, config) {
204
+ if (config.scanQrcode && parts.images.length)
205
+ return true;
206
+ if (config.scanGroupInvite && parts.shares.length)
207
+ return true;
208
+ if (config.scanDocs && (parts.files.some(file => (0, detect_1.isOfficeFile)(file.name))
209
+ || (0, document_1.collectTencentDocUrls)([parts.text, ...parts.shares, ...parts.urls].join('\n')).length
210
+ || parts.shares.some(share_1.isTencentDocCard))) {
211
+ return true;
212
+ }
213
+ return false;
214
+ }
130
215
  function createDocHttp(http) {
131
216
  return {
132
217
  async text(url, headers) {
package/lib/qrcode.d.ts CHANGED
@@ -1,3 +1,18 @@
1
1
  import { HTTP } from 'koishi';
2
- export declare function downloadImage(http: HTTP, src: string): Promise<Buffer>;
2
+ type WechatScan = (input: {
3
+ data: Uint8ClampedArray;
4
+ width: number;
5
+ height: number;
6
+ }) => Promise<{
7
+ text?: string | null;
8
+ } | null | undefined>;
9
+ type FileInternal = {
10
+ getImage?(src: string): unknown;
11
+ };
12
+ export declare function createFileResolver(bot: {
13
+ internal?: FileInternal;
14
+ }): (src: string) => Promise<Buffer | string | null>;
15
+ export declare function downloadImage(http: HTTP, src: string, resolveFile?: (src: string) => Promise<Buffer | string | null>): Promise<Buffer>;
3
16
  export declare function decodeQr(buffer: Buffer): Promise<string | null>;
17
+ export declare function warmupQrDecoder(): Promise<WechatScan | null>;
18
+ export {};
package/lib/qrcode.js CHANGED
@@ -3,25 +3,129 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createFileResolver = createFileResolver;
6
7
  exports.downloadImage = downloadImage;
7
8
  exports.decodeQr = decodeQr;
9
+ exports.warmupQrDecoder = warmupQrDecoder;
10
+ const promises_1 = require("node:fs/promises");
11
+ const node_url_1 = require("node:url");
8
12
  const jimp_1 = __importDefault(require("jimp"));
9
13
  const jsqr_1 = __importDefault(require("jsqr"));
10
14
  const detect_1 = require("./detect");
11
- async function downloadImage(http, src) {
15
+ let wechatScan;
16
+ let wechatLoad;
17
+ function createFileResolver(bot) {
18
+ return async (src) => {
19
+ const internal = bot.internal;
20
+ if (!internal)
21
+ return null;
22
+ try {
23
+ const info = unwrapFileInfo(await internal.getImage?.(src));
24
+ if (!info)
25
+ return null;
26
+ if (typeof info.base64 === 'string' && info.base64) {
27
+ return Buffer.from(info.base64, 'base64');
28
+ }
29
+ if (typeof info.url === 'string' && (0, detect_1.isDownloadableSrc)(info.url))
30
+ return info.url;
31
+ if (typeof info.file === 'string' && info.file) {
32
+ if ((0, detect_1.isDownloadableSrc)(info.file))
33
+ return info.file;
34
+ return (0, promises_1.readFile)(info.file);
35
+ }
36
+ return null;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ };
42
+ }
43
+ async function downloadImage(http, src, resolveFile) {
44
+ let lastError;
45
+ const remote = /^https?:/i.test(src);
46
+ if ((0, detect_1.isDownloadableSrc)(src)) {
47
+ try {
48
+ return await downloadDirect(http, src);
49
+ }
50
+ catch (error) {
51
+ lastError = error;
52
+ }
53
+ if (!remote) {
54
+ throw lastError instanceof Error ? lastError : new Error('cannot download image');
55
+ }
56
+ }
57
+ if (resolveFile) {
58
+ try {
59
+ const resolved = await resolveFile(src);
60
+ if (Buffer.isBuffer(resolved))
61
+ return resolved;
62
+ if (typeof resolved === 'string' && resolved && resolved !== src) {
63
+ return downloadDirect(http, resolved);
64
+ }
65
+ }
66
+ catch (error) {
67
+ lastError = lastError ?? error;
68
+ }
69
+ }
70
+ throw lastError instanceof Error ? lastError : new Error('cannot download image');
71
+ }
72
+ async function downloadDirect(http, src) {
12
73
  if (src.startsWith('data:')) {
13
74
  const data = (0, detect_1.parseDataUrl)(src);
14
75
  if (!data)
15
76
  throw new Error('invalid data url');
16
77
  return data;
17
78
  }
79
+ if (src.startsWith('file:')) {
80
+ return (0, promises_1.readFile)((0, node_url_1.fileURLToPath)(src));
81
+ }
82
+ if (typeof http.file === 'function') {
83
+ const file = await http.file(src);
84
+ return Buffer.from(file.data);
85
+ }
18
86
  return Buffer.from(await http.get(src, { responseType: 'arraybuffer' }));
19
87
  }
20
88
  async function decodeQr(buffer) {
21
- const image = await jimp_1.default.read(buffer);
89
+ const image = await prepareImage(buffer);
22
90
  const { data, width, height } = image.bitmap;
23
- const result = (0, jsqr_1.default)(new Uint8ClampedArray(data), width, height, {
24
- inversionAttempts: 'attemptBoth',
25
- });
26
- return result?.data ?? null;
91
+ const pixels = new Uint8ClampedArray(data);
92
+ const quick = (0, jsqr_1.default)(pixels, width, height, { inversionAttempts: 'attemptBoth' });
93
+ if (quick?.data)
94
+ return quick.data;
95
+ const scan = await loadWechatScan();
96
+ if (!scan)
97
+ return null;
98
+ const result = await scan({ data: pixels, width, height });
99
+ return result?.text ?? null;
100
+ }
101
+ async function prepareImage(buffer) {
102
+ const image = await jimp_1.default.read(buffer);
103
+ const max = 2000;
104
+ if (image.bitmap.width > max || image.bitmap.height > max) {
105
+ return image.scaleToFit(max, max);
106
+ }
107
+ return image;
108
+ }
109
+ function warmupQrDecoder() {
110
+ return loadWechatScan();
111
+ }
112
+ function loadWechatScan() {
113
+ if (wechatScan)
114
+ return Promise.resolve(wechatScan);
115
+ wechatLoad ??= Function('return import("qr-scanner-wechat")')()
116
+ .then(mod => {
117
+ wechatScan = mod.scan;
118
+ return wechatScan;
119
+ })
120
+ .catch(() => null);
121
+ return wechatLoad;
122
+ }
123
+ function unwrapFileInfo(value) {
124
+ if (!value || typeof value !== 'object')
125
+ return null;
126
+ const record = value;
127
+ if (record.data && typeof record.data === 'object' && !Array.isArray(record.data)) {
128
+ return record.data;
129
+ }
130
+ return record;
27
131
  }
package/lib/share.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export declare function normalizeShare(payload: string): string;
2
2
  export declare function isGroupInviteCard(payload: string): boolean;
3
+ export declare function isTencentDocCard(payload: string): boolean;
4
+ export declare function extractShareCardText(payload: string): string;
package/lib/share.js CHANGED
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.normalizeShare = normalizeShare;
4
4
  exports.isGroupInviteCard = isGroupInviteCard;
5
+ exports.isTencentDocCard = isTencentDocCard;
6
+ exports.extractShareCardText = extractShareCardText;
5
7
  const detect_1 = require("./detect");
6
8
  const INVITE_APPS = new Set([
7
9
  'com.tencent.qun.invite',
@@ -12,7 +14,7 @@ function normalizeShare(payload) {
12
14
  const wrapped = /^\[(?:CQ:)?json(?:,data=|:data=)/i.exec(text);
13
15
  if (wrapped && text.endsWith(']'))
14
16
  text = text.slice(wrapped[0].length, -1);
15
- return (0, detect_1.decodeEntities)(text);
17
+ return (0, detect_1.decodeEntities)(text).replace(/\\\//g, '/');
16
18
  }
17
19
  function isGroupInviteCard(payload) {
18
20
  const raw = normalizeShare(payload);
@@ -47,6 +49,55 @@ function isGroupInviteCard(payload) {
47
49
  return true;
48
50
  return false;
49
51
  }
52
+ function isTencentDocCard(payload) {
53
+ return /腾讯文档|docs\.qq\.com|doc\.weixin\.qq\.com/i.test(normalizeShare(payload));
54
+ }
55
+ function extractShareCardText(payload) {
56
+ const raw = normalizeShare(payload);
57
+ const json = tryParseJson(raw);
58
+ if (json) {
59
+ const chunks = [];
60
+ const seen = new Set();
61
+ const add = (value) => {
62
+ if (typeof value !== 'string')
63
+ return;
64
+ const text = value.trim();
65
+ if (!text || seen.has(text))
66
+ return;
67
+ seen.add(text);
68
+ chunks.push(text);
69
+ };
70
+ add(json.prompt);
71
+ add(json.desc);
72
+ if (isRecord(json.meta)) {
73
+ for (const item of Object.values(json.meta)) {
74
+ if (!isRecord(item))
75
+ continue;
76
+ add(item.title);
77
+ add(item.desc);
78
+ add(item.prompt);
79
+ add(item.tag);
80
+ add(item.summary);
81
+ add(item.brief);
82
+ }
83
+ }
84
+ return chunks.join('\n');
85
+ }
86
+ const chunks = [];
87
+ const seen = new Set();
88
+ const add = (value) => {
89
+ const text = value?.trim();
90
+ if (!text || seen.has(text))
91
+ return;
92
+ seen.add(text);
93
+ chunks.push(text);
94
+ };
95
+ add(/brief=["']([^"']+)["']/i.exec(raw)?.[1]);
96
+ add(/<title[^>]*>([^<]+)/i.exec(raw)?.[1]);
97
+ add(/<summary[^>]*>([^<]+)/i.exec(raw)?.[1]);
98
+ add(/<desc[^>]*>([^<]+)/i.exec(raw)?.[1]);
99
+ return chunks.join('\n');
100
+ }
50
101
  function tryParseJson(text) {
51
102
  try {
52
103
  const value = JSON.parse(text);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-ban-qrcode",
3
3
  "description": "检测到群内二维码、拉群卡片或文档广告后自动撤回并禁言",
4
- "version": "1.1.0",
4
+ "version": "1.1.2",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
@@ -44,7 +44,8 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "jimp": "^0.22.12",
47
- "jsqr": "^1.4.0"
47
+ "jsqr": "^1.4.0",
48
+ "qr-scanner-wechat": "^0.1.3"
48
49
  },
49
50
  "devDependencies": {
50
51
  "@types/node": "^22.7.5",
package/readme.md CHANGED
@@ -94,6 +94,7 @@ npm run build
94
94
  | `scanGroupInvite` | `boolean` | `true` | 拦截邀请 / 推荐群聊分享卡 |
95
95
  | `scanDocs` | `boolean` | `true` | 检查腾讯文档和 Word / 文本附件 |
96
96
  | `adKeywords` | `string[]` | `[]` | 额外广告关键词,命中即撤回 |
97
+ | `debug` | `boolean` | `true` | 输出调试日志:跳过原因、消息结构、下载 / 扫码 / 文档结果 |
97
98
 
98
99
  默认提示按原因变化,例如:`检测到拉群卡片,已撤回并禁言 60 秒。` / `检测到文档广告,已撤回并禁言 60 秒。`
99
100
 
@@ -115,7 +116,7 @@ npm run build
115
116
 
116
117
  ### `decodeQr(buffer)`
117
118
 
118
- 用 Jimp 读图、jsQR 解码。没有码时返回 `null`。
119
+ 用 Jimp 读图,先 jsQR、再微信扫码器解码。没有码时返回 `null`。
119
120
 
120
121
  ## Architecture
121
122
 
@@ -189,7 +190,9 @@ npm run build
189
190
  <details>
190
191
  <summary><strong>为什么没有撤回或禁言?</strong></summary>
191
192
 
192
- 检查机器人是否有管理员权限,以及适配器是否实现 `deleteMessage` / `muteGuildMember`。权限不足时插件会打日志并继续后续步骤。
193
+ 先看 `ban-qrcode` 日志。自测最常见是自己是群主 / 管理员,默认 `skipAdmins` 会直接跳过(日志 `skip admin`)。把该项关掉,或用普通成员号发。
194
+
195
+ 也检查机器人是否有撤回 / 禁言权限。适配器没实现 `deleteMessage` / `muteGuildMember` 时会打 warn。
193
196
  </details>
194
197
 
195
198
  <details>
@@ -201,7 +204,7 @@ npm run build
201
204
  <details>
202
205
  <summary><strong>为什么有的二维码没扫到?</strong></summary>
203
206
 
204
- 图太糊、码只占画面很小一块、或格式 Jimp 读不了(少见 webp)时可能漏检。把更清晰的整图发来更容易命中。
207
+ 先看日志是 `qr error`(图没下下来)还是 `qr miss`(下到了但没解出来)。下载走 Koishi 的 `http.file`,OneBot 还会再试 `get_image`。解码先 jsQR,失败再走官方 `qrcode-service` 同款的微信扫码器。图太糊、码只占画面很小一块仍可能漏检。
205
208
  </details>
206
209
 
207
210
  <details>
@@ -272,7 +275,7 @@ Actions 会 `npm ci` → `npm test` → `npm run build` → `npm publish --acces
272
275
 
273
276
  ## Changelog
274
277
 
275
- 当前版本 **1.1.0**:在 1.0.0 扫码撤回之外,增加拉群分享卡拦截,以及腾讯文档 / Word 中的床品推销等广告识别。
278
+ 当前版本 **1.1.2**:修复 OneBot `getImage` 未绑定导致的生产崩溃,扫码失败不再跳过腾讯文档检测;文档分享卡按广告关键词兜底,而不是只看「大一新生必备清单」标题。1.1.1 补齐跳过原因日志,并用 `http.file` / OneBot `get_image` 与微信扫码器提高检出率。1.1.0 起拦截拉群分享卡和腾讯文档 / Word 广告。
276
279
 
277
280
  ## License
278
281