koishi-plugin-ban-qrcode 1.2.0 → 1.3.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/detect.js +36 -2
- package/lib/document.d.ts +3 -1
- package/lib/document.js +124 -22
- package/lib/index.d.ts +1 -0
- package/lib/index.js +18 -2
- package/lib/share.js +31 -1
- package/package.json +1 -1
- package/readme.md +4 -3
package/lib/detect.js
CHANGED
|
@@ -51,9 +51,17 @@ const SHARE_TYPES = new Set([
|
|
|
51
51
|
'share',
|
|
52
52
|
'ark',
|
|
53
53
|
'lightapp',
|
|
54
|
+
'miniapp',
|
|
54
55
|
'onebot:json',
|
|
55
56
|
'onebot:xml',
|
|
56
57
|
'onebot:share',
|
|
58
|
+
'onebot:ark',
|
|
59
|
+
'onebot:lightapp',
|
|
60
|
+
'onebot:miniapp',
|
|
61
|
+
'weapp',
|
|
62
|
+
'wxapp',
|
|
63
|
+
'miniprogram',
|
|
64
|
+
'onebot:weapp',
|
|
57
65
|
]);
|
|
58
66
|
const CONTACT_TYPES = new Set([
|
|
59
67
|
'contact',
|
|
@@ -98,8 +106,8 @@ function collectMessageParts(nodes, extra = '') {
|
|
|
98
106
|
if (typeof content === 'string' && content)
|
|
99
107
|
texts.push(content);
|
|
100
108
|
}
|
|
101
|
-
if (SHARE_TYPES.has(node.type)) {
|
|
102
|
-
addShare(
|
|
109
|
+
if (SHARE_TYPES.has(node.type) || looksLikeShareAttrs(node.attrs)) {
|
|
110
|
+
addShare(sharePayloadFromAttrs(node.attrs));
|
|
103
111
|
}
|
|
104
112
|
if (CONTACT_TYPES.has(node.type)) {
|
|
105
113
|
addShare(node.attrs);
|
|
@@ -198,6 +206,32 @@ function unwrapShareData(raw) {
|
|
|
198
206
|
}
|
|
199
207
|
return raw;
|
|
200
208
|
}
|
|
209
|
+
function looksLikeShareAttrs(attrs) {
|
|
210
|
+
if (!attrs)
|
|
211
|
+
return false;
|
|
212
|
+
if (typeof attrs.app === 'string')
|
|
213
|
+
return true;
|
|
214
|
+
if (attrs.meta && (attrs.prompt || attrs.view || attrs.bizsrc))
|
|
215
|
+
return true;
|
|
216
|
+
const wrapped = attrs.data ?? attrs.content ?? attrs.value;
|
|
217
|
+
if (typeof wrapped === 'string' && /"app"\s*:/.test(wrapped))
|
|
218
|
+
return true;
|
|
219
|
+
if (wrapped && typeof wrapped === 'object' && !Array.isArray(wrapped)) {
|
|
220
|
+
return typeof wrapped.app === 'string';
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
function sharePayloadFromAttrs(attrs) {
|
|
225
|
+
if (!attrs)
|
|
226
|
+
return undefined;
|
|
227
|
+
const wrapped = attrs.data ?? attrs.content ?? attrs.value;
|
|
228
|
+
if (wrapped !== undefined && wrapped !== null && wrapped !== '') {
|
|
229
|
+
return unwrapShareData(wrapped);
|
|
230
|
+
}
|
|
231
|
+
if (typeof attrs.app === 'string' || attrs.meta)
|
|
232
|
+
return attrs;
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
201
235
|
function isDownloadableSrc(src) {
|
|
202
236
|
return /^(https?:|data:|file:)/i.test(src);
|
|
203
237
|
}
|
package/lib/document.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export declare const DEFAULT_MAX_OFFICE_BYTES: number;
|
|
1
2
|
export interface DocContent {
|
|
2
3
|
title: string;
|
|
3
4
|
text: string;
|
|
@@ -15,7 +16,8 @@ export declare function parseTencentDocUrl(url: string): {
|
|
|
15
16
|
pageUrl: string;
|
|
16
17
|
} | null;
|
|
17
18
|
export declare function collectTencentDocUrls(text: string): string[];
|
|
19
|
+
export declare function extractTencentDocUrlsFromShare(payload: string): string[];
|
|
18
20
|
export declare function parseOpendocBody(body: string): DocContent | null;
|
|
19
21
|
export declare function fetchTencentDoc(http: DocHttp, url: string): Promise<DocContent | null>;
|
|
20
|
-
export declare function extractOfficeText(buffer: Buffer, name: string): DocContent | null;
|
|
22
|
+
export declare function extractOfficeText(buffer: Buffer, name: string, maxBytes?: number): DocContent | null;
|
|
21
23
|
export declare function extractReadableText(input: string | Buffer): string;
|
package/lib/document.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_MAX_OFFICE_BYTES = void 0;
|
|
3
4
|
exports.parseTencentDocUrl = parseTencentDocUrl;
|
|
4
5
|
exports.collectTencentDocUrls = collectTencentDocUrls;
|
|
6
|
+
exports.extractTencentDocUrlsFromShare = extractTencentDocUrlsFromShare;
|
|
5
7
|
exports.parseOpendocBody = parseOpendocBody;
|
|
6
8
|
exports.fetchTencentDoc = fetchTencentDoc;
|
|
7
9
|
exports.extractOfficeText = extractOfficeText;
|
|
@@ -9,32 +11,81 @@ exports.extractReadableText = extractReadableText;
|
|
|
9
11
|
const node_zlib_1 = require("node:zlib");
|
|
10
12
|
const detect_1 = require("./detect");
|
|
11
13
|
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
|
|
12
|
-
const
|
|
14
|
+
const DOC_HOST = '(?:docs\\.qq\\.com|doc\\.weixin\\.qq\\.com)';
|
|
15
|
+
const DOC_KIND = 'doc|sheet|slide|pdf|form|mind|smartsheet|smartpage';
|
|
16
|
+
const DOC_ID = '[A-Za-z0-9][A-Za-z0-9_-]{5,}';
|
|
17
|
+
const DOC_RE = new RegExp(`(?:https?:\\/\\/)?(?:\\/\\/)?${DOC_HOST}\\/(${DOC_KIND})(?:\\/page)?\\/(${DOC_ID})`, 'i');
|
|
13
18
|
const ZIP_LOCAL = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
|
14
19
|
const IMAGE_RE = /https?:\/\/docimg\d+\.docs\.qq\.com\/image\/[A-Za-z0-9_-]+(?:\.(?:jpe?g|png|webp))?/gi;
|
|
20
|
+
exports.DEFAULT_MAX_OFFICE_BYTES = 5 * 1024 * 1024;
|
|
15
21
|
function parseTencentDocUrl(url) {
|
|
16
|
-
const match = DOC_RE.exec(url);
|
|
22
|
+
const match = DOC_RE.exec(decodePercents((0, detect_1.unescapePayload)(url)));
|
|
17
23
|
if (!match)
|
|
18
24
|
return null;
|
|
25
|
+
const kind = match[1].toLowerCase();
|
|
26
|
+
const id = match[2];
|
|
27
|
+
const path = kind === 'form' ? `form/page/${id}` : `${kind}/${id}`;
|
|
19
28
|
return {
|
|
20
|
-
kind
|
|
21
|
-
id
|
|
22
|
-
pageUrl: `https://docs.qq.com/${
|
|
29
|
+
kind,
|
|
30
|
+
id,
|
|
31
|
+
pageUrl: `https://docs.qq.com/${path}`,
|
|
23
32
|
};
|
|
24
33
|
}
|
|
25
34
|
function collectTencentDocUrls(text) {
|
|
26
35
|
const urls = [];
|
|
27
36
|
const seen = new Set();
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
const add = (value) => {
|
|
38
|
+
const decoded = decodePercents((0, detect_1.unescapePayload)(value));
|
|
39
|
+
const re = new RegExp(DOC_RE.source, 'gi');
|
|
40
|
+
for (const match of decoded.matchAll(re)) {
|
|
41
|
+
const parsed = parseTencentDocUrl(match[0]);
|
|
42
|
+
if (!parsed || seen.has(parsed.pageUrl))
|
|
43
|
+
continue;
|
|
44
|
+
seen.add(parsed.pageUrl);
|
|
45
|
+
urls.push(parsed.pageUrl);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
add(text);
|
|
49
|
+
return urls;
|
|
50
|
+
}
|
|
51
|
+
function extractTencentDocUrlsFromShare(payload) {
|
|
52
|
+
const urls = collectTencentDocUrls(payload);
|
|
53
|
+
const seen = new Set(urls);
|
|
54
|
+
const add = (value) => {
|
|
55
|
+
for (const url of collectTencentDocUrls(value)) {
|
|
56
|
+
if (seen.has(url))
|
|
57
|
+
continue;
|
|
58
|
+
seen.add(url);
|
|
59
|
+
urls.push(url);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const raw = (0, detect_1.unescapePayload)(payload);
|
|
63
|
+
try {
|
|
64
|
+
walkDocCandidates(JSON.parse(decodePercents(raw)), add);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// not json; still scan xml tags below
|
|
68
|
+
}
|
|
69
|
+
for (const match of raw.matchAll(/<(?:url|qqdocurl|pagepath|jumpurl|pcjumpurl)[^>]*>([\s\S]*?)<\//gi)) {
|
|
70
|
+
add(match[1]);
|
|
35
71
|
}
|
|
36
72
|
return urls;
|
|
37
73
|
}
|
|
74
|
+
function walkDocCandidates(value, add) {
|
|
75
|
+
if (typeof value === 'string') {
|
|
76
|
+
add(value);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(value)) {
|
|
80
|
+
for (const item of value)
|
|
81
|
+
walkDocCandidates(item, add);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (value && typeof value === 'object') {
|
|
85
|
+
for (const item of Object.values(value))
|
|
86
|
+
walkDocCandidates(item, add);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
38
89
|
function parseOpendocBody(body) {
|
|
39
90
|
const data = parseJsonp(body);
|
|
40
91
|
if (!data)
|
|
@@ -68,14 +119,37 @@ async function fetchTencentDoc(http, url) {
|
|
|
68
119
|
const parsed = parseTencentDocUrl(url);
|
|
69
120
|
if (!parsed)
|
|
70
121
|
return null;
|
|
71
|
-
const
|
|
122
|
+
const pageUrls = [parsed.pageUrl];
|
|
123
|
+
if (/doc\.weixin\.qq\.com/i.test(url) || parsed.id.includes('_')) {
|
|
124
|
+
const weixinPath = parsed.kind === 'form' ? `form/page/${parsed.id}` : `${parsed.kind}/${parsed.id}`;
|
|
125
|
+
const weixinUrl = `https://doc.weixin.qq.com/${weixinPath}`;
|
|
126
|
+
if (!pageUrls.includes(weixinUrl))
|
|
127
|
+
pageUrls.push(weixinUrl);
|
|
128
|
+
}
|
|
129
|
+
let lastError;
|
|
130
|
+
for (const pageUrl of pageUrls) {
|
|
131
|
+
try {
|
|
132
|
+
const doc = await fetchOpendocFromPage(http, parsed.id, pageUrl);
|
|
133
|
+
if (doc)
|
|
134
|
+
return doc;
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
lastError = error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (lastError)
|
|
141
|
+
throw lastError;
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
async function fetchOpendocFromPage(http, id, pageUrl) {
|
|
145
|
+
const page = await http.text(pageUrl, {
|
|
72
146
|
'user-agent': UA,
|
|
73
147
|
accept: 'text/html',
|
|
74
148
|
});
|
|
75
|
-
const opendocUrl = extractOpendocUrl(page.body,
|
|
149
|
+
const opendocUrl = extractOpendocUrl(page.body, id) ?? defaultOpendocUrl(id);
|
|
76
150
|
const headers = {
|
|
77
151
|
'user-agent': UA,
|
|
78
|
-
referer:
|
|
152
|
+
referer: pageUrl,
|
|
79
153
|
accept: '*/*',
|
|
80
154
|
};
|
|
81
155
|
if (page.cookies)
|
|
@@ -83,14 +157,16 @@ async function fetchTencentDoc(http, url) {
|
|
|
83
157
|
const opendoc = await http.text(opendocUrl, headers);
|
|
84
158
|
return parseOpendocBody(opendoc.body);
|
|
85
159
|
}
|
|
86
|
-
function extractOfficeText(buffer, name) {
|
|
160
|
+
function extractOfficeText(buffer, name, maxBytes = exports.DEFAULT_MAX_OFFICE_BYTES) {
|
|
161
|
+
if (buffer.length > maxBytes)
|
|
162
|
+
return null;
|
|
87
163
|
const lower = name.toLowerCase();
|
|
88
164
|
if (lower.endsWith('.txt')) {
|
|
89
165
|
const text = buffer.toString('utf8').trim();
|
|
90
166
|
return text ? { title: name, text, images: [] } : null;
|
|
91
167
|
}
|
|
92
168
|
if (lower.endsWith('.docx')) {
|
|
93
|
-
const xml = readZipEntry(buffer, 'word/document.xml');
|
|
169
|
+
const xml = readZipEntry(buffer, 'word/document.xml', maxBytes);
|
|
94
170
|
if (!xml)
|
|
95
171
|
return null;
|
|
96
172
|
const text = xml
|
|
@@ -114,7 +190,7 @@ function defaultOpendocUrl(id) {
|
|
|
114
190
|
return `https://docs.qq.com/dop-api/opendoc?u=&id=${id}&normal=1&outformat=1&noEscape=1&commandsFormat=1&doc_chunk_version=3&preview_token=&doc_chunk_flag=1&callback=clientVarsCallback`;
|
|
115
191
|
}
|
|
116
192
|
function extractOpendocUrl(html, id) {
|
|
117
|
-
const match = html.match(/\/\/docs\.qq\.com\/dop-api\/opendoc\?[^"'<\s]+/);
|
|
193
|
+
const match = html.match(/\/\/(?:docs\.qq\.com|doc\.weixin\.qq\.com)\/dop-api\/opendoc\?[^"'<\s]+/);
|
|
118
194
|
if (!match)
|
|
119
195
|
return undefined;
|
|
120
196
|
const url = `https:${match[0]}`;
|
|
@@ -165,7 +241,7 @@ function extractUtf16LeText(buffer) {
|
|
|
165
241
|
chars.push(run);
|
|
166
242
|
return chars.join('\n');
|
|
167
243
|
}
|
|
168
|
-
function readZipEntry(buffer, suffix) {
|
|
244
|
+
function readZipEntry(buffer, suffix, maxBytes) {
|
|
169
245
|
let offset = 0;
|
|
170
246
|
while (offset < buffer.length) {
|
|
171
247
|
const found = buffer.indexOf(ZIP_LOCAL, offset);
|
|
@@ -175,6 +251,7 @@ function readZipEntry(buffer, suffix) {
|
|
|
175
251
|
return null;
|
|
176
252
|
const method = buffer.readUInt16LE(found + 8);
|
|
177
253
|
const compSize = buffer.readUInt32LE(found + 18);
|
|
254
|
+
const uncompSize = buffer.readUInt32LE(found + 22);
|
|
178
255
|
const nameLen = buffer.readUInt16LE(found + 26);
|
|
179
256
|
const extraLen = buffer.readUInt16LE(found + 28);
|
|
180
257
|
const nameStart = found + 30;
|
|
@@ -183,16 +260,41 @@ function readZipEntry(buffer, suffix) {
|
|
|
183
260
|
return null;
|
|
184
261
|
const name = buffer.subarray(nameStart, nameStart + nameLen).toString('utf8');
|
|
185
262
|
if (name === suffix || name.endsWith(`/${suffix}`)) {
|
|
263
|
+
if (uncompSize > 0 && uncompSize <= 0xffff_fffe && uncompSize > maxBytes)
|
|
264
|
+
return null;
|
|
186
265
|
const data = buffer.subarray(dataStart, dataStart + compSize);
|
|
187
266
|
if (method === 0)
|
|
188
|
-
return data.toString('utf8');
|
|
189
|
-
if (method === 8)
|
|
190
|
-
|
|
267
|
+
return data.length > maxBytes ? null : data.toString('utf8');
|
|
268
|
+
if (method === 8) {
|
|
269
|
+
try {
|
|
270
|
+
return (0, node_zlib_1.inflateRawSync)(data, { maxOutputLength: maxBytes }).toString('utf8');
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
191
276
|
}
|
|
192
277
|
offset = dataStart + Math.max(compSize, 1);
|
|
193
278
|
}
|
|
194
279
|
return null;
|
|
195
280
|
}
|
|
281
|
+
function decodePercents(text, times = 3) {
|
|
282
|
+
let current = text;
|
|
283
|
+
for (let i = 0; i < times; i++) {
|
|
284
|
+
if (!/%[0-9A-Fa-f]{2}/.test(current))
|
|
285
|
+
break;
|
|
286
|
+
try {
|
|
287
|
+
const next = decodeURIComponent(current);
|
|
288
|
+
if (next === current)
|
|
289
|
+
break;
|
|
290
|
+
current = next;
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return current;
|
|
297
|
+
}
|
|
196
298
|
function firstString(...values) {
|
|
197
299
|
for (const value of values) {
|
|
198
300
|
if (typeof value === 'string' && value.trim())
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -29,6 +29,7 @@ exports.Config = koishi_1.Schema.object({
|
|
|
29
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
|
+
maxOfficeMb: koishi_1.Schema.number().min(1).default(5).description('解析 Word / 文本附件的大小上限(MB)。超出则跳过,防止解压占用过多内存。'),
|
|
32
33
|
debug: koishi_1.Schema.boolean().default(true).description('输出调试日志:跳过原因、消息结构、下载/扫码/文档结果。'),
|
|
33
34
|
});
|
|
34
35
|
function apply(ctx, config) {
|
|
@@ -56,7 +57,10 @@ function apply(ctx, config) {
|
|
|
56
57
|
return;
|
|
57
58
|
}
|
|
58
59
|
const docUrls = config.scanDocs
|
|
59
|
-
? (
|
|
60
|
+
? uniqueStrings([
|
|
61
|
+
...(0, document_1.collectTencentDocUrls)([parts.text, ...parts.shares, ...parts.urls].join('\n')),
|
|
62
|
+
...parts.shares.flatMap(document_1.extractTencentDocUrlsFromShare),
|
|
63
|
+
])
|
|
60
64
|
: [];
|
|
61
65
|
const officeFiles = config.scanDocs
|
|
62
66
|
? parts.files.filter(file => (0, detect_1.isOfficeFile)(file.name) || (0, detect_1.isOfficeFile)(file.src))
|
|
@@ -146,7 +150,7 @@ function apply(ctx, config) {
|
|
|
146
150
|
}
|
|
147
151
|
for (const file of officeFiles) {
|
|
148
152
|
try {
|
|
149
|
-
const doc = (0, document_1.extractOfficeText)(await (0, qrcode_1.downloadFile)(ctx.http, { ...file, groupId: guildId }, resolveFile), file.name);
|
|
153
|
+
const doc = (0, document_1.extractOfficeText)(await (0, qrcode_1.downloadFile)(ctx.http, { ...file, groupId: guildId }, resolveFile), file.name, Math.floor(config.maxOfficeMb * 1024 * 1024));
|
|
150
154
|
const ad = doc ? (0, ad_1.detectAdContent)(doc.text, doc.title || file.name, config.adKeywords) : null;
|
|
151
155
|
if (config.debug)
|
|
152
156
|
logger.info('office %s ad=%s', file.name, Boolean(ad));
|
|
@@ -210,6 +214,7 @@ function looksRelevant(parts, config) {
|
|
|
210
214
|
return true;
|
|
211
215
|
if (config.scanDocs && (parts.files.some(file => (0, detect_1.isOfficeFile)(file.name))
|
|
212
216
|
|| (0, document_1.collectTencentDocUrls)([parts.text, ...parts.shares, ...parts.urls].join('\n')).length
|
|
217
|
+
|| parts.shares.some(share => (0, document_1.extractTencentDocUrlsFromShare)(share).length > 0)
|
|
213
218
|
|| parts.shares.some(share_1.isTencentDocCard))) {
|
|
214
219
|
return true;
|
|
215
220
|
}
|
|
@@ -233,6 +238,17 @@ function createDocHttp(http) {
|
|
|
233
238
|
function errorMessage(error) {
|
|
234
239
|
return error instanceof Error && error.message ? error.message : 'unknown';
|
|
235
240
|
}
|
|
241
|
+
function uniqueStrings(values) {
|
|
242
|
+
const seen = new Set();
|
|
243
|
+
const result = [];
|
|
244
|
+
for (const value of values) {
|
|
245
|
+
if (!value || seen.has(value))
|
|
246
|
+
continue;
|
|
247
|
+
seen.add(value);
|
|
248
|
+
result.push(value);
|
|
249
|
+
}
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
236
252
|
function cookiesFromHeaders(headers) {
|
|
237
253
|
const parts = typeof headers.getSetCookie === 'function'
|
|
238
254
|
? headers.getSetCookie()
|
package/lib/share.js
CHANGED
|
@@ -5,11 +5,16 @@ exports.isGroupInviteCard = isGroupInviteCard;
|
|
|
5
5
|
exports.isTencentDocCard = isTencentDocCard;
|
|
6
6
|
exports.extractShareCardText = extractShareCardText;
|
|
7
7
|
const detect_1 = require("./detect");
|
|
8
|
+
const document_1 = require("./document");
|
|
8
9
|
const INVITE_APPS = new Set([
|
|
9
10
|
'com.tencent.qun.invite',
|
|
10
11
|
'com.tencent.troopsharecard',
|
|
11
12
|
]);
|
|
12
13
|
const GROUP_PROMPT = /群名片|\[QQ名片\]群|推荐群聊|邀请你加入群聊|邀请加入群聊/;
|
|
14
|
+
const TENCENT_DOC_APPIDS = new Set([
|
|
15
|
+
'wxd45c635d754dbf59',
|
|
16
|
+
'1108338344',
|
|
17
|
+
]);
|
|
13
18
|
function normalizeShare(payload) {
|
|
14
19
|
let text = payload.trim();
|
|
15
20
|
const wrapped = /^\[(?:CQ:)?json(?:,data=|:data=)/i.exec(text);
|
|
@@ -58,7 +63,12 @@ function isGroupInviteCard(payload) {
|
|
|
58
63
|
return false;
|
|
59
64
|
}
|
|
60
65
|
function isTencentDocCard(payload) {
|
|
61
|
-
|
|
66
|
+
const raw = normalizeShare(payload);
|
|
67
|
+
if (/腾讯文档|docs\.qq\.com|doc\.weixin\.qq\.com|qqdocurl/i.test(raw))
|
|
68
|
+
return true;
|
|
69
|
+
if (TENCENT_DOC_APPIDS.has(findShareAppId(readShareJson(raw))))
|
|
70
|
+
return true;
|
|
71
|
+
return (0, document_1.collectTencentDocUrls)(raw).length > 0;
|
|
62
72
|
}
|
|
63
73
|
function extractShareCardText(payload) {
|
|
64
74
|
const raw = normalizeShare(payload);
|
|
@@ -87,6 +97,8 @@ function extractShareCardText(payload) {
|
|
|
87
97
|
add(item.tag);
|
|
88
98
|
add(item.summary);
|
|
89
99
|
add(item.brief);
|
|
100
|
+
add(item.appname);
|
|
101
|
+
add(item.appName);
|
|
90
102
|
}
|
|
91
103
|
}
|
|
92
104
|
return chunks.join('\n');
|
|
@@ -139,6 +151,24 @@ function tryParseJson(text) {
|
|
|
139
151
|
return null;
|
|
140
152
|
}
|
|
141
153
|
}
|
|
154
|
+
function findShareAppId(value) {
|
|
155
|
+
if (!isRecord(value))
|
|
156
|
+
return '';
|
|
157
|
+
for (const key of ['appid', 'appId', 'appID']) {
|
|
158
|
+
const id = value[key];
|
|
159
|
+
if (id === undefined || id === null)
|
|
160
|
+
continue;
|
|
161
|
+
const text = String(id);
|
|
162
|
+
if (TENCENT_DOC_APPIDS.has(text))
|
|
163
|
+
return text;
|
|
164
|
+
}
|
|
165
|
+
for (const item of Object.values(value)) {
|
|
166
|
+
const found = findShareAppId(item);
|
|
167
|
+
if (found)
|
|
168
|
+
return found;
|
|
169
|
+
}
|
|
170
|
+
return '';
|
|
171
|
+
}
|
|
142
172
|
function isRecord(value) {
|
|
143
173
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
144
174
|
}
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -14,7 +14,7 @@ Recall QR-code images, group-invite share cards, and freshman-list document ads,
|
|
|
14
14
|
|
|
15
15
|
- 🔍 **扫图识码**:下载消息里的图片,解码是否含二维码
|
|
16
16
|
- 📎 **拉群卡片**:识别 QQ 邀请加群 / 推荐群聊 / 群名片分享卡
|
|
17
|
-
- 📄
|
|
17
|
+
- 📄 **文档广告**:拉取腾讯文档(含微信 / QQ 小程序卡片)或 Word / 文本附件正文,识别夹带的床品推销等广告
|
|
18
18
|
- 🗑️ **自动撤回**:命中后立刻撤回原消息
|
|
19
19
|
- 🔇 **自动禁言**:默认禁言 60 秒,秒数可配
|
|
20
20
|
- 🛡️ **白名单**:默认跳过群主 / 管理员,也可按用户 ID、群 ID 过滤
|
|
@@ -74,7 +74,7 @@ npm run build
|
|
|
74
74
|
1. 只处理群聊,忽略私聊和机器人自己的消息
|
|
75
75
|
2. 图片二维码:收集 `img` / `image`,下载后解码
|
|
76
76
|
3. 拉群卡片:识别 `json` / `xml` / `contact` 分享卡(`com.tencent.qun.invite`、群名片、`[推荐群]` 等)
|
|
77
|
-
4.
|
|
77
|
+
4. 文档广告:从文本、新闻卡、微信 / QQ 小程序腾讯文档卡里取出 `docs.qq.com` / `doc.weixin.qq.com` 链接(含编码过的小程序 path、`qqdocurl`),或下载 `.doc` / `.docx` / `.txt`,识别夹带的推销文案(例如校园床品「送货到寝」)
|
|
78
78
|
5. 文档里的图也会再扫一遍二维码
|
|
79
79
|
|
|
80
80
|
不会把解码出的文本或广告原文发回群里。纯物品清单(只写「被子、枕头」而没有推销)不会误杀。
|
|
@@ -94,6 +94,7 @@ npm run build
|
|
|
94
94
|
| `scanGroupInvite` | `boolean` | `true` | 拦截邀请 / 推荐群聊分享卡 |
|
|
95
95
|
| `scanDocs` | `boolean` | `true` | 检查腾讯文档和 Word / 文本附件 |
|
|
96
96
|
| `adKeywords` | `string[]` | `[]` | 额外广告关键词,命中即撤回 |
|
|
97
|
+
| `maxOfficeMb` | `number` | `5` | Word / 文本附件的大小上限(MB),超出则跳过 |
|
|
97
98
|
| `debug` | `boolean` | `true` | 输出调试日志:跳过原因、消息结构、下载 / 扫码 / 文档结果 |
|
|
98
99
|
|
|
99
100
|
内置词在 `src/ad-keywords.txt`,一行一条。`[strong]` 命中任意一条即撤回;`[commerce]` 要同时出现床品类用词且至少两条。控制台 `adKeywords` 按强匹配叠加。
|
|
@@ -278,7 +279,7 @@ Actions 会 `npm ci` → `npm test` → `npm run build` → `npm publish --acces
|
|
|
278
279
|
|
|
279
280
|
## Changelog
|
|
280
281
|
|
|
281
|
-
当前版本 **1.2.0
|
|
282
|
+
当前版本 **1.3.0**:识别微信 / QQ 小程序腾讯文档卡,还原 `docs.qq.com` 链接后再按原逻辑拉正文。1.2.1:Word / 文本附件默认限制 5MB(`maxOfficeMb`)。1.2.0 起内置广告词改到 `ad-keywords.txt` 维护。1.1.3 补拦 QQ 群名片。1.1.2 修复 OneBot `getImage` 未绑定导致的生产崩溃。1.1.1 补齐跳过原因日志。1.1.0 起拦截拉群分享卡和腾讯文档 / Word 广告。
|
|
282
283
|
|
|
283
284
|
## License
|
|
284
285
|
|