koishi-plugin-emojiluna-plus 0.0.3 → 0.0.4

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.
@@ -4,6 +4,8 @@ exports.registerAutoCollectCommands = registerAutoCollectCommands;
4
4
  const koishi_1 = require("koishi");
5
5
  const render_1 = require("./render");
6
6
  const types_1 = require("../types");
7
+ const DEFAULT_HOUR_LIMIT = types_1.DEFAULT_COLLECT_LIMITS.hour;
8
+ const DEFAULT_DAY_LIMIT = types_1.DEFAULT_COLLECT_LIMITS.day;
7
9
  const RECENT_RECORDS = 6;
8
10
  const DEBUG_LINES = 15;
9
11
  function registerAutoCollectCommands(ctx, config, autoCollector) {
@@ -83,7 +85,7 @@ function formatStatus(config, autoCollector) {
83
85
  `条件: ${config.minEmojiSize}KB-${config.maxEmojiSize}MB${config.minEdgePixels ? ` | 短边≥${config.minEdgePixels}px` : ''} | 相似度阈值 ${config.similarityThreshold}`,
84
86
  `取源: HTTP ${config.fetchTimeout}ms 超时 | 本地路径 ${config.allowLocalPaths ? '允许' : '禁止'}${config.localPathRoots.length ? `(限定 ${config.localPathRoots.length} 个目录)` : ''}`,
85
87
  `AI: 类型过滤 ${config.enableImageTypeFilter ? `开启(超时 ${config.aiFilterTimeout}ms,兜底 ${config.aiFilterFallback})` : '关闭'} | 入库后分析 ${config.autoAnalyze ? '开启' : '关闭'}`,
86
- `限流: 默认单会话 ${defaultHourLimit(config)}/小时、${defaultDayLimit(config)}/日 | 全局 ${config.globalDayLimit || '不限'}/日 | 库存上限 ${config.maxEmojiCount}`,
88
+ `限流: 单会话 ${quotaSummary(config)} | 全局 ${config.globalDayLimit || '不限'}/日 | 库存上限 ${config.maxEmojiCount}`,
87
89
  `统计: 收到 ${stats.seen} 张 | 成功 ${stats.collected} 张 | 队列 ${stats.queue}/${config.autoCollectQueueSize} | 处理中 ${stats.active} | 特征 ${stats.signatures}${stats.preloaded ? '' : '(预热中)'}`,
88
90
  ];
89
91
  const rejected = Object.entries(stats.rejected).sort((a, b) => b[1] - a[1]);
@@ -101,12 +103,8 @@ function formatStatus(config, autoCollector) {
101
103
  }
102
104
  return lines.join('\n');
103
105
  }
104
- function defaultHourLimit(config) {
105
- return firstQuota(config)?.hourLimit ?? 20;
106
- }
107
- function defaultDayLimit(config) {
108
- return firstQuota(config)?.dayLimit ?? 100;
109
- }
110
- function firstQuota(config) {
111
- return Object.values(config.groupAutoCollectLimit ?? {})[0];
106
+ function quotaSummary(config) {
107
+ const overrides = Object.keys(config.groupAutoCollectLimit ?? {}).length;
108
+ const scope = overrides ? `,按群覆盖 ${overrides} 个` : '';
109
+ return `默认 ${DEFAULT_HOUR_LIMIT}/小时、${DEFAULT_DAY_LIMIT}/日${scope}`;
112
110
  }
@@ -25,7 +25,7 @@ function registerTriggerMiddleware(ctx, config) {
25
25
  const content = session.content.trim();
26
26
  if (content.length < TRIGGER_MATCH_MIN_LENGTH)
27
27
  return next();
28
- const emoji = await ctx.emojiluna.getEmojiByName(content);
28
+ const emoji = await ctx.emojiluna.getTriggerEmoji(content);
29
29
  if (!emoji)
30
30
  return next();
31
31
  return next(async () => (0, render_1.emojiMessage)(ctx, config, emoji));
@@ -157,7 +157,7 @@ function registerLibraryCommands(ctx, config, emojiluna) {
157
157
  `自动分析: ${flag(config.autoAnalyze)}`,
158
158
  `图片类型过滤: ${flag(config.enableImageTypeFilter)}`,
159
159
  `使用模型: ${config.model || '未配置'}`,
160
- `AI分析统计: ${JSON.stringify(emojiluna.getAiTaskStats())}`,
160
+ `AI分析统计: ${JSON.stringify(await emojiluna.getAiTaskStats())}`,
161
161
  '',
162
162
  '自动获取:',
163
163
  `状态: ${flag(config.autoCollect)}`,
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.applyConsoleListeners = applyConsoleListeners;
7
+ const types_1 = require("../types");
7
8
  const path_1 = __importDefault(require("path"));
8
9
  const fs_1 = __importDefault(require("fs"));
9
10
  const render_1 = require("./render");
@@ -32,7 +33,7 @@ const DIRECT_EVENTS = {
32
33
  'emojiluna/deleteAiTask': 'deleteAiTask',
33
34
  'emojiluna/retryAiTask': 'retryAiTask',
34
35
  };
35
- const MAX_REQUESTED_EMOJI_NAME = 64;
36
+ const MAX_REQUESTED_EMOJI_NAME = types_1.MAX_EMOJI_NAME_LENGTH;
36
37
  function resolveClientRoot(fromDir) {
37
38
  let current = fromDir;
38
39
  for (let depth = 0; depth < 5; depth++) {
@@ -45,7 +46,7 @@ function resolveClientRoot(fromDir) {
45
46
  break;
46
47
  current = parent;
47
48
  }
48
- return path_1.default.resolve(fromDir, '..', '..', '..');
49
+ return path_1.default.resolve(fromDir, '..', '..');
49
50
  }
50
51
  function applyConsoleListeners(ctx, config) {
51
52
  ctx.inject(['console', 'emojiluna'], (ctx) => {
@@ -62,7 +63,15 @@ async function registerListeners(ctx, config) {
62
63
  prod: path_1.default.join(clientRoot, 'dist'),
63
64
  });
64
65
  for (const [event, method] of Object.entries(DIRECT_EVENTS)) {
65
- ctx.console.addListener(event, invoke[method]);
66
+ const handler = invoke[method];
67
+ if (typeof handler !== 'function') {
68
+ emojiluna.debug.log('service', `控制台事件目标方法缺失,已跳过注册: ${event} -> ${method}`);
69
+ continue;
70
+ }
71
+ ctx.console.addListener(event,
72
+ // Koishi invokes listeners with its own `this`, so the service
73
+ // receiver has to be bound explicitly before handing the method over.
74
+ handler.bind(emojiluna));
66
75
  }
67
76
  ctx.console.addListener('emojiluna/getBaseUrl', async () => {
68
77
  const selfUrl = (0, render_1.resolveSelfUrl)(ctx, config);
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.applyRestApi = applyRestApi;
7
+ const types_1 = require("../types");
7
8
  const promises_1 = __importDefault(require("fs/promises"));
8
9
  const path_1 = __importDefault(require("path"));
9
10
  const utils_1 = require("../utils");
@@ -176,10 +177,11 @@ function applyRestApi(ctx, config) {
176
177
  if (!file?.filepath) {
177
178
  return json(koa, { success: false, message: 'No file uploaded' }, 400);
178
179
  }
180
+ const requestedName = firstField(fields, 'name') ||
181
+ file.originalFilename?.replace(/\.[^/.]+$/, '') ||
182
+ 'uploaded';
179
183
  const emoji = await ctx.emojiluna.addEmoji({
180
- name: firstField(fields, 'name') ||
181
- file.originalFilename?.replace(/\.[^/.]+$/, '') ||
182
- 'uploaded',
184
+ name: requestedName.slice(0, types_1.MAX_EMOJI_NAME_LENGTH),
183
185
  category: firstField(fields, 'category'),
184
186
  tags: parseTagsField(firstField(fields, 'tags')),
185
187
  }, { path: file.filepath }, firstField(fields, 'aiAnalysis') === 'true');
@@ -33,6 +33,7 @@ export declare class AutoCollector {
33
33
  private rejectedHashes;
34
34
  private aiVerdicts;
35
35
  private queue;
36
+ private commitTail;
36
37
  private active;
37
38
  private nameSeq;
38
39
  private manualEnabled;
@@ -52,12 +53,15 @@ export declare class AutoCollector {
52
53
  dispose(): void;
53
54
  private pump;
54
55
  private processJob;
56
+ private commit;
55
57
  private save;
56
58
  private evaluateScope;
57
59
  private evaluateSize;
58
60
  private evaluateResolution;
59
61
  private evaluateKnownHash;
62
+ private findSimilar;
60
63
  private evaluateSimilarity;
64
+ private serialize;
61
65
  private evaluateFrequency;
62
66
  private evaluateLimit;
63
67
  private evaluateLibrary;
@@ -7,6 +7,8 @@ const image_1 = require("../image");
7
7
  const utils_1 = require("../utils");
8
8
  const HOUR_MS = 3_600_000;
9
9
  const DAY_MS = 86_400_000;
10
+ const DEFAULT_HOUR_LIMIT = types_1.DEFAULT_COLLECT_LIMITS.hour;
11
+ const DEFAULT_DAY_LIMIT = types_1.DEFAULT_COLLECT_LIMITS.day;
10
12
  const PENDING_REF_TTL_MS = 60_000;
11
13
  const REJECTED_HASH_TTL_MS = 6 * HOUR_MS;
12
14
  const AI_VERDICT_TTL_MS = 24 * HOUR_MS;
@@ -39,6 +41,7 @@ class AutoCollector {
39
41
  rejectedHashes = new Map();
40
42
  aiVerdicts = new Map();
41
43
  queue = [];
44
+ commitTail = Promise.resolve();
42
45
  active = 0;
43
46
  nameSeq = 0;
44
47
  manualEnabled = null;
@@ -64,12 +67,10 @@ class AutoCollector {
64
67
  return !this.stopped && (this.manualEnabled ?? this.config.autoCollect);
65
68
  }
66
69
  start() {
67
- if (!this.config.autoCollect) {
68
- this.debug.log('collect', '自动获取未启用,跳过监听注册');
69
- return;
70
- }
71
70
  this.ctx.on('message', (session) => this.observe(session));
72
- this.ctx.logger.info('Auto collector started');
71
+ this.ctx.logger.info(this.config.autoCollect
72
+ ? 'Auto collector started'
73
+ : 'Auto collector idle: 自动获取未启用,可用 emojiluna.auto.toggle on 临时开启');
73
74
  }
74
75
  observe(session) {
75
76
  if (!this.isEnabled)
@@ -237,7 +238,7 @@ class AutoCollector {
237
238
  detail: `试运行:将保存为 "${this.buildName(session, isDirect)}",未实际写入`,
238
239
  });
239
240
  }
240
- const saved = await this.save(image.buffer, signature, hash, session, isDirect);
241
+ const saved = await this.serialize(() => this.commit(image.buffer, signature, hash, session, isDirect));
241
242
  if (!pass(saved))
242
243
  return;
243
244
  this.consumeQuota(chatKey);
@@ -252,6 +253,22 @@ class AutoCollector {
252
253
  this.pendingRefs.delete(ref);
253
254
  }
254
255
  }
256
+ async commit(buffer, signature, hash, session, isDirect) {
257
+ if (this.luna.uploadManager.hasHash(hash)) {
258
+ return { gate: '去重', ok: false, detail: '入库前已出现相同内容' };
259
+ }
260
+ if (signature) {
261
+ const similar = this.findSimilar(signature);
262
+ if (similar) {
263
+ return {
264
+ gate: '相似',
265
+ ok: false,
266
+ detail: `入库前与 ${similar.hash.slice(0, 8)} 相似度 ${similar.score.toFixed(3)}`,
267
+ };
268
+ }
269
+ }
270
+ return this.save(buffer, signature, hash, session, isDirect);
271
+ }
255
272
  async save(buffer, signature, hash, session, isDirect) {
256
273
  const name = this.buildName(session, isDirect);
257
274
  const tags = this.buildTags(session, isDirect);
@@ -366,7 +383,7 @@ class AutoCollector {
366
383
  }
367
384
  return { gate: '去重', ok: true, detail: `内容哈希 ${hash.slice(0, 8)}` };
368
385
  }
369
- evaluateSimilarity(signature) {
386
+ findSimilar(signature) {
370
387
  let best = null;
371
388
  for (const [hash, cached] of this.signatures) {
372
389
  const score = similarityScore(signature, cached);
@@ -374,6 +391,10 @@ class AutoCollector {
374
391
  best = { hash, score };
375
392
  }
376
393
  }
394
+ return best;
395
+ }
396
+ evaluateSimilarity(signature) {
397
+ const best = this.findSimilar(signature);
377
398
  if (!best) {
378
399
  return {
379
400
  gate: '相似',
@@ -387,6 +408,11 @@ class AutoCollector {
387
408
  detail: `与表情 ${best.hash.slice(0, 8)} 相似度 ${best.score.toFixed(3)},阈值 ${this.config.similarityThreshold}`,
388
409
  };
389
410
  }
411
+ serialize(task) {
412
+ const run = this.commitTail.then(task, task);
413
+ this.commitTail = run.then(() => undefined, () => undefined);
414
+ return run;
415
+ }
390
416
  evaluateFrequency(hash, chatKey) {
391
417
  const windowMs = this.config.frequencyWindow * 60_000;
392
418
  const key = `${hash}|${chatKey}`;
@@ -411,8 +437,8 @@ class AutoCollector {
411
437
  evaluateLimit(chatKey) {
412
438
  const counter = this.windowCounter(chatKey);
413
439
  const quota = this.config.groupAutoCollectLimit?.[chatKey.split(':')[1]];
414
- const hourLimit = quota?.hourLimit ?? 20;
415
- const dayLimit = quota?.dayLimit ?? 100;
440
+ const hourLimit = quota?.hourLimit ?? DEFAULT_HOUR_LIMIT;
441
+ const dayLimit = quota?.dayLimit ?? DEFAULT_DAY_LIMIT;
416
442
  if (hourLimit > 0 && counter.hourCount >= hourLimit) {
417
443
  return {
418
444
  gate: '限流',
@@ -594,6 +620,13 @@ class AutoCollector {
594
620
  else
595
621
  this.frequency.delete(key);
596
622
  }
623
+ for (const [key, counter] of this.counters) {
624
+ if (!counter.hourCount &&
625
+ !counter.dayCount &&
626
+ now - counter.dayStart >= DAY_MS) {
627
+ this.counters.delete(key);
628
+ }
629
+ }
597
630
  }
598
631
  chatKey(session, isDirect) {
599
632
  return isDirect
@@ -48,6 +48,12 @@ export declare class EmojiLunaService extends Service {
48
48
  buffer: Buffer;
49
49
  }[], aiAnalysis: boolean): Promise<EmojiItem[]>;
50
50
  getEmojiByName(name: string): Promise<EmojiItem | null>;
51
+ /**
52
+ * Trigger-word replies should only fire on an exact emoji name or tag; the
53
+ * four-level lookup used by `emojiluna.get` also matches categories and ids,
54
+ * which would make ordinary messages steal a sticker.
55
+ */
56
+ getTriggerEmoji(text: string): Promise<EmojiItem | null>;
51
57
  getEmojisByName(name: string): Promise<EmojiItem[]>;
52
58
  getEmojiById(id: string): Promise<EmojiItem | null>;
53
59
  private filterEmojis;
@@ -13,6 +13,7 @@ const debug_1 = require("../debug");
13
13
  const path_1 = __importDefault(require("path"));
14
14
  const promises_1 = __importDefault(require("fs/promises"));
15
15
  const crypto_1 = require("crypto");
16
+ const MAX_IMPORT_ERRORS = 20;
16
17
  class EmojiLunaService extends koishi_1.Service {
17
18
  config;
18
19
  _emojiStorage = {};
@@ -304,6 +305,18 @@ class EmojiLunaService extends koishi_1.Service {
304
305
  const byCategory = this._byCategory.get(name)?.values().next().value;
305
306
  return byCategory ? this._emojiStorage[byCategory] ?? null : null;
306
307
  }
308
+ /**
309
+ * Trigger-word replies should only fire on an exact emoji name or tag; the
310
+ * four-level lookup used by `emojiluna.get` also matches categories and ids,
311
+ * which would make ordinary messages steal a sticker.
312
+ */
313
+ async getTriggerEmoji(text) {
314
+ const byName = this._byName.get(text);
315
+ if (byName)
316
+ return this._emojiStorage[byName] ?? null;
317
+ const byTag = this._byTag.get(text)?.values().next().value;
318
+ return byTag ? this._emojiStorage[byTag] ?? null : null;
319
+ }
307
320
  async getEmojisByName(name) {
308
321
  const ids = new Set([
309
322
  ...(this._byName.get(name) ? [this._byName.get(name)] : []),
@@ -384,9 +397,15 @@ class EmojiLunaService extends koishi_1.Service {
384
397
  try {
385
398
  const emojis = Object.values(this._emojiStorage);
386
399
  const concurrency = 4;
400
+ let failures = 0;
387
401
  for (let i = 0; i < emojis.length; i += concurrency) {
388
402
  const batch = emojis.slice(i, i + concurrency);
389
- await Promise.all(batch.map((emoji) => this.deleteEmoji(emoji.id)));
403
+ const results = await Promise.all(batch.map((emoji) => this.deleteEmoji(emoji.id)));
404
+ failures += results.filter((deleted) => !deleted).length;
405
+ }
406
+ if (failures) {
407
+ this.ctx.logger.warn(`清空表情包时有 ${failures} 个未能删除`);
408
+ return false;
390
409
  }
391
410
  }
392
411
  catch (error) {
@@ -617,7 +636,7 @@ class EmojiLunaService extends koishi_1.Service {
617
636
  return removedCount;
618
637
  }
619
638
  async categorizeExistingEmojis() {
620
- if (!this._aiAnalyzer.model || !this.config.autoCategorize) {
639
+ if (!this._aiAnalyzer.hasModel || !this.config.autoCategorize) {
621
640
  return { success: 0, failed: 0 };
622
641
  }
623
642
  let success = 0, failed = 0;
@@ -776,7 +795,9 @@ class EmojiLunaService extends koishi_1.Service {
776
795
  }
777
796
  catch (error) {
778
797
  result.failed++;
779
- result.errors.push(`导入 ${file.name} 失败: ${error.message}`);
798
+ if (result.errors.length < MAX_IMPORT_ERRORS) {
799
+ result.errors.push(`导入 ${file.name} 失败: ${error.message}`);
800
+ }
780
801
  this.ctx.logger.error(`导入失败 ${file.path}:`, error);
781
802
  }
782
803
  }
package/lib/types.d.ts CHANGED
@@ -111,6 +111,11 @@ export declare const GROUP_SCOPES: {
111
111
  readonly whitelist: "仅白名单群";
112
112
  readonly blacklist: "排除黑名单群";
113
113
  };
114
+ export declare const DEFAULT_COLLECT_LIMITS: {
115
+ readonly hour: 20;
116
+ readonly day: 100;
117
+ };
118
+ export declare const MAX_EMOJI_NAME_LENGTH = 64;
114
119
  export declare const COLLECT_MODES: {
115
120
  readonly instant: "立即收集";
116
121
  readonly frequency: "频次达标后收集";
package/lib/types.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.IMAGE_TYPE_LABELS = exports.DEBUG_SCOPES = exports.AI_FILTER_FALLBACKS = exports.COLLECT_MODES = exports.GROUP_SCOPES = exports.DEFAULT_ACCEPTED_IMAGE_TYPES = exports.IMAGE_CONTENT_TYPES = void 0;
3
+ exports.IMAGE_TYPE_LABELS = exports.DEBUG_SCOPES = exports.AI_FILTER_FALLBACKS = exports.COLLECT_MODES = exports.MAX_EMOJI_NAME_LENGTH = exports.DEFAULT_COLLECT_LIMITS = exports.GROUP_SCOPES = exports.DEFAULT_ACCEPTED_IMAGE_TYPES = exports.IMAGE_CONTENT_TYPES = void 0;
4
4
  exports.IMAGE_CONTENT_TYPES = [
5
5
  {
6
6
  type: 'emoji',
@@ -72,6 +72,11 @@ exports.GROUP_SCOPES = {
72
72
  whitelist: '仅白名单群',
73
73
  blacklist: '排除黑名单群',
74
74
  };
75
+ exports.DEFAULT_COLLECT_LIMITS = {
76
+ hour: 20,
77
+ day: 100,
78
+ };
79
+ exports.MAX_EMOJI_NAME_LENGTH = 64;
75
80
  exports.COLLECT_MODES = {
76
81
  instant: '立即收集',
77
82
  frequency: '频次达标后收集',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-emojiluna-plus",
3
3
  "description": "Koishi 智能表情包管理插件(emojiluna fork),支持群聊/私聊自动获取表情包、AI自动分类打标、WebUI管理与HTTP API",
4
- "version": "0.0.3",
4
+ "version": "0.0.4",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [