koishi-plugin-message-dedup 0.0.1

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/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # koishi-plugin-message-dedup
2
+
3
+ Koishi 消息去重插件,检测群内重复的图片、链接、聊天记录。
4
+
5
+ ![](assets/dup-1.jpg)
6
+
7
+ ## 功能
8
+
9
+ - **链接去重**:检测重复发送的网页链接
10
+ - **转发消息去重**:检测重复的聊天记录转发
11
+ - **图片去重**:使用 pHash 感知哈希检测相似图片(默认关闭,可能误判表情包)
12
+
13
+ ## 配置
14
+
15
+ | 配置项 | 说明 | 默认值 |
16
+ |--------|------|--------|
17
+ | enableImage | 启用图片去重 | false |
18
+ | enableLink | 启用链接去重 | true |
19
+ | enableForward | 启用转发消息去重 | true |
20
+ | sendMethod | 图片发送方式 (koishi/onebot) | onebot |
21
+
22
+ ## 安装
23
+
24
+ ```bash
25
+ npm install koishi-plugin-message-dedup
26
+ ```
27
+
28
+ ## License
29
+
30
+ MIT
Binary file
@@ -0,0 +1,16 @@
1
+ import { Schema } from 'koishi';
2
+ export interface Config {
3
+ enableImage: boolean;
4
+ enableLink: boolean;
5
+ enableForward: boolean;
6
+ imageSimilarityThreshold: number;
7
+ linkExactMatch: boolean;
8
+ forwardContentMaxLength: number;
9
+ retentionDays: number;
10
+ stickerDir: string;
11
+ sendMethod: 'koishi' | 'onebot';
12
+ excludedGuilds: string[];
13
+ excludedUsers: string[];
14
+ debug: boolean;
15
+ }
16
+ export declare const Config: Schema<Config>;
package/lib/config.js ADDED
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Config = void 0;
4
+ const koishi_1 = require("koishi");
5
+ exports.Config = koishi_1.Schema.object({
6
+ enableImage: koishi_1.Schema.boolean()
7
+ .default(false)
8
+ .description('启用图片去重(默认关闭,可能误判表情包)'),
9
+ enableLink: koishi_1.Schema.boolean()
10
+ .default(true)
11
+ .description('启用链接去重'),
12
+ enableForward: koishi_1.Schema.boolean()
13
+ .default(true)
14
+ .description('启用转发消息去重'),
15
+ imageSimilarityThreshold: koishi_1.Schema.number()
16
+ .default(10)
17
+ .min(0)
18
+ .max(32)
19
+ .description('图片相似度阈值'),
20
+ linkExactMatch: koishi_1.Schema.boolean()
21
+ .default(true)
22
+ .description('链接是否要求完全匹配'),
23
+ forwardContentMaxLength: koishi_1.Schema.number()
24
+ .default(500)
25
+ .min(100)
26
+ .max(2000)
27
+ .description('转发消息内容摘要最大长度'),
28
+ retentionDays: koishi_1.Schema.number()
29
+ .default(7)
30
+ .min(1)
31
+ .max(30)
32
+ .description('数据保留天数'),
33
+ stickerDir: koishi_1.Schema.string()
34
+ .default('data/emojiluna/dup')
35
+ .description('表情包目录路径'),
36
+ sendMethod: koishi_1.Schema.union([
37
+ koishi_1.Schema.const('koishi').description('Koishi通用方式'),
38
+ koishi_1.Schema.const('onebot').description('OneBot API直接发送')
39
+ ])
40
+ .default('onebot')
41
+ .description('图片发送方式'),
42
+ excludedGuilds: koishi_1.Schema.array(String)
43
+ .default([])
44
+ .description('排除检测的群ID列表'),
45
+ excludedUsers: koishi_1.Schema.array(String)
46
+ .default([])
47
+ .description('排除检测的用户ID列表'),
48
+ debug: koishi_1.Schema.boolean()
49
+ .default(false)
50
+ .description('调试模式')
51
+ });
@@ -0,0 +1,22 @@
1
+ import { Context } from 'koishi';
2
+ declare module 'koishi' {
3
+ interface Tables {
4
+ message_dedup: DedupRecord;
5
+ }
6
+ }
7
+ export interface DedupRecord {
8
+ id?: number;
9
+ guildId: string;
10
+ userId: string;
11
+ username: string;
12
+ timestamp: number;
13
+ contentType: 'image' | 'link' | 'forward';
14
+ contentHash: string;
15
+ originalMessageId: string;
16
+ originalContent: string;
17
+ extraInfo: string;
18
+ }
19
+ export declare function extendDatabase(ctx: Context): void;
20
+ export declare function findDuplicate(ctx: Context, guildId: string, contentType: 'image' | 'link' | 'forward', contentHash: string, imageThreshold?: number): Promise<DedupRecord | null>;
21
+ export declare function saveRecord(ctx: Context, record: DedupRecord): Promise<void>;
22
+ export declare function cleanupOldRecords(ctx: Context, retentionDays: number): Promise<void>;
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extendDatabase = extendDatabase;
4
+ exports.findDuplicate = findDuplicate;
5
+ exports.saveRecord = saveRecord;
6
+ exports.cleanupOldRecords = cleanupOldRecords;
7
+ const hash_1 = require("./hash");
8
+ function extendDatabase(ctx) {
9
+ ctx.model.extend('message_dedup', {
10
+ id: 'unsigned',
11
+ guildId: 'string',
12
+ userId: 'string',
13
+ username: 'string',
14
+ timestamp: 'integer',
15
+ contentType: 'string',
16
+ contentHash: 'string',
17
+ originalMessageId: 'string',
18
+ originalContent: 'text',
19
+ extraInfo: 'text'
20
+ }, {
21
+ primary: 'id',
22
+ autoInc: true
23
+ });
24
+ }
25
+ async function findDuplicate(ctx, guildId, contentType, contentHash, imageThreshold) {
26
+ const records = await ctx.database.get('message_dedup', {
27
+ guildId,
28
+ contentType
29
+ });
30
+ if (contentType === 'image' && imageThreshold !== undefined) {
31
+ // 图片需要计算汉明距离
32
+ // compareHashes返回0-1,0表示相同
33
+ // threshold是百分比阈值(如10表示10%)
34
+ const thresholdRatio = imageThreshold / 100;
35
+ for (const record of records) {
36
+ try {
37
+ const distance = (0, hash_1.calculateHashDistance)(contentHash, record.contentHash);
38
+ if (distance <= thresholdRatio) {
39
+ return record;
40
+ }
41
+ }
42
+ catch {
43
+ // 哈希格式不匹配时跳过
44
+ continue;
45
+ }
46
+ }
47
+ }
48
+ else {
49
+ // 链接和转发消息直接匹配哈希
50
+ return records.find(r => r.contentHash === contentHash) || null;
51
+ }
52
+ return null;
53
+ }
54
+ async function saveRecord(ctx, record) {
55
+ await ctx.database.create('message_dedup', record);
56
+ }
57
+ async function cleanupOldRecords(ctx, retentionDays) {
58
+ const cutoffTime = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
59
+ await ctx.database.remove('message_dedup', {
60
+ timestamp: { $lt: cutoffTime }
61
+ });
62
+ }
package/lib/hash.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { Context } from 'koishi';
2
+ /**
3
+ * 计算图片感知哈希(pHash)
4
+ */
5
+ export declare function calculateImageHash(buffer: Buffer): Promise<string>;
6
+ /**
7
+ * 计算两个哈希的汉明距离(返回0-1,0表示相同)
8
+ */
9
+ export declare function calculateHashDistance(hash1: string, hash2: string): number;
10
+ /**
11
+ * 从URL下载图片并计算哈希
12
+ */
13
+ export declare function downloadAndHashImage(imageUrl: string, ctx: Context): Promise<string | null>;
14
+ /**
15
+ * 提取消息中的URL
16
+ */
17
+ export declare function extractUrls(text: string): string[];
18
+ /**
19
+ * 计算字符串哈希(用于链接/转发消息)
20
+ */
21
+ export declare function calculateStringHash(str: string): string;
22
+ /**
23
+ * 标准化URL(去除跟踪参数等)
24
+ */
25
+ export declare function normalizeUrl(url: string): string;
package/lib/hash.js ADDED
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.calculateImageHash = calculateImageHash;
37
+ exports.calculateHashDistance = calculateHashDistance;
38
+ exports.downloadAndHashImage = downloadAndHashImage;
39
+ exports.extractUrls = extractUrls;
40
+ exports.calculateStringHash = calculateStringHash;
41
+ exports.normalizeUrl = normalizeUrl;
42
+ const jimp_1 = require("jimp");
43
+ /**
44
+ * 计算图片感知哈希(pHash)
45
+ */
46
+ async function calculateImageHash(buffer) {
47
+ const image = await jimp_1.Jimp.read(buffer);
48
+ return image.pHash();
49
+ }
50
+ /**
51
+ * 计算两个哈希的汉明距离(返回0-1,0表示相同)
52
+ */
53
+ function calculateHashDistance(hash1, hash2) {
54
+ return (0, jimp_1.compareHashes)(hash1, hash2);
55
+ }
56
+ /**
57
+ * 从URL下载图片并计算哈希
58
+ */
59
+ async function downloadAndHashImage(imageUrl, ctx) {
60
+ try {
61
+ // 检查是否是data URI
62
+ if (imageUrl.startsWith('data:')) {
63
+ // data:image/png;base64,xxxxx
64
+ const base64Match = imageUrl.match(/^data:image\/[^;]+;base64,(.+)$/);
65
+ if (base64Match) {
66
+ const buffer = Buffer.from(base64Match[1], 'base64');
67
+ return await calculateImageHash(buffer);
68
+ }
69
+ return null;
70
+ }
71
+ // 检查是否是file URI
72
+ if (imageUrl.startsWith('file://')) {
73
+ const filePath = imageUrl.slice(7);
74
+ const fs = await Promise.resolve().then(() => __importStar(require('fs')));
75
+ const buffer = fs.readFileSync(filePath);
76
+ return await calculateImageHash(buffer);
77
+ }
78
+ // 使用http服务下载图片
79
+ if (!ctx.http) {
80
+ return null;
81
+ }
82
+ const response = await ctx.http.get(imageUrl, { responseType: 'arraybuffer' });
83
+ const buffer = Buffer.from(response);
84
+ return await calculateImageHash(buffer);
85
+ }
86
+ catch (err) {
87
+ return null;
88
+ }
89
+ }
90
+ /**
91
+ * 提取消息中的URL
92
+ */
93
+ function extractUrls(text) {
94
+ const urlRegex = /https?:\/\/[^\s<>"{}|\\^`\[\]]+/gi;
95
+ return text.match(urlRegex) || [];
96
+ }
97
+ /**
98
+ * 计算字符串哈希(用于链接/转发消息)
99
+ */
100
+ function calculateStringHash(str) {
101
+ let hash = 5381;
102
+ for (let i = 0; i < str.length; i++) {
103
+ hash = ((hash << 5) + hash) + str.charCodeAt(i);
104
+ hash = hash & 0xFFFFFFFF;
105
+ }
106
+ return hash.toString(16);
107
+ }
108
+ /**
109
+ * 标准化URL(去除跟踪参数等)
110
+ */
111
+ function normalizeUrl(url) {
112
+ try {
113
+ const urlObj = new URL(url);
114
+ const trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'spm', 'from'];
115
+ trackingParams.forEach(p => urlObj.searchParams.delete(p));
116
+ return urlObj.toString();
117
+ }
118
+ catch {
119
+ return url;
120
+ }
121
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { Context } from 'koishi';
2
+ import { Config } from './config';
3
+ export declare const name = "message-dedup";
4
+ export declare const inject: {
5
+ readonly required: readonly ["database"];
6
+ readonly optional: readonly ["http"];
7
+ };
8
+ export { Config };
9
+ export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js ADDED
@@ -0,0 +1,345 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.Config = exports.inject = exports.name = void 0;
37
+ exports.apply = apply;
38
+ const koishi_1 = require("koishi");
39
+ const config_1 = require("./config");
40
+ Object.defineProperty(exports, "Config", { enumerable: true, get: function () { return config_1.Config; } });
41
+ const hash_1 = require("./hash");
42
+ const database_1 = require("./database");
43
+ const fs = __importStar(require("fs"));
44
+ const path = __importStar(require("path"));
45
+ exports.name = 'message-dedup';
46
+ exports.inject = {
47
+ required: ['database'],
48
+ optional: ['http']
49
+ };
50
+ function apply(ctx, config) {
51
+ const logger = ctx.logger('message-dedup');
52
+ // 扩展数据库模型
53
+ (0, database_1.extendDatabase)(ctx);
54
+ // 定期清理过期数据
55
+ const retentionDays = config.retentionDays ?? 7;
56
+ if (retentionDays > 0) {
57
+ ctx.setInterval(() => {
58
+ (0, database_1.cleanupOldRecords)(ctx, retentionDays).catch(err => {
59
+ logger.error('清理过期数据失败:', err);
60
+ });
61
+ }, 24 * 60 * 60 * 1000);
62
+ }
63
+ // 中间件处理消息
64
+ ctx.middleware(async (session, next) => {
65
+ // 只处理群消息
66
+ if (!session.guildId)
67
+ return next();
68
+ // 检查是否在排除列表
69
+ if (config.excludedGuilds.includes(session.guildId))
70
+ return next();
71
+ if (session.userId && config.excludedUsers.includes(session.userId))
72
+ return next();
73
+ try {
74
+ const result = await processMessage(session, config, ctx, logger);
75
+ if (result) {
76
+ await sendDuplicateWarning(session, result, config, ctx);
77
+ return;
78
+ }
79
+ }
80
+ catch (err) {
81
+ if (config.debug) {
82
+ logger.error('处理消息失败:', err);
83
+ }
84
+ }
85
+ return next();
86
+ });
87
+ if (config.debug) {
88
+ logger.info('message-dedup 插件已加载(调试模式)');
89
+ }
90
+ }
91
+ async function processMessage(session, config, ctx, logger) {
92
+ const elements = session.elements;
93
+ if (!elements || elements.length === 0)
94
+ return null;
95
+ const username = session.author?.nickname || session.author?.username || session.username || '未知用户';
96
+ const originalContent = session.content || extractTextFromElements(elements) || '';
97
+ // 1. 检查图片
98
+ if (config.enableImage) {
99
+ for (const elem of elements) {
100
+ if (elem.type === 'img' && elem.attrs?.src) {
101
+ const duplicate = await processImage(elem.attrs.src, session, username, originalContent, config, ctx, logger);
102
+ if (duplicate)
103
+ return duplicate;
104
+ }
105
+ }
106
+ }
107
+ // 2. 检查链接
108
+ if (config.enableLink) {
109
+ const text = extractTextFromElements(elements);
110
+ const urls = (0, hash_1.extractUrls)(text);
111
+ for (const url of urls) {
112
+ const duplicate = await processLink(url, session, username, originalContent, config, ctx, logger);
113
+ if (duplicate)
114
+ return duplicate;
115
+ }
116
+ }
117
+ // 3. 检查转发消息
118
+ if (config.enableForward) {
119
+ for (const elem of elements) {
120
+ if (elem.type === 'forward') {
121
+ const duplicate = await processForward(elem, session, username, originalContent, config, ctx, logger);
122
+ if (duplicate)
123
+ return duplicate;
124
+ }
125
+ }
126
+ }
127
+ return null;
128
+ }
129
+ function extractTextFromElements(elements) {
130
+ return elements.map(elem => {
131
+ if (elem.type === 'text') {
132
+ return elem.attrs?.content || '';
133
+ }
134
+ if (elem.children && elem.children.length > 0) {
135
+ return extractTextFromElements(elem.children);
136
+ }
137
+ return '';
138
+ }).join('');
139
+ }
140
+ async function processImage(imageUrl, session, username, originalContent, config, ctx, logger) {
141
+ try {
142
+ if (config.debug) {
143
+ logger.info(`处理图片: ${imageUrl}`);
144
+ }
145
+ const hash = await (0, hash_1.downloadAndHashImage)(imageUrl, ctx);
146
+ if (!hash) {
147
+ if (config.debug) {
148
+ logger.info(`无法计算图片哈希`);
149
+ }
150
+ return null;
151
+ }
152
+ if (config.debug) {
153
+ logger.info(`图片哈希: ${hash}`);
154
+ }
155
+ const guildId = session.guildId;
156
+ const duplicate = await (0, database_1.findDuplicate)(ctx, guildId, 'image', hash, config.imageSimilarityThreshold);
157
+ if (duplicate) {
158
+ if (config.debug) {
159
+ logger.info(`发现重复图片`);
160
+ }
161
+ return duplicate;
162
+ }
163
+ await (0, database_1.saveRecord)(ctx, {
164
+ guildId: session.guildId,
165
+ userId: session.userId,
166
+ username,
167
+ timestamp: Date.now(),
168
+ contentType: 'image',
169
+ contentHash: hash,
170
+ originalMessageId: session.messageId,
171
+ originalContent,
172
+ extraInfo: JSON.stringify({ url: imageUrl })
173
+ });
174
+ return null;
175
+ }
176
+ catch (err) {
177
+ if (config.debug) {
178
+ logger.error('处理图片失败:', err);
179
+ }
180
+ return null;
181
+ }
182
+ }
183
+ async function processLink(url, session, username, originalContent, config, ctx, logger) {
184
+ try {
185
+ const normalizedUrl = config.linkExactMatch ? url : (0, hash_1.normalizeUrl)(url);
186
+ const hash = (0, hash_1.calculateStringHash)(normalizedUrl);
187
+ if (config.debug) {
188
+ logger.info(`链接哈希: ${hash} URL: ${url}`);
189
+ }
190
+ const guildId = session.guildId;
191
+ const duplicate = await (0, database_1.findDuplicate)(ctx, guildId, 'link', hash);
192
+ if (duplicate) {
193
+ if (config.debug) {
194
+ logger.info(`发现重复链接`);
195
+ }
196
+ return duplicate;
197
+ }
198
+ await (0, database_1.saveRecord)(ctx, {
199
+ guildId: session.guildId,
200
+ userId: session.userId,
201
+ username,
202
+ timestamp: Date.now(),
203
+ contentType: 'link',
204
+ contentHash: hash,
205
+ originalMessageId: session.messageId,
206
+ originalContent,
207
+ extraInfo: JSON.stringify({ url })
208
+ });
209
+ return null;
210
+ }
211
+ catch (err) {
212
+ if (config.debug) {
213
+ logger.error('处理链接失败:', err);
214
+ }
215
+ return null;
216
+ }
217
+ }
218
+ async function processForward(forwardElem, session, username, originalContent, config, ctx, logger) {
219
+ try {
220
+ const content = extractForwardContent(forwardElem);
221
+ if (!content || content.length < 10) {
222
+ return null;
223
+ }
224
+ const truncated = content.slice(0, config.forwardContentMaxLength);
225
+ const hash = (0, hash_1.calculateStringHash)(truncated);
226
+ if (config.debug) {
227
+ logger.info(`转发消息哈希: ${hash}, 内容长度: ${content.length}`);
228
+ }
229
+ const guildId = session.guildId;
230
+ const duplicate = await (0, database_1.findDuplicate)(ctx, guildId, 'forward', hash);
231
+ if (duplicate) {
232
+ if (config.debug) {
233
+ logger.info(`发现重复转发消息`);
234
+ }
235
+ return duplicate;
236
+ }
237
+ await (0, database_1.saveRecord)(ctx, {
238
+ guildId: session.guildId,
239
+ userId: session.userId,
240
+ username,
241
+ timestamp: Date.now(),
242
+ contentType: 'forward',
243
+ contentHash: hash,
244
+ originalMessageId: session.messageId,
245
+ originalContent: truncated.slice(0, 100),
246
+ extraInfo: JSON.stringify({ preview: truncated.slice(0, 100) })
247
+ });
248
+ return null;
249
+ }
250
+ catch (err) {
251
+ if (config.debug) {
252
+ logger.error('处理转发消息失败:', err);
253
+ }
254
+ return null;
255
+ }
256
+ }
257
+ function extractForwardContent(elem) {
258
+ let content = '';
259
+ if (elem.attrs?.content) {
260
+ content += elem.attrs.content;
261
+ }
262
+ if (elem.attrs?.text) {
263
+ content += elem.attrs.text;
264
+ }
265
+ if (elem.children && elem.children.length > 0) {
266
+ for (const child of elem.children) {
267
+ content += extractForwardContent(child);
268
+ }
269
+ }
270
+ return content;
271
+ }
272
+ async function sendDuplicateWarning(session, duplicate, config, ctx) {
273
+ const date = new Date(duplicate.timestamp);
274
+ const dateStr = date.toLocaleString('zh-CN', {
275
+ year: 'numeric',
276
+ month: '2-digit',
277
+ day: '2-digit',
278
+ hour: '2-digit',
279
+ minute: '2-digit'
280
+ });
281
+ const message = `此条news已被其他群友发布过\n原消息:${dateStr}\n发送人:${duplicate.username}`;
282
+ // 随机选择表情包
283
+ const stickerFile = getRandomSticker(ctx.baseDir, config.stickerDir);
284
+ // 根据配置选择发送方式
285
+ if (config.sendMethod === 'onebot' && session.platform === 'onebot' && session.bot?.internal) {
286
+ const groupId = Number(session.guildId);
287
+ try {
288
+ // 使用 reply CQ码引用原消息
289
+ const originalMsgId = duplicate.originalMessageId;
290
+ let cqMessage = `[CQ:reply,id=${originalMsgId}]${message}`;
291
+ // 添加表情包
292
+ if (stickerFile) {
293
+ const imageBase64 = fs.readFileSync(stickerFile).toString('base64');
294
+ cqMessage += `[CQ:image,file=base64://${imageBase64}]`;
295
+ }
296
+ await session.bot.internal.sendGroupMsg(groupId, cqMessage);
297
+ return;
298
+ }
299
+ catch (err) {
300
+ // OneBot 发送失败,回退到 Koishi 方式
301
+ }
302
+ }
303
+ // Koishi 通用方式,使用 quote 引用
304
+ if (stickerFile) {
305
+ const imageBase64 = fs.readFileSync(stickerFile).toString('base64');
306
+ const ext = path.extname(stickerFile).slice(1).toLowerCase();
307
+ const mimeType = ext === 'jpg' ? 'jpeg' : ext;
308
+ await session.send([
309
+ koishi_1.h.quote(duplicate.originalMessageId),
310
+ message,
311
+ koishi_1.h.img(`data:image/${mimeType};base64,${imageBase64}`)
312
+ ]);
313
+ }
314
+ else {
315
+ await session.send([koishi_1.h.quote(duplicate.originalMessageId), message]);
316
+ }
317
+ }
318
+ /**
319
+ * 从表情包目录随机选择一张图片
320
+ * 如果目录不存在或为空,使用插件内置默认表情包
321
+ */
322
+ function getRandomSticker(baseDir, stickerDir) {
323
+ const supportedExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
324
+ // 尝试用户配置的目录
325
+ const userDir = path.resolve(baseDir, stickerDir);
326
+ if (fs.existsSync(userDir)) {
327
+ const files = fs.readdirSync(userDir)
328
+ .filter(f => supportedExtensions.some(ext => f.toLowerCase().endsWith(ext)));
329
+ if (files.length > 0) {
330
+ const randomFile = files[Math.floor(Math.random() * files.length)];
331
+ return path.join(userDir, randomFile);
332
+ }
333
+ }
334
+ // 尝试插件内置默认表情包目录
335
+ const pluginAssetsDir = path.resolve(__dirname, '../assets');
336
+ if (fs.existsSync(pluginAssetsDir)) {
337
+ const files = fs.readdirSync(pluginAssetsDir)
338
+ .filter(f => supportedExtensions.some(ext => f.toLowerCase().endsWith(ext)));
339
+ if (files.length > 0) {
340
+ const randomFile = files[Math.floor(Math.random() * files.length)];
341
+ return path.join(pluginAssetsDir, randomFile);
342
+ }
343
+ }
344
+ return null;
345
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "koishi-plugin-message-dedup",
3
+ "version": "0.0.1",
4
+ "description": "消息去重插件,检测群内重复的图片、链接、聊天记录",
5
+ "main": "lib/index.js",
6
+ "typings": "lib/index.d.ts",
7
+ "files": [
8
+ "lib",
9
+ "assets"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc -p tsconfig.json",
13
+ "watch": "tsc -p tsconfig.json --watch"
14
+ },
15
+ "peerDependencies": {
16
+ "koishi": "^4.18.0"
17
+ },
18
+ "keywords": [
19
+ "koishi",
20
+ "plugin",
21
+ "dedup",
22
+ "message"
23
+ ],
24
+ "license": "MIT",
25
+ "author": "FHfanshu",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/FHfanshu/koishi-plugin-message-dedup"
29
+ },
30
+ "koishi": {
31
+ "description": {
32
+ "zh": "消息去重插件,检测群内重复的图片、链接、聊天记录并提醒"
33
+ },
34
+ "service": {
35
+ "required": [
36
+ "database"
37
+ ],
38
+ "optional": [
39
+ "http"
40
+ ]
41
+ }
42
+ }
43
+ }