koishi-plugin-emojiluna-plus 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.
Files changed (49) hide show
  1. package/LICENSE +24 -0
  2. package/dist/index.js +10 -0
  3. package/dist/style.css +1 -0
  4. package/lib/adapters/autoCommands.d.ts +4 -0
  5. package/lib/adapters/autoCommands.js +111 -0
  6. package/lib/adapters/backend.d.ts +3 -0
  7. package/lib/adapters/backend.js +11 -0
  8. package/lib/adapters/chatlunaIntegration.d.ts +3 -0
  9. package/lib/adapters/chatlunaIntegration.js +48 -0
  10. package/lib/adapters/commands.d.ts +4 -0
  11. package/lib/adapters/commands.js +330 -0
  12. package/lib/adapters/consoleListeners.d.ts +3 -0
  13. package/lib/adapters/consoleListeners.js +168 -0
  14. package/lib/adapters/index.d.ts +2 -0
  15. package/lib/adapters/index.js +7 -0
  16. package/lib/adapters/render.d.ts +7 -0
  17. package/lib/adapters/render.js +35 -0
  18. package/lib/adapters/restApi.d.ts +3 -0
  19. package/lib/adapters/restApi.js +225 -0
  20. package/lib/collectors/autoCollector.d.ts +75 -0
  21. package/lib/collectors/autoCollector.js +728 -0
  22. package/lib/collectors/index.d.ts +1 -0
  23. package/lib/collectors/index.js +17 -0
  24. package/lib/config.d.ts +194 -0
  25. package/lib/config.js +369 -0
  26. package/lib/debug.d.ts +13 -0
  27. package/lib/debug.js +44 -0
  28. package/lib/image/imageProcessor.d.ts +20 -0
  29. package/lib/image/imageProcessor.js +243 -0
  30. package/lib/image/index.d.ts +1 -0
  31. package/lib/image/index.js +17 -0
  32. package/lib/index.d.ts +57 -0
  33. package/lib/index.js +40 -0
  34. package/lib/services/aiAnalyzer.d.ts +24 -0
  35. package/lib/services/aiAnalyzer.js +162 -0
  36. package/lib/services/emojiluna.d.ts +102 -0
  37. package/lib/services/emojiluna.js +910 -0
  38. package/lib/services/index.d.ts +2 -0
  39. package/lib/services/index.js +18 -0
  40. package/lib/storage/index.d.ts +1 -0
  41. package/lib/storage/index.js +17 -0
  42. package/lib/storage/uploadManager.d.ts +102 -0
  43. package/lib/storage/uploadManager.js +424 -0
  44. package/lib/types.d.ts +159 -0
  45. package/lib/types.js +69 -0
  46. package/lib/utils.d.ts +29 -0
  47. package/lib/utils.js +256 -0
  48. package/package.json +99 -0
  49. package/readme.md +174 -0
@@ -0,0 +1,910 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.EmojiLunaService = void 0;
7
+ const koishi_1 = require("koishi");
8
+ const storage_1 = require("../storage");
9
+ const aiAnalyzer_1 = require("./aiAnalyzer");
10
+ const utils_1 = require("../utils");
11
+ const image_1 = require("../image");
12
+ const debug_1 = require("../debug");
13
+ const path_1 = __importDefault(require("path"));
14
+ const promises_1 = __importDefault(require("fs/promises"));
15
+ const crypto_1 = require("crypto");
16
+ class EmojiLunaService extends koishi_1.Service {
17
+ config;
18
+ _emojiStorage = {};
19
+ _categories = {};
20
+ _isInitialized = false;
21
+ _readyPromise;
22
+ _readyResolve;
23
+ _isDisposed = false;
24
+ _uploadManager;
25
+ _aiAnalyzer;
26
+ _byName = new Map();
27
+ _byTag = new Map();
28
+ _byCategory = new Map();
29
+ _storageDir;
30
+ _uploadToken;
31
+ debug;
32
+ autoCollector = null;
33
+ constructor(ctx, config) {
34
+ super(ctx, 'emojiluna', true);
35
+ this.config = config;
36
+ defineDatabase(ctx);
37
+ this.debug = new debug_1.DebugLogger(ctx, config);
38
+ this._storageDir = path_1.default.resolve(ctx.baseDir, config.storagePath);
39
+ this._uploadToken = config.uploadToken || (0, crypto_1.randomUUID)().replace(/-/g, '');
40
+ this._uploadManager = new storage_1.UploadManager(ctx, config, this.debug);
41
+ this._aiAnalyzer = new aiAnalyzer_1.AIAnalyzer(ctx, config, this.debug);
42
+ this._uploadManager.setTaskProcessor({
43
+ analyzeEmoji: (base64) => this.analyzeEmoji(base64),
44
+ updateEmojiInfo: (id, updates) => this.updateEmojiInfo(id, updates),
45
+ getEmojiById: async (id) => {
46
+ const emoji = this._emojiStorage[id];
47
+ if (!emoji)
48
+ return null;
49
+ return {
50
+ id: emoji.id,
51
+ name: emoji.name,
52
+ category: emoji.category,
53
+ tags: emoji.tags,
54
+ path: emoji.path
55
+ };
56
+ }
57
+ });
58
+ this._readyPromise = new Promise((resolve) => {
59
+ this._readyResolve = resolve;
60
+ });
61
+ ctx.on('ready', async () => {
62
+ try {
63
+ await this.initializeStorage();
64
+ await this._aiAnalyzer.initialize();
65
+ this._isInitialized = true;
66
+ }
67
+ catch (error) {
68
+ this.ctx.logger.error(`EmojiLuna initialization failed: ${error.message}`, error);
69
+ }
70
+ finally {
71
+ this._readyResolve();
72
+ }
73
+ this._uploadManager.startAITaskProcessor();
74
+ });
75
+ }
76
+ get ready() {
77
+ return this._readyPromise;
78
+ }
79
+ get uploadManager() {
80
+ return this._uploadManager;
81
+ }
82
+ get storageDir() {
83
+ return this._storageDir;
84
+ }
85
+ get aiAnalyzer() {
86
+ return this._aiAnalyzer;
87
+ }
88
+ get uploadToken() {
89
+ return this._uploadToken;
90
+ }
91
+ async initializeStorage() {
92
+ await promises_1.default.mkdir(this._storageDir, { recursive: true });
93
+ await this.loadEmojis();
94
+ await this.loadCategories();
95
+ await this._uploadManager.loadExistingHashes();
96
+ this.debug.log('service', `初始化完成:${this.getEmojiCount()} 个表情包,${this.getCategoryCount()} 个分类,存储目录 ${this._storageDir}`);
97
+ this.debug.log('service', `HTTP 上传 token 来源:${this.config.uploadToken ? '配置项' : '随机生成(仅控制台可用)'}`);
98
+ this.ctx.on('dispose', () => {
99
+ this._isDisposed = true;
100
+ this._emojiStorage = {};
101
+ this._categories = {};
102
+ this._byName.clear();
103
+ this._byTag.clear();
104
+ this._byCategory.clear();
105
+ });
106
+ }
107
+ get isInitialized() {
108
+ return this._isInitialized;
109
+ }
110
+ async readEmojiBuffer(emoji) {
111
+ try {
112
+ return await promises_1.default.readFile(emoji.path);
113
+ }
114
+ catch (error) {
115
+ this.ctx.logger.warn(`Emoji file unreadable ${emoji.id}: ${emoji.path} (${error.message})`);
116
+ return null;
117
+ }
118
+ }
119
+ indexEmoji(emoji) {
120
+ this._byName.set(emoji.name, emoji.id);
121
+ for (const tag of emoji.tags) {
122
+ const set = this._byTag.get(tag) ?? new Set();
123
+ set.add(emoji.id);
124
+ this._byTag.set(tag, set);
125
+ }
126
+ const category = emoji.category.trim();
127
+ const categorySet = this._byCategory.get(category) ?? new Set();
128
+ categorySet.add(emoji.id);
129
+ this._byCategory.set(category, categorySet);
130
+ }
131
+ unindexEmoji(emoji) {
132
+ if (this._byName.get(emoji.name) === emoji.id)
133
+ this._byName.delete(emoji.name);
134
+ for (const tag of emoji.tags)
135
+ this._byTag.get(tag)?.delete(emoji.id);
136
+ this._byCategory.get(emoji.category.trim())?.delete(emoji.id);
137
+ }
138
+ reindexEmoji(before, after) {
139
+ this.unindexEmoji(before);
140
+ this.indexEmoji(after);
141
+ }
142
+ async categorizeEmoji(imageBase64) {
143
+ const result = await this._aiAnalyzer.categorize(imageBase64);
144
+ if (result?.newCategory) {
145
+ const exists = await this.getCategoryByName(result.newCategory);
146
+ if (!exists) {
147
+ await this.addCategory(result.newCategory, 'AI建议的新分类');
148
+ }
149
+ result.category = result.newCategory;
150
+ }
151
+ return result;
152
+ }
153
+ async analyzeEmoji(imageBase64) {
154
+ const result = await this._aiAnalyzer.analyze(imageBase64);
155
+ if (result?.newCategory) {
156
+ const exists = await this.getCategoryByName(result.newCategory);
157
+ if (!exists) {
158
+ await this.addCategory(result.newCategory, 'AI建议的新分类');
159
+ }
160
+ result.category = result.newCategory;
161
+ }
162
+ return result;
163
+ }
164
+ async filterImageByType(imageBase64) {
165
+ return this._aiAnalyzer.filterImageType(imageBase64);
166
+ }
167
+ assertActive() {
168
+ if (this._isDisposed)
169
+ throw new Error('EmojiLuna 服务已卸载,请重新启用插件后再试');
170
+ }
171
+ async addEmoji(options, source, aiAnalysis = this.config.autoAnalyze) {
172
+ this.assertActive();
173
+ const sourcePath = 'path' in source ? source.path : null;
174
+ const imageBuffer = sourcePath
175
+ ? await promises_1.default.readFile(sourcePath)
176
+ : source;
177
+ const format = (0, image_1.detectImageFormat)(imageBuffer);
178
+ const mimeType = `image/${format === 'unknown' ? 'jpeg' : format}`;
179
+ const imageHash = (0, utils_1.hashBuffer)(imageBuffer);
180
+ const validationError = await this._uploadManager.validateNewEmoji(imageBuffer, imageHash);
181
+ if (validationError) {
182
+ await this._uploadManager.cleanupTempFile(sourcePath);
183
+ throw new Error(validationError);
184
+ }
185
+ const limit = this.config.maxEmojiCount;
186
+ if (limit > 0 && this.getEmojiCount() >= limit) {
187
+ await this._uploadManager.cleanupTempFile(sourcePath);
188
+ throw new Error(`表情包数量已达上限 ${limit},请先清理或提高 maxEmojiCount`);
189
+ }
190
+ const id = (0, crypto_1.randomUUID)();
191
+ const destPath = path_1.default.join(this._storageDir, `${id}.${format === 'unknown' ? 'jpg' : format}`);
192
+ await promises_1.default.mkdir(this._storageDir, { recursive: true });
193
+ if (sourcePath) {
194
+ await this._uploadManager.finalizeFile(sourcePath, destPath);
195
+ }
196
+ else {
197
+ await promises_1.default.writeFile(destPath, imageBuffer);
198
+ }
199
+ try {
200
+ const emoji = await this.buildEmojiRecord(id, destPath, imageBuffer, mimeType, imageHash, options, aiAnalysis);
201
+ await this.persistEmoji(emoji, imageHash);
202
+ this._uploadManager.registerEmoji(id, imageHash);
203
+ return emoji;
204
+ }
205
+ catch (error) {
206
+ await promises_1.default.rm(destPath, { force: true }).catch(() => undefined);
207
+ this._uploadManager.forgetEmoji(id, imageHash);
208
+ throw error;
209
+ }
210
+ }
211
+ async buildEmojiRecord(id, destPath, imageBuffer, mimeType, imageHash, options, aiAnalysis) {
212
+ let finalOptions = { ...options };
213
+ if (aiAnalysis) {
214
+ const cachedResult = this._uploadManager.getCachedAIResult(imageHash);
215
+ if (cachedResult) {
216
+ finalOptions = {
217
+ name: cachedResult.name || options.name,
218
+ category: cachedResult.category || options.category,
219
+ tags: [
220
+ ...new Set([
221
+ ...(options.tags || []),
222
+ ...(cachedResult.tags || []),
223
+ ]),
224
+ ],
225
+ description: cachedResult.description,
226
+ };
227
+ this.debug.log('ai', `复用缓存分析结果 ${imageHash.slice(0, 8)}: ${cachedResult.name}`, { description: cachedResult.description });
228
+ }
229
+ else {
230
+ this._uploadManager.queueAIAnalysis(id, destPath, imageHash);
231
+ }
232
+ }
233
+ else if (this.config.autoCategorize && !options.category) {
234
+ const categorizeResult = await this.categorizeEmoji(imageBuffer.toString('base64'));
235
+ if (!categorizeResult) {
236
+ throw new Error('AI 分类失败,未能确定分类');
237
+ }
238
+ finalOptions.category = categorizeResult.category;
239
+ }
240
+ return {
241
+ id,
242
+ name: finalOptions.name,
243
+ category: (finalOptions.category || this.config.autoCollectCategory).trim(),
244
+ path: destPath,
245
+ size: imageBuffer.length,
246
+ mimeType,
247
+ createdAt: new Date(),
248
+ tags: finalOptions.tags || [],
249
+ description: finalOptions.description,
250
+ };
251
+ }
252
+ async persistEmoji(emoji, imageHash) {
253
+ await this.ctx.database.upsert('emojiluna_emojis', [
254
+ {
255
+ id: emoji.id,
256
+ name: emoji.name,
257
+ category: emoji.category,
258
+ path: emoji.path,
259
+ size: emoji.size,
260
+ mime_type: emoji.mimeType,
261
+ created_at: emoji.createdAt,
262
+ tags: JSON.stringify(emoji.tags),
263
+ image_hash: imageHash,
264
+ description: emoji.description ?? '',
265
+ },
266
+ ]);
267
+ this._emojiStorage[emoji.id] = emoji;
268
+ this.indexEmoji(emoji);
269
+ await this.updateCategoryEmojiCount(emoji.category);
270
+ this.ctx.logger.success(`Emoji added: ${emoji.name} (${emoji.id})`);
271
+ this.ctx.emit('emojiluna/emoji-added', emoji);
272
+ }
273
+ async addEmojis(emojis, aiAnalysis) {
274
+ const createdEmojis = [];
275
+ const batchSize = Math.max(1, this.config.batchSize);
276
+ for (let i = 0; i < emojis.length; i += batchSize) {
277
+ const batch = emojis.slice(i, i + batchSize);
278
+ const results = await Promise.all(batch.map(async ({ options, buffer }) => {
279
+ try {
280
+ return await this.addEmoji(options, buffer, aiAnalysis);
281
+ }
282
+ catch (error) {
283
+ this.ctx.logger.error(`Failed to add emoji ${options.name}:`, error);
284
+ return null;
285
+ }
286
+ }));
287
+ for (const emoji of results) {
288
+ if (emoji)
289
+ createdEmojis.push(emoji);
290
+ }
291
+ }
292
+ return createdEmojis;
293
+ }
294
+ async getEmojiByName(name) {
295
+ const byId = this._emojiStorage[name];
296
+ if (byId)
297
+ return byId;
298
+ const byName = this._byName.get(name);
299
+ if (byName)
300
+ return this._emojiStorage[byName] ?? null;
301
+ const byTag = this._byTag.get(name)?.values().next().value;
302
+ if (byTag)
303
+ return this._emojiStorage[byTag] ?? null;
304
+ const byCategory = this._byCategory.get(name)?.values().next().value;
305
+ return byCategory ? this._emojiStorage[byCategory] ?? null : null;
306
+ }
307
+ async getEmojisByName(name) {
308
+ const ids = new Set([
309
+ ...(this._byName.get(name) ? [this._byName.get(name)] : []),
310
+ ...(this._byTag.get(name) ?? []),
311
+ ...(this._byCategory.get(name) ?? []),
312
+ ]);
313
+ return [...ids]
314
+ .map((id) => this._emojiStorage[id])
315
+ .filter((emoji) => Boolean(emoji));
316
+ }
317
+ async getEmojiById(id) {
318
+ return this._emojiStorage[id] || null;
319
+ }
320
+ filterEmojis(options = {}) {
321
+ const { keyword, tags } = options;
322
+ const category = options.category?.trim();
323
+ let emojis = category && !keyword?.trim() && !tags?.length
324
+ ? [...(this._byCategory.get(category) ?? [])]
325
+ .map((id) => this._emojiStorage[id])
326
+ .filter((emoji) => Boolean(emoji))
327
+ : Object.values(this._emojiStorage);
328
+ if (keyword?.trim()) {
329
+ const normalizedKeyword = keyword.trim();
330
+ emojis = emojis.filter((emoji) => emoji.name.includes(normalizedKeyword) ||
331
+ emoji.tags.some((tag) => tag.includes(normalizedKeyword)));
332
+ }
333
+ if (category) {
334
+ emojis = emojis.filter((emoji) => emoji.category.trim() === category);
335
+ }
336
+ if (tags?.length) {
337
+ emojis = emojis.filter((emoji) => tags.some((tag) => emoji.tags.includes(tag)));
338
+ }
339
+ return emojis;
340
+ }
341
+ async getEmojiList(options = {}) {
342
+ const { limit = undefined, offset = 0 } = options;
343
+ const emojis = this.filterEmojis(options);
344
+ if (!limit) {
345
+ return emojis;
346
+ }
347
+ return emojis.slice(offset, offset + limit);
348
+ }
349
+ async getEmojiPage(options = {}) {
350
+ const limit = Math.max(1, options.limit ?? 50);
351
+ const offset = Math.max(0, options.offset ?? 0);
352
+ const filtered = this.filterEmojis(options);
353
+ return {
354
+ items: filtered.slice(offset, offset + limit),
355
+ total: filtered.length,
356
+ limit,
357
+ offset
358
+ };
359
+ }
360
+ async searchEmoji(keyword) {
361
+ return this.filterEmojis({ keyword });
362
+ }
363
+ async deleteEmoji(id) {
364
+ this.assertActive();
365
+ const emoji = this._emojiStorage[id];
366
+ if (!emoji)
367
+ return false;
368
+ try {
369
+ await promises_1.default.unlink(emoji.path);
370
+ delete this._emojiStorage[id];
371
+ this.unindexEmoji(emoji);
372
+ this._uploadManager.forgetEmoji(id);
373
+ await this.ctx.database.remove('emojiluna_emojis', { id });
374
+ await this.updateCategoryEmojiCount(emoji.category);
375
+ this.ctx.emit('emojiluna/emoji-deleted', id);
376
+ return true;
377
+ }
378
+ catch (error) {
379
+ this.ctx.logger.error(`Failed to delete emoji ${id}:`, error);
380
+ return false;
381
+ }
382
+ }
383
+ async deleteAllEmojis() {
384
+ try {
385
+ const emojis = Object.values(this._emojiStorage);
386
+ const concurrency = 4;
387
+ for (let i = 0; i < emojis.length; i += concurrency) {
388
+ const batch = emojis.slice(i, i + concurrency);
389
+ await Promise.all(batch.map((emoji) => this.deleteEmoji(emoji.id)));
390
+ }
391
+ }
392
+ catch (error) {
393
+ this.ctx.logger.error('Failed to delete all emojis:', error);
394
+ return false;
395
+ }
396
+ return true;
397
+ }
398
+ async updateEmojiInfo(id, updates) {
399
+ this.assertActive();
400
+ const emoji = this._emojiStorage[id];
401
+ if (!emoji)
402
+ return false;
403
+ const oldCategory = emoji.category;
404
+ const next = {
405
+ ...emoji,
406
+ name: updates.name ?? emoji.name,
407
+ category: updates.category ?? emoji.category,
408
+ tags: updates.tags ? [...new Set(updates.tags.map((t) => t.trim()).filter(Boolean))] : emoji.tags,
409
+ description: updates.description ?? emoji.description,
410
+ };
411
+ const changed = next.name !== emoji.name ||
412
+ next.category !== emoji.category ||
413
+ next.description !== emoji.description ||
414
+ JSON.stringify(next.tags) !== JSON.stringify(emoji.tags);
415
+ if (!changed)
416
+ return true;
417
+ this._emojiStorage[id] = next;
418
+ this.reindexEmoji(emoji, next);
419
+ await this.ctx.database.upsert('emojiluna_emojis', [
420
+ {
421
+ id: next.id,
422
+ name: next.name,
423
+ category: next.category,
424
+ tags: JSON.stringify(next.tags),
425
+ description: next.description ?? '',
426
+ }
427
+ ]);
428
+ if (updates.category !== undefined &&
429
+ updates.category !== oldCategory) {
430
+ await this.updateCategoryEmojiCount(oldCategory);
431
+ await this.updateCategoryEmojiCount(next.category);
432
+ }
433
+ this.debug.log('service', `更新表情包 ${id}`, {
434
+ name: next.name,
435
+ category: next.category,
436
+ tags: next.tags,
437
+ });
438
+ this.ctx.emit('emojiluna/emoji-updated', next);
439
+ return true;
440
+ }
441
+ async updateEmojiName(id, name) {
442
+ const nextName = name.trim();
443
+ if (!nextName)
444
+ return false;
445
+ const emoji = this._emojiStorage[id];
446
+ if (!emoji)
447
+ return false;
448
+ if (emoji.name === nextName)
449
+ return true;
450
+ const duplicated = this._byName.has(nextName);
451
+ if (duplicated)
452
+ return false;
453
+ return this.updateEmojiInfo(id, { name: nextName });
454
+ }
455
+ async updateEmojiTags(id, tags) {
456
+ return this.updateEmojiInfo(id, { tags });
457
+ }
458
+ async updateEmojiCategory(id, category) {
459
+ const nextCategory = category.trim();
460
+ if (!nextCategory)
461
+ return false;
462
+ const emoji = this._emojiStorage[id];
463
+ if (!emoji)
464
+ return false;
465
+ if (emoji.category === nextCategory)
466
+ return true;
467
+ return this.updateEmojiInfo(id, { category: nextCategory });
468
+ }
469
+ async addCategory(name, description) {
470
+ const categoryName = name.trim();
471
+ if (!categoryName) {
472
+ throw new Error('分类名称不能为空');
473
+ }
474
+ const existingCategory = await this.getCategoryByName(categoryName);
475
+ if (existingCategory) {
476
+ if (description !== undefined &&
477
+ description !== existingCategory.description) {
478
+ existingCategory.description = description;
479
+ await this.ctx.database.upsert('emojiluna_categories', [
480
+ {
481
+ id: existingCategory.id,
482
+ name: existingCategory.name,
483
+ description: existingCategory.description
484
+ }
485
+ ]);
486
+ }
487
+ return existingCategory;
488
+ }
489
+ const id = (0, crypto_1.randomUUID)();
490
+ const category = {
491
+ id,
492
+ name: categoryName,
493
+ description,
494
+ emojiCount: 0,
495
+ createdAt: new Date()
496
+ };
497
+ this._categories[id] = category;
498
+ await this.ctx.database.upsert('emojiluna_categories', [
499
+ {
500
+ id: category.id,
501
+ name: category.name,
502
+ description: category.description,
503
+ emoji_count: category.emojiCount,
504
+ created_at: category.createdAt
505
+ }
506
+ ]);
507
+ this.ctx.emit('emojiluna/category-added', category);
508
+ return category;
509
+ }
510
+ async getCategories() {
511
+ return Object.values(this._categories);
512
+ }
513
+ async getCategoriesPage(options = {}) {
514
+ const limit = Math.max(1, options.limit ?? 24);
515
+ const offset = Math.max(0, options.offset ?? 0);
516
+ const keyword = options.keyword?.trim().toLowerCase();
517
+ const categories = Object.values(this._categories).filter((cat) => {
518
+ if (!keyword)
519
+ return true;
520
+ return (cat.name.toLowerCase().includes(keyword) ||
521
+ (cat.description || '').toLowerCase().includes(keyword));
522
+ });
523
+ return {
524
+ items: categories.slice(offset, offset + limit),
525
+ total: categories.length,
526
+ limit,
527
+ offset
528
+ };
529
+ }
530
+ async getCategoryByName(name) {
531
+ return (Object.values(this._categories).find((cat) => cat.name === name) ||
532
+ null);
533
+ }
534
+ async deleteCategory(id, deleteEmojis = false) {
535
+ const category = this._categories[id];
536
+ if (!category)
537
+ return false;
538
+ const emojisInCategory = Object.values(this._emojiStorage).filter((emoji) => emoji.category === category.name);
539
+ if (emojisInCategory.length > 0) {
540
+ if (!deleteEmojis) {
541
+ throw new Error(`分类 ${category.name} 中还有表情包,无法删除`);
542
+ }
543
+ const results = await Promise.all(emojisInCategory.map((emoji) => this.deleteEmoji(emoji.id)));
544
+ if (results.some((success) => !success)) {
545
+ throw new Error(`分类 ${category.name} 下部分表情包删除失败`);
546
+ }
547
+ }
548
+ delete this._categories[id];
549
+ await this.ctx.database.remove('emojiluna_categories', { id });
550
+ this.ctx.emit('emojiluna/category-deleted', id);
551
+ return true;
552
+ }
553
+ async cleanupEmptyCategories() {
554
+ const emptyCategories = Object.values(this._categories).filter((category) => category.emojiCount <= 0);
555
+ for (const category of emptyCategories) {
556
+ delete this._categories[category.id];
557
+ await this.ctx.database.remove('emojiluna_categories', {
558
+ id: category.id
559
+ });
560
+ this.ctx.emit('emojiluna/category-deleted', category.id);
561
+ }
562
+ return emptyCategories.length;
563
+ }
564
+ buildTagUsageMap() {
565
+ const tagUsageMap = new Map();
566
+ Object.values(this._emojiStorage).forEach((emoji) => {
567
+ emoji.tags.forEach((tag) => {
568
+ const normalizedTag = tag.trim();
569
+ if (!normalizedTag)
570
+ return;
571
+ tagUsageMap.set(normalizedTag, (tagUsageMap.get(normalizedTag) || 0) + 1);
572
+ });
573
+ });
574
+ return tagUsageMap;
575
+ }
576
+ async getAllTags() {
577
+ const tags = new Set();
578
+ Object.values(this._emojiStorage).forEach((emoji) => {
579
+ emoji.tags.forEach((tag) => tags.add(tag));
580
+ });
581
+ return Array.from(tags);
582
+ }
583
+ async getTagsPage(options = {}) {
584
+ const limit = Math.max(1, options.limit ?? 24);
585
+ const offset = Math.max(0, options.offset ?? 0);
586
+ const keyword = options.keyword?.trim().toLowerCase();
587
+ const tagUsageMap = this.buildTagUsageMap();
588
+ const tags = Array.from(tagUsageMap.entries())
589
+ .map(([name, usage]) => ({ name, usage }))
590
+ .filter((tag) => {
591
+ if (!keyword)
592
+ return true;
593
+ return tag.name.toLowerCase().includes(keyword);
594
+ })
595
+ .sort((a, b) => b.usage - a.usage);
596
+ return {
597
+ items: tags.slice(offset, offset + limit),
598
+ total: tags.length,
599
+ limit,
600
+ offset
601
+ };
602
+ }
603
+ async cleanupEmptyTags() {
604
+ let removedCount = 0;
605
+ for (const emoji of Object.values(this._emojiStorage)) {
606
+ const rawTags = emoji.tags ?? [];
607
+ const cleanedTags = [
608
+ ...new Set(rawTags.map((tag) => tag.trim()).filter(Boolean)),
609
+ ];
610
+ const unchanged = cleanedTags.length === rawTags.length &&
611
+ cleanedTags.every((tag, index) => tag === rawTags[index]);
612
+ if (unchanged)
613
+ continue;
614
+ removedCount += Math.max(0, rawTags.length - cleanedTags.length);
615
+ await this.updateEmojiTags(emoji.id, cleanedTags);
616
+ }
617
+ return removedCount;
618
+ }
619
+ async categorizeExistingEmojis() {
620
+ if (!this._aiAnalyzer.model || !this.config.autoCategorize) {
621
+ return { success: 0, failed: 0 };
622
+ }
623
+ let success = 0, failed = 0;
624
+ for (const emoji of Object.values(this._emojiStorage)) {
625
+ try {
626
+ const imageBuffer = await promises_1.default.readFile(emoji.path);
627
+ const imageBase64 = imageBuffer.toString('base64');
628
+ const result = await this.categorizeEmoji(imageBase64);
629
+ if (result && result.category !== emoji.category) {
630
+ await this.updateEmojiCategory(emoji.id, result.category);
631
+ success++;
632
+ }
633
+ }
634
+ catch (error) {
635
+ this.ctx.logger.error(`分类表情包 ${emoji.id} 失败:`, error);
636
+ failed++;
637
+ }
638
+ }
639
+ return { success, failed };
640
+ }
641
+ static SUPPORTED_EXTENSIONS = [
642
+ '.png',
643
+ '.jpg',
644
+ '.jpeg',
645
+ '.gif',
646
+ '.webp'
647
+ ];
648
+ async scanFolder(folderPath) {
649
+ const files = [];
650
+ const subfolders = [];
651
+ try {
652
+ await promises_1.default.access(folderPath);
653
+ }
654
+ catch {
655
+ throw new Error(`文件夹不存在或无法访问: ${folderPath}`);
656
+ }
657
+ const entries = await promises_1.default.readdir(folderPath, { withFileTypes: true });
658
+ for (const entry of entries) {
659
+ const entryPath = path_1.default.join(folderPath, entry.name);
660
+ if (entry.isDirectory()) {
661
+ subfolders.push(entry.name);
662
+ }
663
+ else if (entry.isFile()) {
664
+ const ext = path_1.default.extname(entry.name).toLowerCase();
665
+ if (EmojiLunaService.SUPPORTED_EXTENSIONS.includes(ext)) {
666
+ const stat = await promises_1.default.stat(entryPath);
667
+ files.push({
668
+ path: entryPath,
669
+ name: path_1.default.basename(entry.name, ext),
670
+ category: path_1.default.basename(folderPath),
671
+ size: stat.size
672
+ });
673
+ }
674
+ }
675
+ }
676
+ return {
677
+ folderPath,
678
+ files,
679
+ subfolders,
680
+ totalFiles: files.length
681
+ };
682
+ }
683
+ async scanFolderRecursive(folderPath, useSubfoldersAsCategories, defaultCategory, basePath) {
684
+ const files = [];
685
+ const actualBasePath = basePath || folderPath;
686
+ try {
687
+ await promises_1.default.access(folderPath);
688
+ }
689
+ catch {
690
+ this.ctx.logger.warn(`文件夹不存在或无法访问: ${folderPath}`);
691
+ return files;
692
+ }
693
+ const entries = await promises_1.default.readdir(folderPath, { withFileTypes: true });
694
+ for (const entry of entries) {
695
+ const entryPath = path_1.default.join(folderPath, entry.name);
696
+ if (entry.isDirectory()) {
697
+ const subfolderCategory = useSubfoldersAsCategories
698
+ ? entry.name
699
+ : defaultCategory;
700
+ const subFiles = await this.scanFolderRecursive(entryPath, false, subfolderCategory, actualBasePath);
701
+ files.push(...subFiles);
702
+ }
703
+ else if (entry.isFile()) {
704
+ const ext = path_1.default.extname(entry.name).toLowerCase();
705
+ if (EmojiLunaService.SUPPORTED_EXTENSIONS.includes(ext)) {
706
+ const stat = await promises_1.default.stat(entryPath);
707
+ let category = defaultCategory;
708
+ if (useSubfoldersAsCategories &&
709
+ folderPath !== actualBasePath) {
710
+ category = path_1.default.basename(folderPath);
711
+ }
712
+ files.push({
713
+ path: entryPath,
714
+ name: path_1.default.basename(entry.name, ext),
715
+ category,
716
+ size: stat.size
717
+ });
718
+ }
719
+ }
720
+ }
721
+ return files;
722
+ }
723
+ emojiNameExists(name) {
724
+ return Object.values(this._emojiStorage).some((emoji) => emoji.name === name);
725
+ }
726
+ async importFromFolder(options) {
727
+ const { folderPath, useSubfoldersAsCategories, defaultCategory = 'Other', recursive, aiAnalysis, skipExisting } = options;
728
+ const result = {
729
+ success: true,
730
+ imported: 0,
731
+ skipped: 0,
732
+ failed: 0,
733
+ errors: [],
734
+ importedEmojis: []
735
+ };
736
+ try {
737
+ let files;
738
+ if (recursive) {
739
+ files = await this.scanFolderRecursive(folderPath, useSubfoldersAsCategories, defaultCategory);
740
+ }
741
+ else {
742
+ const scanResult = await this.scanFolder(folderPath);
743
+ files = scanResult.files.map((f) => ({
744
+ ...f,
745
+ category: useSubfoldersAsCategories
746
+ ? f.category
747
+ : defaultCategory
748
+ }));
749
+ }
750
+ if (files.length === 0) {
751
+ result.errors.push('未找到支持的图片文件');
752
+ return result;
753
+ }
754
+ this.ctx.logger.info(`开始导入 ${files.length} 个表情包从 ${folderPath}`);
755
+ const categoryNames = [...new Set(files.map((f) => f.category))];
756
+ for (const categoryName of categoryNames) {
757
+ const exists = await this.getCategoryByName(categoryName);
758
+ if (!exists) {
759
+ await this.addCategory(categoryName, `从文件夹导入: ${folderPath}`);
760
+ }
761
+ }
762
+ for (const file of files) {
763
+ try {
764
+ if (skipExisting && this.emojiNameExists(file.name)) {
765
+ result.skipped++;
766
+ continue;
767
+ }
768
+ const imageBuffer = await promises_1.default.readFile(file.path);
769
+ const emoji = await this.addEmoji({
770
+ name: file.name,
771
+ category: file.category,
772
+ tags: []
773
+ }, imageBuffer, aiAnalysis);
774
+ result.imported++;
775
+ result.importedEmojis.push(emoji);
776
+ }
777
+ catch (error) {
778
+ result.failed++;
779
+ result.errors.push(`导入 ${file.name} 失败: ${error.message}`);
780
+ this.ctx.logger.error(`导入失败 ${file.path}:`, error);
781
+ }
782
+ }
783
+ this.ctx.logger.success(`文件夹导入完成: 成功 ${result.imported}, 跳过 ${result.skipped}, 失败 ${result.failed}`);
784
+ }
785
+ catch (error) {
786
+ result.success = false;
787
+ result.errors.push(`导入失败: ${error.message}`);
788
+ this.ctx.logger.error('文件夹导入失败:', error);
789
+ }
790
+ return result;
791
+ }
792
+ async updateCategoryEmojiCount(categoryName) {
793
+ const normalizedCategoryName = categoryName.trim();
794
+ if (!normalizedCategoryName)
795
+ return;
796
+ const count = this._byCategory.get(normalizedCategoryName)?.size ?? 0;
797
+ let category = Object.values(this._categories).find((cat) => cat.name.trim() === normalizedCategoryName);
798
+ if (!category && count > 0) {
799
+ category = await this.addCategory(normalizedCategoryName);
800
+ }
801
+ if (!category || category.emojiCount === count)
802
+ return;
803
+ category.emojiCount = count;
804
+ await this.ctx.database.upsert('emojiluna_categories', [
805
+ {
806
+ id: category.id,
807
+ emoji_count: count,
808
+ },
809
+ ]);
810
+ }
811
+ async loadEmojis() {
812
+ const emojis = await this.ctx.database
813
+ .select('emojiluna_emojis')
814
+ .execute();
815
+ for (const emojiData of emojis) {
816
+ const emoji = {
817
+ id: emojiData.id,
818
+ name: emojiData.name,
819
+ category: (emojiData.category ?? '').trim(),
820
+ path: emojiData.path,
821
+ size: emojiData.size,
822
+ mimeType: emojiData.mime_type || 'image/png',
823
+ createdAt: new Date(emojiData.created_at),
824
+ tags: (0, utils_1.parseTags)(emojiData.tags),
825
+ description: emojiData.description || undefined,
826
+ };
827
+ this._emojiStorage[emoji.id] = emoji;
828
+ this.indexEmoji(emoji);
829
+ }
830
+ }
831
+ async loadCategories() {
832
+ const categories = await this.ctx.database
833
+ .select('emojiluna_categories')
834
+ .execute();
835
+ for (const categoryData of categories) {
836
+ this._categories[categoryData.id] = {
837
+ id: categoryData.id,
838
+ name: categoryData.name,
839
+ description: categoryData.description,
840
+ emojiCount: categoryData.emoji_count,
841
+ createdAt: new Date(categoryData.created_at)
842
+ };
843
+ }
844
+ for (const category of this.config.categories) {
845
+ const exists = Object.values(this._categories).find((cat) => cat.name === category);
846
+ if (!exists) {
847
+ await this.addCategory(category);
848
+ }
849
+ }
850
+ }
851
+ getEmojiCount() {
852
+ return Object.keys(this._emojiStorage).length;
853
+ }
854
+ getCategoryCount() {
855
+ return Object.keys(this._categories).length;
856
+ }
857
+ async getAiTaskStats() {
858
+ return this._uploadManager.getAITaskStats();
859
+ }
860
+ async getFailedAiEmojiIds() {
861
+ return this._uploadManager.getFailedAIEmojiIds();
862
+ }
863
+ setAiPaused(paused) {
864
+ this._uploadManager.setAIPaused(paused);
865
+ }
866
+ async retryFailedTasks() {
867
+ return this._uploadManager.retryFailedTasks();
868
+ }
869
+ async reanalyzeBatch(ids) {
870
+ return this._uploadManager.reanalyzeBatch(ids);
871
+ }
872
+ async getAiTasksAll() {
873
+ return this._uploadManager.getAiTasksAll();
874
+ }
875
+ async deleteAiTask(emojiId) {
876
+ this._uploadManager.deleteAiTask(emojiId);
877
+ }
878
+ async retryAiTask(emojiId) {
879
+ await this._uploadManager.retryAiTask(emojiId);
880
+ }
881
+ static inject = ['database', 'chatluna'];
882
+ }
883
+ exports.EmojiLunaService = EmojiLunaService;
884
+ function defineDatabase(ctx) {
885
+ ctx.database.extend('emojiluna_emojis', {
886
+ id: { type: 'string', length: 36 },
887
+ name: { type: 'string', length: 255 },
888
+ category: { type: 'string', length: 255 },
889
+ path: { type: 'string', length: 1024 },
890
+ size: { type: 'unsigned' },
891
+ mime_type: { type: 'string', length: 50 },
892
+ created_at: { type: 'timestamp' },
893
+ tags: { type: 'string' },
894
+ image_hash: { type: 'string', length: 64 },
895
+ description: { type: 'string', length: 1024, initial: '' },
896
+ }, {
897
+ autoInc: false,
898
+ primary: 'id'
899
+ });
900
+ ctx.database.extend('emojiluna_categories', {
901
+ id: { type: 'string', length: 36 },
902
+ name: { type: 'string', length: 255 },
903
+ description: { type: 'string', length: 500 },
904
+ emoji_count: { type: 'unsigned' },
905
+ created_at: { type: 'timestamp' }
906
+ }, {
907
+ autoInc: false,
908
+ primary: 'id'
909
+ });
910
+ }