koishi-plugin-ban-qrcode 1.1.2 → 1.2.0
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/ad-keywords.txt +24 -0
- package/lib/ad.d.ts +5 -0
- package/lib/ad.js +24 -21
- package/lib/detect.d.ts +2 -0
- package/lib/detect.js +59 -11
- package/lib/index.js +11 -5
- package/lib/qrcode.d.ts +10 -1
- package/lib/qrcode.js +91 -19
- package/lib/share.js +42 -10
- package/package.json +2 -2
- package/readme.md +12 -9
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# 一行一个关键词。空行和 # 注释会被忽略。
|
|
2
|
+
# [strong] 命中任意一条即撤回
|
|
3
|
+
# [commerce] 需同时出现床品类用词,且至少命中两条
|
|
4
|
+
|
|
5
|
+
[strong]
|
|
6
|
+
校园麦芽
|
|
7
|
+
送货到寝
|
|
8
|
+
配送到寝
|
|
9
|
+
送寝
|
|
10
|
+
名额有限
|
|
11
|
+
营业执照
|
|
12
|
+
联系本校的负责人
|
|
13
|
+
联系本校负责人
|
|
14
|
+
整套四五百
|
|
15
|
+
|
|
16
|
+
[commerce]
|
|
17
|
+
提前预订
|
|
18
|
+
提前预定
|
|
19
|
+
买被子
|
|
20
|
+
购买链接
|
|
21
|
+
质保四年
|
|
22
|
+
厂家直销
|
|
23
|
+
真空包装
|
|
24
|
+
代发到寝
|
package/lib/ad.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export interface AdHit {
|
|
2
2
|
matches: string[];
|
|
3
3
|
}
|
|
4
|
+
export interface AdKeywordLists {
|
|
5
|
+
strong: string[];
|
|
6
|
+
commerce: string[];
|
|
7
|
+
}
|
|
8
|
+
export declare function parseAdKeywords(source: string): AdKeywordLists;
|
|
4
9
|
export declare function detectAdContent(text: string, title?: string, extraKeywords?: readonly string[]): AdHit | null;
|
package/lib/ad.js
CHANGED
|
@@ -1,27 +1,30 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseAdKeywords = parseAdKeywords;
|
|
3
4
|
exports.detectAdContent = detectAdContent;
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
'
|
|
10
|
-
'
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
7
|
+
const SECTION = /^\[(strong|commerce)\]$/i;
|
|
8
|
+
function parseAdKeywords(source) {
|
|
9
|
+
const lists = { strong: [], commerce: [] };
|
|
10
|
+
let section = 'strong';
|
|
11
|
+
for (const raw of source.replace(/^\uFEFF/, '').split(/\r?\n/)) {
|
|
12
|
+
const line = raw.trim();
|
|
13
|
+
if (!line || line.startsWith('#'))
|
|
14
|
+
continue;
|
|
15
|
+
const marker = line.match(SECTION);
|
|
16
|
+
if (marker) {
|
|
17
|
+
section = marker[1].toLowerCase();
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
lists[section].push(line);
|
|
21
|
+
}
|
|
22
|
+
return lists;
|
|
23
|
+
}
|
|
24
|
+
function loadAdKeywords() {
|
|
25
|
+
return parseAdKeywords((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, 'ad-keywords.txt'), 'utf8'));
|
|
26
|
+
}
|
|
27
|
+
const { strong: VERY_STRONG, commerce: COMMERCE } = loadAdKeywords();
|
|
25
28
|
const PRODUCT = /被子|床品|四件套|六件套|九件套|被芯|床垫/;
|
|
26
29
|
const SELL = /购买|预订|预定|下单|代购|团购|包邮|厂家|品牌|优惠|联系.{0,8}负责/;
|
|
27
30
|
const CONTACT = /微信|加v|加微|vx[::]|v信/i;
|
package/lib/detect.d.ts
CHANGED
package/lib/detect.js
CHANGED
|
@@ -55,6 +55,10 @@ const SHARE_TYPES = new Set([
|
|
|
55
55
|
'onebot:xml',
|
|
56
56
|
'onebot:share',
|
|
57
57
|
]);
|
|
58
|
+
const CONTACT_TYPES = new Set([
|
|
59
|
+
'contact',
|
|
60
|
+
'onebot:contact',
|
|
61
|
+
]);
|
|
58
62
|
const FILE_TYPES = new Set(['file']);
|
|
59
63
|
const OFFICE_FILE = /\.(docx?|txt)$/i;
|
|
60
64
|
function collectImageSrcs(nodes) {
|
|
@@ -95,14 +99,24 @@ function collectMessageParts(nodes, extra = '') {
|
|
|
95
99
|
texts.push(content);
|
|
96
100
|
}
|
|
97
101
|
if (SHARE_TYPES.has(node.type)) {
|
|
98
|
-
addShare(node.attrs?.data ?? node.attrs?.content ?? node.attrs?.value);
|
|
102
|
+
addShare(unwrapShareData(node.attrs?.data ?? node.attrs?.content ?? node.attrs?.value));
|
|
103
|
+
}
|
|
104
|
+
if (CONTACT_TYPES.has(node.type)) {
|
|
105
|
+
addShare(node.attrs);
|
|
99
106
|
}
|
|
100
107
|
if (FILE_TYPES.has(node.type)) {
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
const fileId = pickFileId(node.attrs);
|
|
109
|
+
const src = pickSrc(node.attrs) ?? fileId;
|
|
110
|
+
const name = String(node.attrs?.filename ?? node.attrs?.name ?? node.attrs?.file_name ?? node.attrs?.file ?? '');
|
|
111
|
+
if (src && !seenFile.has(fileId || src)) {
|
|
112
|
+
seenFile.add(fileId || src);
|
|
113
|
+
const busid = pickBusid(node.attrs);
|
|
114
|
+
files.push({
|
|
115
|
+
src,
|
|
116
|
+
name,
|
|
117
|
+
...(fileId ? { fileId } : {}),
|
|
118
|
+
...(busid !== undefined ? { busid } : {}),
|
|
119
|
+
});
|
|
106
120
|
}
|
|
107
121
|
}
|
|
108
122
|
if (node.children?.length)
|
|
@@ -117,6 +131,8 @@ function collectMessageParts(nodes, extra = '') {
|
|
|
117
131
|
addShare(match[1]);
|
|
118
132
|
for (const match of extra.matchAll(/\[(?:CQ:)?xml(?:,data=|:data=)([\s\S]+?)\]/gi))
|
|
119
133
|
addShare(match[1]);
|
|
134
|
+
for (const match of extra.matchAll(/\[(?:CQ:)?contact(?:,|\.)[^\]]+\]/gi))
|
|
135
|
+
addShare(match[0]);
|
|
120
136
|
for (const match of extra.matchAll(/<(?:json|xml|onebot:json|onebot:xml)\s[^>]*\bdata="([^"]+)"/gi)) {
|
|
121
137
|
addShare(decodeEntities(match[1]));
|
|
122
138
|
}
|
|
@@ -152,20 +168,35 @@ function decodeEntities(text) {
|
|
|
152
168
|
.replace(/&/g, '&')
|
|
153
169
|
.replace(/'/g, "'")
|
|
154
170
|
.replace(/</g, '<')
|
|
155
|
-
.replace(/>/g, '>')
|
|
171
|
+
.replace(/>/g, '>')
|
|
172
|
+
.replace(/,/g, ',')
|
|
173
|
+
.replace(/[/g, '[')
|
|
174
|
+
.replace(/]/g, ']');
|
|
156
175
|
}
|
|
157
176
|
function stringifyShare(raw) {
|
|
158
|
-
|
|
177
|
+
const unwrapped = unwrapShareData(raw);
|
|
178
|
+
if (unwrapped && typeof unwrapped === 'object') {
|
|
159
179
|
try {
|
|
160
|
-
return JSON.stringify(
|
|
180
|
+
return JSON.stringify(unwrapped);
|
|
161
181
|
}
|
|
162
182
|
catch {
|
|
163
183
|
return undefined;
|
|
164
184
|
}
|
|
165
185
|
}
|
|
166
|
-
if (typeof
|
|
186
|
+
if (typeof unwrapped !== 'string' || !unwrapped.trim())
|
|
167
187
|
return undefined;
|
|
168
|
-
return unescapePayload(
|
|
188
|
+
return unescapePayload(unwrapped.trim());
|
|
189
|
+
}
|
|
190
|
+
function unwrapShareData(raw) {
|
|
191
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
192
|
+
return raw;
|
|
193
|
+
const record = raw;
|
|
194
|
+
if (typeof record.app === 'string')
|
|
195
|
+
return raw;
|
|
196
|
+
if (typeof record.data === 'string' && record.data.trim().startsWith('{')) {
|
|
197
|
+
return unescapePayload(record.data.trim());
|
|
198
|
+
}
|
|
199
|
+
return raw;
|
|
169
200
|
}
|
|
170
201
|
function isDownloadableSrc(src) {
|
|
171
202
|
return /^(https?:|data:|file:)/i.test(src);
|
|
@@ -184,6 +215,23 @@ function pickSrc(attrs) {
|
|
|
184
215
|
.map(value => value.trim());
|
|
185
216
|
return values.find(isDownloadableSrc) ?? values[0];
|
|
186
217
|
}
|
|
218
|
+
function pickFileId(attrs) {
|
|
219
|
+
const values = [attrs?.file_id, attrs?.fileId]
|
|
220
|
+
.filter((value) => typeof value === 'string' && Boolean(value.trim()))
|
|
221
|
+
.map(value => value.trim());
|
|
222
|
+
return values[0];
|
|
223
|
+
}
|
|
224
|
+
function pickBusid(attrs) {
|
|
225
|
+
const value = attrs?.busid ?? attrs?.busId;
|
|
226
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
227
|
+
return value;
|
|
228
|
+
if (typeof value === 'string' && value.trim()) {
|
|
229
|
+
const parsed = Number(value);
|
|
230
|
+
if (Number.isFinite(parsed))
|
|
231
|
+
return parsed;
|
|
232
|
+
}
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
187
235
|
function collectUrlsFromJson(raw) {
|
|
188
236
|
let value = raw;
|
|
189
237
|
if (typeof raw === 'string') {
|
package/lib/index.js
CHANGED
|
@@ -26,7 +26,7 @@ exports.Config = koishi_1.Schema.object({
|
|
|
26
26
|
ignoreUsers: koishi_1.Schema.array(koishi_1.Schema.string()).role('table').default([]).description('忽略的用户 ID。'),
|
|
27
27
|
guilds: koishi_1.Schema.array(koishi_1.Schema.string()).role('table').default([]).description('只在这些群生效。留空表示全部群。'),
|
|
28
28
|
scanQrcode: koishi_1.Schema.boolean().default(true).description('扫描图片二维码。'),
|
|
29
|
-
scanGroupInvite: koishi_1.Schema.boolean().default(true).description('拦截邀请 /
|
|
29
|
+
scanGroupInvite: koishi_1.Schema.boolean().default(true).description('拦截邀请 / 推荐群聊 / 群名片分享卡。'),
|
|
30
30
|
scanDocs: koishi_1.Schema.boolean().default(true).description('检查腾讯文档和 Word / 文本附件里的广告。'),
|
|
31
31
|
adKeywords: koishi_1.Schema.array(koishi_1.Schema.string()).role('table').default([]).description('额外广告关键词。命中即撤回。'),
|
|
32
32
|
debug: koishi_1.Schema.boolean().default(true).description('输出调试日志:跳过原因、消息结构、下载/扫码/文档结果。'),
|
|
@@ -58,7 +58,9 @@ function apply(ctx, config) {
|
|
|
58
58
|
const docUrls = config.scanDocs
|
|
59
59
|
? (0, document_1.collectTencentDocUrls)([parts.text, ...parts.shares, ...parts.urls].join('\n'))
|
|
60
60
|
: [];
|
|
61
|
-
const officeFiles = config.scanDocs
|
|
61
|
+
const officeFiles = config.scanDocs
|
|
62
|
+
? parts.files.filter(file => (0, detect_1.isOfficeFile)(file.name) || (0, detect_1.isOfficeFile)(file.src))
|
|
63
|
+
: [];
|
|
62
64
|
const docCards = config.scanDocs ? parts.shares.filter(share_1.isTencentDocCard) : [];
|
|
63
65
|
const docCardText = docCards.map(share_1.extractShareCardText).filter(Boolean).join('\n');
|
|
64
66
|
const needInvite = config.scanGroupInvite && parts.shares.some(share_1.isGroupInviteCard);
|
|
@@ -69,7 +71,8 @@ function apply(ctx, config) {
|
|
|
69
71
|
}
|
|
70
72
|
if (!needInvite && !needQr && !needDocs)
|
|
71
73
|
return;
|
|
72
|
-
const
|
|
74
|
+
const guildId = (0, detect_1.resolveGuildId)(moderate);
|
|
75
|
+
const resolveFile = (0, qrcode_1.createFileResolver)(session.bot, guildId);
|
|
73
76
|
const download = (src) => (0, qrcode_1.downloadImage)(ctx.http, src, resolveFile);
|
|
74
77
|
try {
|
|
75
78
|
if (needInvite) {
|
|
@@ -143,7 +146,7 @@ function apply(ctx, config) {
|
|
|
143
146
|
}
|
|
144
147
|
for (const file of officeFiles) {
|
|
145
148
|
try {
|
|
146
|
-
const doc = (0, document_1.extractOfficeText)(await
|
|
149
|
+
const doc = (0, document_1.extractOfficeText)(await (0, qrcode_1.downloadFile)(ctx.http, { ...file, groupId: guildId }, resolveFile), file.name);
|
|
147
150
|
const ad = doc ? (0, ad_1.detectAdContent)(doc.text, doc.title || file.name, config.adKeywords) : null;
|
|
148
151
|
if (config.debug)
|
|
149
152
|
logger.info('office %s ad=%s', file.name, Boolean(ad));
|
|
@@ -154,7 +157,7 @@ function apply(ctx, config) {
|
|
|
154
157
|
}
|
|
155
158
|
catch (error) {
|
|
156
159
|
if (config.debug)
|
|
157
|
-
logger.info('office error %s', file.name);
|
|
160
|
+
logger.info('office error %s %s', file.name, errorMessage(error));
|
|
158
161
|
logger.warn(error);
|
|
159
162
|
}
|
|
160
163
|
}
|
|
@@ -227,6 +230,9 @@ function createDocHttp(http) {
|
|
|
227
230
|
},
|
|
228
231
|
};
|
|
229
232
|
}
|
|
233
|
+
function errorMessage(error) {
|
|
234
|
+
return error instanceof Error && error.message ? error.message : 'unknown';
|
|
235
|
+
}
|
|
230
236
|
function cookiesFromHeaders(headers) {
|
|
231
237
|
const parts = typeof headers.getSetCookie === 'function'
|
|
232
238
|
? headers.getSetCookie()
|
package/lib/qrcode.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HTTP } from 'koishi';
|
|
2
|
+
import { FileRef } from './detect';
|
|
2
3
|
type WechatScan = (input: {
|
|
3
4
|
data: Uint8ClampedArray;
|
|
4
5
|
width: number;
|
|
@@ -6,13 +7,21 @@ type WechatScan = (input: {
|
|
|
6
7
|
}) => Promise<{
|
|
7
8
|
text?: string | null;
|
|
8
9
|
} | null | undefined>;
|
|
10
|
+
export type FileDownload = FileRef & {
|
|
11
|
+
groupId?: string;
|
|
12
|
+
};
|
|
9
13
|
type FileInternal = {
|
|
10
14
|
getImage?(src: string): unknown;
|
|
15
|
+
getFile?(...args: unknown[]): unknown;
|
|
16
|
+
get_file?(...args: unknown[]): unknown;
|
|
17
|
+
getGroupFileUrl?(...args: unknown[]): unknown;
|
|
18
|
+
get_group_file_url?(...args: unknown[]): unknown;
|
|
11
19
|
};
|
|
12
20
|
export declare function createFileResolver(bot: {
|
|
13
21
|
internal?: FileInternal;
|
|
14
|
-
}): (
|
|
22
|
+
}, groupId?: string): (input: string | FileDownload) => Promise<Buffer | string | null>;
|
|
15
23
|
export declare function downloadImage(http: HTTP, src: string, resolveFile?: (src: string) => Promise<Buffer | string | null>): Promise<Buffer>;
|
|
24
|
+
export declare function downloadFile(http: HTTP, file: FileDownload, resolveFile?: (file: FileDownload) => Promise<Buffer | string | null>): Promise<Buffer>;
|
|
16
25
|
export declare function decodeQr(buffer: Buffer): Promise<string | null>;
|
|
17
26
|
export declare function warmupQrDecoder(): Promise<WechatScan | null>;
|
|
18
27
|
export {};
|
package/lib/qrcode.js
CHANGED
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.createFileResolver = createFileResolver;
|
|
7
7
|
exports.downloadImage = downloadImage;
|
|
8
|
+
exports.downloadFile = downloadFile;
|
|
8
9
|
exports.decodeQr = decodeQr;
|
|
9
10
|
exports.warmupQrDecoder = warmupQrDecoder;
|
|
10
11
|
const promises_1 = require("node:fs/promises");
|
|
@@ -14,26 +15,17 @@ const jsqr_1 = __importDefault(require("jsqr"));
|
|
|
14
15
|
const detect_1 = require("./detect");
|
|
15
16
|
let wechatScan;
|
|
16
17
|
let wechatLoad;
|
|
17
|
-
function createFileResolver(bot) {
|
|
18
|
-
return async (
|
|
18
|
+
function createFileResolver(bot, groupId) {
|
|
19
|
+
return async (input) => {
|
|
19
20
|
const internal = bot.internal;
|
|
20
21
|
if (!internal)
|
|
21
22
|
return null;
|
|
23
|
+
const file = typeof input === 'string' ? { src: input, name: input } : input;
|
|
22
24
|
try {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return null;
|
|
26
|
-
if (typeof info.base64 === 'string' && info.base64) {
|
|
27
|
-
return Buffer.from(info.base64, 'base64');
|
|
25
|
+
if ((0, detect_1.isOfficeFile)(file.name) || (0, detect_1.isOfficeFile)(file.src)) {
|
|
26
|
+
return await resolveOfficeFile(internal, { ...file, groupId: file.groupId ?? groupId });
|
|
28
27
|
}
|
|
29
|
-
|
|
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;
|
|
28
|
+
return await resolveFromInfo(unwrapFileInfo(await internal.getImage?.(file.src)));
|
|
37
29
|
}
|
|
38
30
|
catch {
|
|
39
31
|
return null;
|
|
@@ -41,6 +33,13 @@ function createFileResolver(bot) {
|
|
|
41
33
|
};
|
|
42
34
|
}
|
|
43
35
|
async function downloadImage(http, src, resolveFile) {
|
|
36
|
+
return downloadBuffer(http, src, resolveFile, 'cannot download image');
|
|
37
|
+
}
|
|
38
|
+
async function downloadFile(http, file, resolveFile) {
|
|
39
|
+
return downloadBuffer(http, file, resolveFile, 'cannot download file');
|
|
40
|
+
}
|
|
41
|
+
async function downloadBuffer(http, target, resolveFile, fallback) {
|
|
42
|
+
const src = typeof target === 'string' ? target : target.src;
|
|
44
43
|
let lastError;
|
|
45
44
|
const remote = /^https?:/i.test(src);
|
|
46
45
|
if ((0, detect_1.isDownloadableSrc)(src)) {
|
|
@@ -51,23 +50,23 @@ async function downloadImage(http, src, resolveFile) {
|
|
|
51
50
|
lastError = error;
|
|
52
51
|
}
|
|
53
52
|
if (!remote) {
|
|
54
|
-
throw lastError instanceof Error ? lastError : new Error(
|
|
53
|
+
throw lastError instanceof Error ? lastError : new Error(fallback);
|
|
55
54
|
}
|
|
56
55
|
}
|
|
57
56
|
if (resolveFile) {
|
|
58
57
|
try {
|
|
59
|
-
const resolved = await resolveFile(
|
|
58
|
+
const resolved = await resolveFile(target);
|
|
60
59
|
if (Buffer.isBuffer(resolved))
|
|
61
60
|
return resolved;
|
|
62
61
|
if (typeof resolved === 'string' && resolved && resolved !== src) {
|
|
63
|
-
return downloadDirect(http, resolved);
|
|
62
|
+
return (0, detect_1.isDownloadableSrc)(resolved) ? downloadDirect(http, resolved) : (0, promises_1.readFile)(resolved);
|
|
64
63
|
}
|
|
65
64
|
}
|
|
66
65
|
catch (error) {
|
|
67
66
|
lastError = lastError ?? error;
|
|
68
67
|
}
|
|
69
68
|
}
|
|
70
|
-
throw lastError instanceof Error ? lastError : new Error(
|
|
69
|
+
throw lastError instanceof Error ? lastError : new Error(fallback);
|
|
71
70
|
}
|
|
72
71
|
async function downloadDirect(http, src) {
|
|
73
72
|
if (src.startsWith('data:')) {
|
|
@@ -85,6 +84,76 @@ async function downloadDirect(http, src) {
|
|
|
85
84
|
}
|
|
86
85
|
return Buffer.from(await http.get(src, { responseType: 'arraybuffer' }));
|
|
87
86
|
}
|
|
87
|
+
async function resolveOfficeFile(internal, file) {
|
|
88
|
+
const keys = uniqueKeys([
|
|
89
|
+
file.fileId,
|
|
90
|
+
file.src && !(0, detect_1.isDownloadableSrc)(file.src) ? file.src : undefined,
|
|
91
|
+
]);
|
|
92
|
+
for (const key of keys) {
|
|
93
|
+
const fromFile = await firstResolved([
|
|
94
|
+
() => internal.getFile?.({ file: key }),
|
|
95
|
+
() => internal.get_file?.({ file: key }),
|
|
96
|
+
]);
|
|
97
|
+
if (fromFile)
|
|
98
|
+
return fromFile;
|
|
99
|
+
}
|
|
100
|
+
const groupId = file.groupId;
|
|
101
|
+
if (!groupId)
|
|
102
|
+
return null;
|
|
103
|
+
const busid = file.busid ?? 102;
|
|
104
|
+
const gid = /^\d+$/.test(groupId) ? Number(groupId) : groupId;
|
|
105
|
+
for (const key of keys) {
|
|
106
|
+
const payload = { group_id: gid, file_id: key, busid };
|
|
107
|
+
const fromUrl = await firstResolved([
|
|
108
|
+
() => internal.getGroupFileUrl?.(payload),
|
|
109
|
+
() => internal.get_group_file_url?.(payload),
|
|
110
|
+
() => internal.getGroupFileUrl?.(gid, key, busid),
|
|
111
|
+
() => internal.get_group_file_url?.(gid, key, busid),
|
|
112
|
+
]);
|
|
113
|
+
if (fromUrl)
|
|
114
|
+
return fromUrl;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
async function firstResolved(calls) {
|
|
119
|
+
for (const call of calls) {
|
|
120
|
+
try {
|
|
121
|
+
const result = await resolveFromInfo(unwrapFileInfo(await call()));
|
|
122
|
+
if (result)
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// adapter methods may be missing or reject; try the next one
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
async function resolveFromInfo(info) {
|
|
132
|
+
if (!info)
|
|
133
|
+
return null;
|
|
134
|
+
if (typeof info.base64 === 'string' && info.base64) {
|
|
135
|
+
return Buffer.from(info.base64, 'base64');
|
|
136
|
+
}
|
|
137
|
+
if (typeof info.url === 'string' && (0, detect_1.isDownloadableSrc)(info.url))
|
|
138
|
+
return info.url;
|
|
139
|
+
if (typeof info.file === 'string' && info.file) {
|
|
140
|
+
if ((0, detect_1.isDownloadableSrc)(info.file))
|
|
141
|
+
return info.file;
|
|
142
|
+
return (0, promises_1.readFile)(info.file);
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
function uniqueKeys(values) {
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
const keys = [];
|
|
149
|
+
for (const value of values) {
|
|
150
|
+
if (!value || seen.has(value))
|
|
151
|
+
continue;
|
|
152
|
+
seen.add(value);
|
|
153
|
+
keys.push(value);
|
|
154
|
+
}
|
|
155
|
+
return keys;
|
|
156
|
+
}
|
|
88
157
|
async function decodeQr(buffer) {
|
|
89
158
|
const image = await prepareImage(buffer);
|
|
90
159
|
const { data, width, height } = image.bitmap;
|
|
@@ -121,6 +190,9 @@ function loadWechatScan() {
|
|
|
121
190
|
return wechatLoad;
|
|
122
191
|
}
|
|
123
192
|
function unwrapFileInfo(value) {
|
|
193
|
+
if (typeof value === 'string' && value) {
|
|
194
|
+
return (0, detect_1.isDownloadableSrc)(value) ? { url: value } : { file: value };
|
|
195
|
+
}
|
|
124
196
|
if (!value || typeof value !== 'object')
|
|
125
197
|
return null;
|
|
126
198
|
const record = value;
|
package/lib/share.js
CHANGED
|
@@ -9,6 +9,7 @@ const INVITE_APPS = new Set([
|
|
|
9
9
|
'com.tencent.qun.invite',
|
|
10
10
|
'com.tencent.troopsharecard',
|
|
11
11
|
]);
|
|
12
|
+
const GROUP_PROMPT = /群名片|\[QQ名片\]群|推荐群聊|邀请你加入群聊|邀请加入群聊/;
|
|
12
13
|
function normalizeShare(payload) {
|
|
13
14
|
let text = payload.trim();
|
|
14
15
|
const wrapped = /^\[(?:CQ:)?json(?:,data=|:data=)/i.exec(text);
|
|
@@ -18,8 +19,12 @@ function normalizeShare(payload) {
|
|
|
18
19
|
}
|
|
19
20
|
function isGroupInviteCard(payload) {
|
|
20
21
|
const raw = normalizeShare(payload);
|
|
21
|
-
|
|
22
|
+
if (isContactGroupSegment(raw))
|
|
23
|
+
return true;
|
|
24
|
+
const json = readShareJson(raw);
|
|
22
25
|
if (json) {
|
|
26
|
+
if (isContactGroupRecord(json))
|
|
27
|
+
return true;
|
|
23
28
|
const app = String(json.app ?? '');
|
|
24
29
|
if (INVITE_APPS.has(app))
|
|
25
30
|
return true;
|
|
@@ -28,22 +33,25 @@ function isGroupInviteCard(payload) {
|
|
|
28
33
|
return true;
|
|
29
34
|
const contact = isRecord(json.meta) ? json.meta.contact : undefined;
|
|
30
35
|
if (isRecord(contact)) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const jumpUrl = String(contact.jumpUrl ?? '');
|
|
37
|
+
const pcJumpUrl = String(contact.pcJumpUrl ?? '');
|
|
38
|
+
const avatar = String(contact.avatar ?? '');
|
|
39
|
+
if (contact.tag === '群名片'
|
|
40
|
+
|| jumpUrl.includes('card_type=group')
|
|
41
|
+
|| pcJumpUrl.includes('groupwpa')
|
|
42
|
+
|| avatar.includes('p.qlogo.cn/gh/'))
|
|
38
43
|
return true;
|
|
44
|
+
if (contact.tag === '推荐好友' || jumpUrl.includes('source=sharecard'))
|
|
45
|
+
return false;
|
|
39
46
|
}
|
|
40
47
|
const prompt = String(json.prompt ?? '');
|
|
41
|
-
|
|
48
|
+
const desc = String(json.desc ?? '');
|
|
49
|
+
if (GROUP_PROMPT.test(prompt) || GROUP_PROMPT.test(desc))
|
|
42
50
|
return true;
|
|
43
51
|
if (bizsrc.includes('cardshare'))
|
|
44
52
|
return false;
|
|
45
53
|
}
|
|
46
|
-
if (/brief=["']\[
|
|
54
|
+
if (/brief=["']\[(?:推荐群|邀请加群|群名片)\]["']/.test(raw))
|
|
47
55
|
return true;
|
|
48
56
|
if ((/邀请你加入群聊|邀请加入群聊/.test(raw)) && (/serviceID=/.test(raw) || /com\.tencent\.qun/.test(raw)))
|
|
49
57
|
return true;
|
|
@@ -98,6 +106,30 @@ function extractShareCardText(payload) {
|
|
|
98
106
|
add(/<desc[^>]*>([^<]+)/i.exec(raw)?.[1]);
|
|
99
107
|
return chunks.join('\n');
|
|
100
108
|
}
|
|
109
|
+
function readShareJson(text) {
|
|
110
|
+
const json = tryParseJson(text);
|
|
111
|
+
if (!json)
|
|
112
|
+
return null;
|
|
113
|
+
if (typeof json.app === 'string')
|
|
114
|
+
return json;
|
|
115
|
+
if (typeof json.data === 'string') {
|
|
116
|
+
const inner = tryParseJson(normalizeShare(json.data));
|
|
117
|
+
if (inner)
|
|
118
|
+
return inner;
|
|
119
|
+
}
|
|
120
|
+
return json;
|
|
121
|
+
}
|
|
122
|
+
function isContactGroupSegment(raw) {
|
|
123
|
+
return /\[(?:CQ:)?contact(?:,|\.)[^\]]*type=group/i.test(raw);
|
|
124
|
+
}
|
|
125
|
+
function isContactGroupRecord(json) {
|
|
126
|
+
const type = String(json.type ?? '').toLowerCase();
|
|
127
|
+
if (type !== 'group' && type !== '群')
|
|
128
|
+
return false;
|
|
129
|
+
if (json.app)
|
|
130
|
+
return false;
|
|
131
|
+
return Boolean(json.id || json.qq || json.uin);
|
|
132
|
+
}
|
|
101
133
|
function tryParseJson(text) {
|
|
102
134
|
try {
|
|
103
135
|
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.
|
|
4
|
+
"version": "1.2.0",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"typings": "lib/index.d.ts",
|
|
7
7
|
"files": [
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"access": "public"
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
|
-
"build": "tsc -b",
|
|
24
|
+
"build": "tsc -b && node -e \"require('fs').copyFileSync('src/ad-keywords.txt', 'lib/ad-keywords.txt')\"",
|
|
25
25
|
"test": "tsx --test tests/*.spec.ts"
|
|
26
26
|
},
|
|
27
27
|
"keywords": [
|
package/readme.md
CHANGED
|
@@ -73,7 +73,7 @@ npm run build
|
|
|
73
73
|
|
|
74
74
|
1. 只处理群聊,忽略私聊和机器人自己的消息
|
|
75
75
|
2. 图片二维码:收集 `img` / `image`,下载后解码
|
|
76
|
-
3. 拉群卡片:识别 `json` / `xml` 分享卡(`com.tencent.qun.invite`、群名片、`[推荐群]` 等)
|
|
76
|
+
3. 拉群卡片:识别 `json` / `xml` / `contact` 分享卡(`com.tencent.qun.invite`、群名片、`[推荐群]` 等)
|
|
77
77
|
4. 文档广告:从文本、分享卡里取出 `docs.qq.com` 链接,或下载 `.doc` / `.docx` / `.txt`,识别夹带的推销文案(例如校园床品「送货到寝」)
|
|
78
78
|
5. 文档里的图也会再扫一遍二维码
|
|
79
79
|
|
|
@@ -96,11 +96,13 @@ npm run build
|
|
|
96
96
|
| `adKeywords` | `string[]` | `[]` | 额外广告关键词,命中即撤回 |
|
|
97
97
|
| `debug` | `boolean` | `true` | 输出调试日志:跳过原因、消息结构、下载 / 扫码 / 文档结果 |
|
|
98
98
|
|
|
99
|
+
内置词在 `src/ad-keywords.txt`,一行一条。`[strong]` 命中任意一条即撤回;`[commerce]` 要同时出现床品类用词且至少两条。控制台 `adKeywords` 按强匹配叠加。
|
|
100
|
+
|
|
99
101
|
默认提示按原因变化,例如:`检测到拉群卡片,已撤回并禁言 60 秒。` / `检测到文档广告,已撤回并禁言 60 秒。`
|
|
100
102
|
|
|
101
103
|
## API Reference
|
|
102
104
|
|
|
103
|
-
插件入口导出 `name`、`inject`、`Config`、`apply`。判定与扫码编排在 `src/detect.ts`,拉群卡在 `src/share.ts`,文档广告在 `src/ad.ts` / `src/document.ts`,下载和解码在 `src/qrcode.ts
|
|
105
|
+
插件入口导出 `name`、`inject`、`Config`、`apply`。判定与扫码编排在 `src/detect.ts`,拉群卡在 `src/share.ts`,文档广告在 `src/ad.ts` / `src/ad-keywords.txt` / `src/document.ts`,下载和解码在 `src/qrcode.ts`。内置广告词在 `ad-keywords.txt` 里一行一条;控制台 `adKeywords` 仍是额外覆盖。
|
|
104
106
|
|
|
105
107
|
### `collectImageSrcs(nodes)`
|
|
106
108
|
|
|
@@ -122,12 +124,13 @@ npm run build
|
|
|
122
124
|
|
|
123
125
|
```
|
|
124
126
|
src/
|
|
125
|
-
├── index.ts
|
|
126
|
-
├── detect.ts
|
|
127
|
-
├── share.ts
|
|
128
|
-
├── ad.ts
|
|
129
|
-
├──
|
|
130
|
-
|
|
127
|
+
├── index.ts # 插件入口:监听群消息,撤回 / 禁言 / 提示
|
|
128
|
+
├── detect.ts # 收集图片 / 卡片 / 文件、是否处理、提示语
|
|
129
|
+
├── share.ts # 识别 QQ 拉群分享卡
|
|
130
|
+
├── ad.ts # 识别文档里的卖货文案
|
|
131
|
+
├── ad-keywords.txt # 内置广告关键词,一行一条
|
|
132
|
+
├── document.ts # 拉腾讯文档、抽出 Word / 文本
|
|
133
|
+
└── qrcode.ts # 下载图片、解码二维码
|
|
131
134
|
tests/
|
|
132
135
|
├── detect.spec.ts
|
|
133
136
|
├── share.spec.ts
|
|
@@ -275,7 +278,7 @@ Actions 会 `npm ci` → `npm test` → `npm run build` → `npm publish --acces
|
|
|
275
278
|
|
|
276
279
|
## Changelog
|
|
277
280
|
|
|
278
|
-
当前版本 **1.
|
|
281
|
+
当前版本 **1.2.0**:内置广告词改到 `ad-keywords.txt` 维护,控制台 `adKeywords` 仍作额外强匹配。1.1.3 补拦 QQ 群名片。1.1.2 修复 OneBot `getImage` 未绑定导致的生产崩溃。1.1.1 补齐跳过原因日志。1.1.0 起拦截拉群分享卡和腾讯文档 / Word 广告。
|
|
279
282
|
|
|
280
283
|
## License
|
|
281
284
|
|