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 +11 -1
- package/lib/detect.js +134 -30
- package/lib/document.js +1 -1
- package/lib/index.d.ts +2 -1
- package/lib/index.js +104 -19
- package/lib/qrcode.d.ts +16 -1
- package/lib/qrcode.js +110 -6
- package/lib/share.d.ts +2 -0
- package/lib/share.js +52 -1
- package/package.json +3 -2
- package/readme.md +7 -4
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
|
|
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([
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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<>"'
|
|
119
|
-
const url =
|
|
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(/"/g, '"')
|
|
@@ -132,24 +154,103 @@ function decodeEntities(text) {
|
|
|
132
154
|
.replace(/</g, '<')
|
|
133
155
|
.replace(/>/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
|
|
137
|
-
|
|
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
|
|
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
|
|
142
|
-
if (!input
|
|
143
|
-
return
|
|
144
|
-
if (
|
|
145
|
-
return
|
|
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
|
|
245
|
+
return 'self';
|
|
148
246
|
if (input.ignoreUsers.includes(input.userId))
|
|
149
|
-
return
|
|
247
|
+
return 'ignored';
|
|
150
248
|
if (input.skipAdmins && isAdminRole(input.roles))
|
|
151
|
-
return
|
|
152
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
92
|
-
|
|
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
|
-
|
|
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 &&
|
|
186
|
+
if (duration > 0 && guildId && session.userId) {
|
|
119
187
|
try {
|
|
120
|
-
await session.bot.muteGuildMember(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
89
|
+
const image = await prepareImage(buffer);
|
|
22
90
|
const { data, width, height } = image.bitmap;
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|