koishi-plugin-ban-qrcode 1.2.1 → 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 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(unwrapShareData(node.attrs?.data ?? node.attrs?.content ?? node.attrs?.value));
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
@@ -16,6 +16,7 @@ export declare function parseTencentDocUrl(url: string): {
16
16
  pageUrl: string;
17
17
  } | null;
18
18
  export declare function collectTencentDocUrls(text: string): string[];
19
+ export declare function extractTencentDocUrlsFromShare(payload: string): string[];
19
20
  export declare function parseOpendocBody(body: string): DocContent | null;
20
21
  export declare function fetchTencentDoc(http: DocHttp, url: string): Promise<DocContent | null>;
21
22
  export declare function extractOfficeText(buffer: Buffer, name: string, maxBytes?: number): DocContent | null;
package/lib/document.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEFAULT_MAX_OFFICE_BYTES = void 0;
4
4
  exports.parseTencentDocUrl = parseTencentDocUrl;
5
5
  exports.collectTencentDocUrls = collectTencentDocUrls;
6
+ exports.extractTencentDocUrlsFromShare = extractTencentDocUrlsFromShare;
6
7
  exports.parseOpendocBody = parseOpendocBody;
7
8
  exports.fetchTencentDoc = fetchTencentDoc;
8
9
  exports.extractOfficeText = extractOfficeText;
@@ -10,33 +11,81 @@ exports.extractReadableText = extractReadableText;
10
11
  const node_zlib_1 = require("node:zlib");
11
12
  const detect_1 = require("./detect");
12
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';
13
- const DOC_RE = /https?:\/\/(?:docs\.qq\.com|doc\.weixin\.qq\.com)\/(doc|sheet|slide|pdf|form|mind)\/([A-Za-z0-9]+)/i;
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');
14
18
  const ZIP_LOCAL = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
15
19
  const IMAGE_RE = /https?:\/\/docimg\d+\.docs\.qq\.com\/image\/[A-Za-z0-9_-]+(?:\.(?:jpe?g|png|webp))?/gi;
16
20
  exports.DEFAULT_MAX_OFFICE_BYTES = 5 * 1024 * 1024;
17
21
  function parseTencentDocUrl(url) {
18
- const match = DOC_RE.exec(url);
22
+ const match = DOC_RE.exec(decodePercents((0, detect_1.unescapePayload)(url)));
19
23
  if (!match)
20
24
  return null;
25
+ const kind = match[1].toLowerCase();
26
+ const id = match[2];
27
+ const path = kind === 'form' ? `form/page/${id}` : `${kind}/${id}`;
21
28
  return {
22
- kind: match[1],
23
- id: match[2],
24
- pageUrl: `https://docs.qq.com/${match[1]}/${match[2]}`,
29
+ kind,
30
+ id,
31
+ pageUrl: `https://docs.qq.com/${path}`,
25
32
  };
26
33
  }
27
34
  function collectTencentDocUrls(text) {
28
35
  const urls = [];
29
36
  const seen = new Set();
30
- const re = new RegExp(DOC_RE.source, 'gi');
31
- for (const match of (0, detect_1.unescapePayload)(text).matchAll(re)) {
32
- const parsed = parseTencentDocUrl(match[0]);
33
- if (!parsed || seen.has(parsed.pageUrl))
34
- continue;
35
- seen.add(parsed.pageUrl);
36
- urls.push(parsed.pageUrl);
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]);
37
71
  }
38
72
  return urls;
39
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
+ }
40
89
  function parseOpendocBody(body) {
41
90
  const data = parseJsonp(body);
42
91
  if (!data)
@@ -70,14 +119,37 @@ async function fetchTencentDoc(http, url) {
70
119
  const parsed = parseTencentDocUrl(url);
71
120
  if (!parsed)
72
121
  return null;
73
- const page = await http.text(parsed.pageUrl, {
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, {
74
146
  'user-agent': UA,
75
147
  accept: 'text/html',
76
148
  });
77
- const opendocUrl = extractOpendocUrl(page.body, parsed.id) ?? defaultOpendocUrl(parsed.id);
149
+ const opendocUrl = extractOpendocUrl(page.body, id) ?? defaultOpendocUrl(id);
78
150
  const headers = {
79
151
  'user-agent': UA,
80
- referer: parsed.pageUrl,
152
+ referer: pageUrl,
81
153
  accept: '*/*',
82
154
  };
83
155
  if (page.cookies)
@@ -118,7 +190,7 @@ function defaultOpendocUrl(id) {
118
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`;
119
191
  }
120
192
  function extractOpendocUrl(html, id) {
121
- 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]+/);
122
194
  if (!match)
123
195
  return undefined;
124
196
  const url = `https:${match[0]}`;
@@ -206,6 +278,23 @@ function readZipEntry(buffer, suffix, maxBytes) {
206
278
  }
207
279
  return null;
208
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
+ }
209
298
  function firstString(...values) {
210
299
  for (const value of values) {
211
300
  if (typeof value === 'string' && value.trim())
package/lib/index.js CHANGED
@@ -57,7 +57,10 @@ function apply(ctx, config) {
57
57
  return;
58
58
  }
59
59
  const docUrls = config.scanDocs
60
- ? (0, document_1.collectTencentDocUrls)([parts.text, ...parts.shares, ...parts.urls].join('\n'))
60
+ ? uniqueStrings([
61
+ ...(0, document_1.collectTencentDocUrls)([parts.text, ...parts.shares, ...parts.urls].join('\n')),
62
+ ...parts.shares.flatMap(document_1.extractTencentDocUrlsFromShare),
63
+ ])
61
64
  : [];
62
65
  const officeFiles = config.scanDocs
63
66
  ? parts.files.filter(file => (0, detect_1.isOfficeFile)(file.name) || (0, detect_1.isOfficeFile)(file.src))
@@ -211,6 +214,7 @@ function looksRelevant(parts, config) {
211
214
  return true;
212
215
  if (config.scanDocs && (parts.files.some(file => (0, detect_1.isOfficeFile)(file.name))
213
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)
214
218
  || parts.shares.some(share_1.isTencentDocCard))) {
215
219
  return true;
216
220
  }
@@ -234,6 +238,17 @@ function createDocHttp(http) {
234
238
  function errorMessage(error) {
235
239
  return error instanceof Error && error.message ? error.message : 'unknown';
236
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
+ }
237
252
  function cookiesFromHeaders(headers) {
238
253
  const parts = typeof headers.getSetCookie === 'function'
239
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
- return /腾讯文档|docs\.qq\.com|doc\.weixin\.qq\.com/i.test(normalizeShare(payload));
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-ban-qrcode",
3
3
  "description": "检测到群内二维码、拉群卡片或文档广告后自动撤回并禁言",
4
- "version": "1.2.1",
4
+ "version": "1.3.0",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
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
- - 📄 **文档广告**:拉取腾讯文档或 Word / 文本附件正文,识别夹带的床品推销等广告
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. 文档广告:从文本、分享卡里取出 `docs.qq.com` 链接,或下载 `.doc` / `.docx` / `.txt`,识别夹带的推销文案(例如校园床品「送货到寝」)
77
+ 4. 文档广告:从文本、新闻卡、微信 / QQ 小程序腾讯文档卡里取出 `docs.qq.com` / `doc.weixin.qq.com` 链接(含编码过的小程序 path、`qqdocurl`),或下载 `.doc` / `.docx` / `.txt`,识别夹带的推销文案(例如校园床品「送货到寝」)
78
78
  5. 文档里的图也会再扫一遍二维码
79
79
 
80
80
  不会把解码出的文本或广告原文发回群里。纯物品清单(只写「被子、枕头」而没有推销)不会误杀。
@@ -279,7 +279,7 @@ Actions 会 `npm ci` → `npm test` → `npm run build` → `npm publish --acces
279
279
 
280
280
  ## Changelog
281
281
 
282
- 当前版本 **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
+ 当前版本 **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 广告。
283
283
 
284
284
  ## License
285
285