mioku-plugin-chat 2.5.2 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,24 @@
1
- import type { AIRequestLimitConfig, DynamicDelayConfig } from "../types";
1
+ import type { AIRequestLimitConfig, ChatConfig, DynamicDelayConfig } from "../types";
2
+
3
+ const DEFAULT_DYNAMIC_DELAY: DynamicDelayConfig = {
4
+ enabled: true,
5
+ interactionWindowMs: 60_000,
6
+ baseDelayMs: 30_000,
7
+ maxDelayMs: 300_000,
8
+ };
9
+
10
+ const DEFAULT_AI_REQUEST_LIMITS: AIRequestLimitConfig = {
11
+ userRpm: 3,
12
+ groupRpm: 6,
13
+ windowMs: 60_000,
14
+ };
15
+
16
+ export type ChatConfigProvider = (groupId?: number) => ChatConfig;
2
17
 
3
18
  export class RateLimiter {
4
- // 用户触发记录:userId -> timestamp[]
5
19
  private userTriggers: Map<number, number[]> = new Map();
6
- // 用户最近消息:userId -> {content, timestamp}[]
7
20
  private userMessages: Map<number, { content: string; timestamp: number }[]> =
8
21
  new Map();
9
- // 群组最后响应时间:groupId -> timestamp
10
22
  private groupLastResponse: Map<number, number> = new Map();
11
23
  private groupInteractions: Map<number, Map<number, number[]>> = new Map();
12
24
  private userAiRequests: Map<number, number[]> = new Map();
@@ -17,14 +29,13 @@ export class RateLimiter {
17
29
  private readonly dedupWindowMs: number;
18
30
  private readonly groupCooldownMs: number;
19
31
  private readonly cleanupTimer: ReturnType<typeof setInterval>;
20
- private readonly dynamicDelayConfig: DynamicDelayConfig;
21
- private readonly aiRequestLimitConfig: AIRequestLimitConfig;
22
- // 外部注入的队列长度获取函数
32
+ private getConfig: ChatConfigProvider;
23
33
  private getQueueLengthFn: ((groupId: number) => number) | null = null;
24
34
 
25
- /**
26
- * 设置队列长度获取函数
27
- */
35
+ setConfigProvider(provider: ChatConfigProvider): void {
36
+ this.getConfig = provider;
37
+ }
38
+
28
39
  setQueueLengthGetter(fn: (groupId: number) => number): void {
29
40
  this.getQueueLengthFn = fn;
30
41
  }
@@ -34,28 +45,27 @@ export class RateLimiter {
34
45
  windowMs?: number;
35
46
  dedupWindowMs?: number;
36
47
  groupCooldownMs?: number;
37
- dynamicDelay?: DynamicDelayConfig;
38
- aiRequestLimits?: AIRequestLimitConfig;
39
48
  }) {
40
49
  this.maxTriggersPerWindow = options?.maxTriggersPerWindow ?? 5;
41
50
  this.windowMs = options?.windowMs ?? 60_000;
42
51
  this.dedupWindowMs = options?.dedupWindowMs ?? 30_000;
43
52
  this.groupCooldownMs = options?.groupCooldownMs ?? 1_000;
44
- this.dynamicDelayConfig = options?.dynamicDelay ?? {
45
- enabled: true,
46
- interactionWindowMs: 60_000,
47
- baseDelayMs: 30_000,
48
- maxDelayMs: 300_000,
49
- };
50
- this.aiRequestLimitConfig = options?.aiRequestLimits ?? {
51
- userRpm: 3,
52
- groupRpm: 6,
53
- windowMs: 60_000,
54
- };
53
+ this.getConfig = () => ({
54
+ dynamicDelay: DEFAULT_DYNAMIC_DELAY,
55
+ aiRequestLimits: DEFAULT_AI_REQUEST_LIMITS,
56
+ }) as unknown as ChatConfig;
55
57
 
56
58
  this.cleanupTimer = setInterval(() => this.cleanup(), 300_000);
57
59
  }
58
60
 
61
+ private getDynamicDelay(groupId?: number): DynamicDelayConfig {
62
+ return this.getConfig(groupId)?.dynamicDelay ?? DEFAULT_DYNAMIC_DELAY;
63
+ }
64
+
65
+ private getAiRequestLimits(groupId?: number): AIRequestLimitConfig {
66
+ return this.getConfig(groupId)?.aiRequestLimits ?? DEFAULT_AI_REQUEST_LIMITS;
67
+ }
68
+
59
69
  canProcess(
60
70
  userId: number,
61
71
  groupId: number | undefined,
@@ -63,7 +73,6 @@ export class RateLimiter {
63
73
  ): boolean {
64
74
  const now = Date.now();
65
75
 
66
- // 群组冷却检查
67
76
  if (groupId) {
68
77
  const lastResponse = this.groupLastResponse.get(groupId);
69
78
  if (lastResponse && now - lastResponse < this.groupCooldownMs) {
@@ -71,14 +80,12 @@ export class RateLimiter {
71
80
  }
72
81
  }
73
82
 
74
- // 用户频率检查
75
83
  const triggers = this.userTriggers.get(userId) ?? [];
76
84
  const recentTriggers = triggers.filter((t) => now - t < this.windowMs);
77
85
  if (recentTriggers.length >= this.maxTriggersPerWindow) {
78
86
  return false;
79
87
  }
80
88
 
81
- // 重复消息检查
82
89
  const messages = this.userMessages.get(userId) ?? [];
83
90
  const recentSame = messages.find(
84
91
  (m) => m.content === content && now - m.timestamp < this.dedupWindowMs,
@@ -86,34 +93,29 @@ export class RateLimiter {
86
93
  return !recentSame;
87
94
  }
88
95
 
89
- /**
90
- * 记录已处理的消息
91
- */
92
96
  record(userId: number, groupId: number | undefined, content: string): void {
93
97
  const now = Date.now();
94
98
 
95
- // 记录触发
96
99
  const triggers = this.userTriggers.get(userId) ?? [];
97
100
  triggers.push(now);
98
101
  this.userTriggers.set(userId, triggers);
99
102
 
100
- // 记录消息(只保留最近 3 条)
101
103
  const messages = this.userMessages.get(userId) ?? [];
102
104
  messages.push({ content, timestamp: now });
103
105
  if (messages.length > 3) messages.shift();
104
106
  this.userMessages.set(userId, messages);
105
107
 
106
- // 记录群组响应
107
108
  if (groupId) {
108
109
  this.groupLastResponse.set(groupId, now);
109
110
  }
110
111
  }
111
112
 
112
113
  recordInteraction(groupId: number, userId: number): void {
113
- if (!this.dynamicDelayConfig.enabled) return;
114
+ const dynamicDelay = this.getDynamicDelay(groupId);
115
+ if (!dynamicDelay.enabled) return;
114
116
 
115
117
  const now = Date.now();
116
- const windowMs = this.dynamicDelayConfig.interactionWindowMs;
118
+ const windowMs = dynamicDelay.interactionWindowMs;
117
119
 
118
120
  let groupUsers = this.groupInteractions.get(groupId);
119
121
  if (!groupUsers) {
@@ -129,7 +131,7 @@ export class RateLimiter {
129
131
 
130
132
  canRunAIRequest(userId?: number, groupId?: number): boolean {
131
133
  const now = Date.now();
132
- const { userRpm, groupRpm, windowMs } = this.aiRequestLimitConfig;
134
+ const { userRpm, groupRpm, windowMs } = this.getAiRequestLimits(groupId);
133
135
 
134
136
  if (typeof userId === "number") {
135
137
  const userRequests = (this.userAiRequests.get(userId) ?? []).filter(
@@ -154,7 +156,7 @@ export class RateLimiter {
154
156
 
155
157
  recordAIRequest(userId?: number, groupId?: number): void {
156
158
  const now = Date.now();
157
- const windowMs = this.aiRequestLimitConfig.windowMs;
159
+ const { windowMs } = this.getAiRequestLimits(groupId);
158
160
 
159
161
  if (typeof userId === "number") {
160
162
  const userRequests = (this.userAiRequests.get(userId) ?? []).filter(
@@ -175,9 +177,8 @@ export class RateLimiter {
175
177
 
176
178
  getInteractionCount(groupId: number): number {
177
179
  const now = Date.now();
178
- const windowMs = this.dynamicDelayConfig.interactionWindowMs;
180
+ const { interactionWindowMs: windowMs } = this.getDynamicDelay(groupId);
179
181
 
180
- // 优先使用队列长度作为互动人数计算
181
182
  if (this.getQueueLengthFn) {
182
183
  const queueLength = this.getQueueLengthFn(groupId);
183
184
  if (queueLength > 0) {
@@ -185,7 +186,6 @@ export class RateLimiter {
185
186
  }
186
187
  }
187
188
 
188
- // 回退到 groupInteractions 统计
189
189
  const groupUsers = this.groupInteractions.get(groupId);
190
190
  if (!groupUsers) return 0;
191
191
 
@@ -200,12 +200,13 @@ export class RateLimiter {
200
200
  }
201
201
 
202
202
  calculateDelay(groupId: number): number {
203
- if (!this.dynamicDelayConfig.enabled) return 0;
203
+ const dynamicDelay = this.getDynamicDelay(groupId);
204
+ if (!dynamicDelay.enabled) return 0;
204
205
 
205
206
  const interactionCount = this.getInteractionCount(groupId);
206
207
  if (interactionCount <= 1) return 0;
207
208
 
208
- const { baseDelayMs, maxDelayMs } = this.dynamicDelayConfig;
209
+ const { baseDelayMs, maxDelayMs } = dynamicDelay;
209
210
  const delay = (interactionCount - 1) * baseDelayMs;
210
211
  return Math.min(delay, maxDelayMs);
211
212
  }
@@ -257,46 +258,21 @@ export class RateLimiter {
257
258
  }
258
259
  }
259
260
 
260
- const aiWindowMs = this.aiRequestLimitConfig.windowMs;
261
- for (const [userId, requests] of this.userAiRequests) {
262
- const valid = requests.filter(
263
- (timestamp) => now - timestamp < aiWindowMs,
264
- );
265
- if (valid.length === 0) {
266
- this.userAiRequests.delete(userId);
267
- } else {
268
- this.userAiRequests.set(userId, valid);
269
- }
270
- }
271
-
272
- for (const [groupId, requests] of this.groupAiRequests) {
273
- const valid = requests.filter(
274
- (timestamp) => now - timestamp < aiWindowMs,
275
- );
276
- if (valid.length === 0) {
277
- this.groupAiRequests.delete(groupId);
278
- } else {
279
- this.groupAiRequests.set(groupId, valid);
280
- }
281
- }
282
-
283
- if (this.dynamicDelayConfig.enabled) {
284
- const windowMs = this.dynamicDelayConfig.interactionWindowMs;
285
- for (const [groupId, groupUsers] of this.groupInteractions) {
286
- let hasActiveUser = false;
287
- for (const [userId, timestamps] of groupUsers) {
288
- const valid = timestamps.filter((t) => now - t < windowMs);
289
- if (valid.length === 0) {
290
- groupUsers.delete(userId);
291
- } else {
292
- groupUsers.set(userId, valid);
293
- hasActiveUser = true;
294
- }
295
- }
296
- if (!hasActiveUser) {
297
- this.groupInteractions.delete(groupId);
261
+ for (const [groupId, groupUsers] of this.groupInteractions) {
262
+ const windowMs = this.getDynamicDelay(groupId).interactionWindowMs;
263
+ let hasActiveUser = false;
264
+ for (const [userId, timestamps] of groupUsers) {
265
+ const valid = timestamps.filter((t) => now - t < windowMs);
266
+ if (valid.length === 0) {
267
+ groupUsers.delete(userId);
268
+ } else {
269
+ groupUsers.set(userId, valid);
270
+ hasActiveUser = true;
298
271
  }
299
272
  }
273
+ if (!hasActiveUser) {
274
+ this.groupInteractions.delete(groupId);
275
+ }
300
276
  }
301
277
  }
302
278
 
@@ -309,4 +285,4 @@ export class RateLimiter {
309
285
  this.userAiRequests.clear();
310
286
  this.groupAiRequests.clear();
311
287
  }
312
- }
288
+ }
@@ -0,0 +1,130 @@
1
+ export type SessionTurnSource =
2
+ | "message"
3
+ | "queued-message"
4
+ | "cooldown"
5
+ | "cooldown-planner"
6
+ | "dynamic-delay"
7
+ | "idle-check"
8
+ | "idle-debug"
9
+ | "poke"
10
+ | "chat-runtime";
11
+
12
+ interface SessionTurnEntry<T> {
13
+ source: SessionTurnSource;
14
+ dedupeKey?: string;
15
+ run: () => Promise<T>;
16
+ promise: Promise<T>;
17
+ resolve: (value: T) => void;
18
+ reject: (reason: unknown) => void;
19
+ }
20
+
21
+ interface SessionTurnState {
22
+ running: boolean;
23
+ pending: SessionTurnEntry<unknown>[];
24
+ }
25
+
26
+ export class SessionTurnSchedulerDisposedError extends Error {
27
+ constructor() {
28
+ super("Session turn scheduler has been disposed");
29
+ this.name = "SessionTurnSchedulerDisposedError";
30
+ }
31
+ }
32
+
33
+ export class SessionTurnScheduler {
34
+ private readonly sessions = new Map<string, SessionTurnState>();
35
+ private disposed = false;
36
+
37
+ run<T>(
38
+ sessionId: string,
39
+ source: SessionTurnSource,
40
+ task: () => Promise<T>,
41
+ options: { dedupeKey?: string } = {},
42
+ ): Promise<T> {
43
+ if (this.disposed) {
44
+ return Promise.reject(new SessionTurnSchedulerDisposedError());
45
+ }
46
+
47
+ const state = this.getOrCreateState(sessionId);
48
+ if (options.dedupeKey) {
49
+ const existing = state.pending.find(
50
+ (entry) => entry.dedupeKey === options.dedupeKey,
51
+ );
52
+ if (existing) return existing.promise as Promise<T>;
53
+ }
54
+
55
+ let resolve!: (value: T) => void;
56
+ let reject!: (reason: unknown) => void;
57
+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
58
+ resolve = resolvePromise;
59
+ reject = rejectPromise;
60
+ });
61
+ const entry: SessionTurnEntry<T> = {
62
+ source,
63
+ dedupeKey: options.dedupeKey,
64
+ run: task,
65
+ promise,
66
+ resolve,
67
+ reject,
68
+ };
69
+
70
+ state.pending.push(entry as SessionTurnEntry<unknown>);
71
+ if (!state.running) void this.drain(sessionId, state);
72
+ return promise;
73
+ }
74
+
75
+ isBusy(sessionId: string): boolean {
76
+ const state = this.sessions.get(sessionId);
77
+ return Boolean(state?.running || state?.pending.length);
78
+ }
79
+
80
+ pendingCount(sessionId: string): number {
81
+ return this.sessions.get(sessionId)?.pending.length ?? 0;
82
+ }
83
+
84
+ dispose(): void {
85
+ if (this.disposed) return;
86
+ this.disposed = true;
87
+ const error = new SessionTurnSchedulerDisposedError();
88
+
89
+ for (const [sessionId, state] of this.sessions) {
90
+ for (const entry of state.pending.splice(0)) entry.reject(error);
91
+ if (!state.running) this.sessions.delete(sessionId);
92
+ }
93
+ }
94
+
95
+ private getOrCreateState(sessionId: string): SessionTurnState {
96
+ let state = this.sessions.get(sessionId);
97
+ if (!state) {
98
+ state = { running: false, pending: [] };
99
+ this.sessions.set(sessionId, state);
100
+ }
101
+ return state;
102
+ }
103
+
104
+ private async drain(
105
+ sessionId: string,
106
+ state: SessionTurnState,
107
+ ): Promise<void> {
108
+ if (state.running || this.disposed) return;
109
+ state.running = true;
110
+
111
+ try {
112
+ while (!this.disposed) {
113
+ const entry = state.pending.shift();
114
+ if (!entry) break;
115
+ try {
116
+ entry.resolve(await entry.run());
117
+ } catch (err) {
118
+ entry.reject(err);
119
+ }
120
+ }
121
+ } finally {
122
+ state.running = false;
123
+ if (state.pending.length === 0) {
124
+ this.sessions.delete(sessionId);
125
+ } else if (!this.disposed) {
126
+ void this.drain(sessionId, state);
127
+ }
128
+ }
129
+ }
130
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-plugin-chat",
3
- "version": "2.5.2",
3
+ "version": "2.7.0",
4
4
  "description": "AI 智能聊天插件",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -10,9 +10,21 @@ import type { ChatPluginContext, ChatRuntime } from "../context";
10
10
  import type { ChatConfig } from "../types";
11
11
  import { executeChatRuntimeRequest } from "../core/chat-turn";
12
12
 
13
+ function resolveRuntimeGroupId(
14
+ options: ChatRuntimeNoticeOptions | ChatRuntimeInformationRequestOptions,
15
+ ): number | undefined {
16
+ if ("event" in options) {
17
+ const event = options.event;
18
+ return event?.message_type === "group" && typeof event.group_id === "number"
19
+ ? event.group_id
20
+ : undefined;
21
+ }
22
+ return "groupId" in options ? options.groupId : undefined;
23
+ }
24
+
13
25
  export function createChatRuntime(
14
26
  pluginCtx: ChatPluginContext,
15
- getConfig: () => Promise<ChatConfig>,
27
+ getConfig: (groupId?: number) => Promise<ChatConfig>,
16
28
  ): ChatRuntime {
17
29
  return {
18
30
  async requestInformation(
@@ -79,7 +91,7 @@ export function createChatRuntime(
79
91
  const result = await executeChatRuntimeRequest(
80
92
  {
81
93
  ...options,
82
- config: await getConfig(),
94
+ config: await getConfig(resolveRuntimeGroupId(options)),
83
95
  targetMessageContent: options.targetMessage,
84
96
  promptInjections,
85
97
  extraTools,
@@ -111,7 +123,7 @@ export function createChatRuntime(
111
123
  return executeChatRuntimeRequest(
112
124
  {
113
125
  ...options,
114
- config: await getConfig(),
126
+ config: await getConfig(resolveRuntimeGroupId(options)),
115
127
  targetMessageContent: options.instruction,
116
128
  promptInjections,
117
129
  send: options.send,
package/types.ts CHANGED
@@ -65,10 +65,8 @@ export interface PlannerConfig {
65
65
  */
66
66
  export interface EmojiConfig {
67
67
  enabled: boolean;
68
- // 允许的表情包角色
69
68
  characters: string[];
70
- // 使用AI选择表情包发送
71
- useAISelection: boolean;
69
+ stickers: string[];
72
70
  }
73
71
 
74
72
  /**
@@ -219,6 +217,29 @@ export interface ChatConfig {
219
217
  retention: RetentionConfig;
220
218
  }
221
219
 
220
+ /**
221
+ * 每群单独的开关覆盖
222
+ * 不填写的开关回退到全局默认;私聊会话不使用此覆盖
223
+ */
224
+ export interface ChatGroupOverrides {
225
+ emoji?: { enabled?: boolean };
226
+ expression?: { enabled?: boolean };
227
+ retention?: { enabled?: boolean };
228
+ memory?: { enabled?: boolean };
229
+ topic?: { enabled?: boolean };
230
+ planner?: { enabled?: boolean };
231
+ audio?: { enabled?: boolean };
232
+ searxng?: { enabled?: boolean };
233
+ webReader?: { enabled?: boolean };
234
+ dynamicDelay?: { enabled?: boolean };
235
+ enableMarkdownScreenshot?: boolean;
236
+ enableMediaRecognition?: boolean;
237
+ }
238
+
239
+ export type ChatGroupsFile = {
240
+ groups: Record<string, ChatGroupOverrides>;
241
+ };
242
+
222
243
  /**
223
244
  * 会话类型
224
245
  */
@@ -353,18 +374,6 @@ export interface ExpressionRecord {
353
374
  createdAt: number;
354
375
  }
355
376
 
356
- /**
357
- * 表情包注册记录
358
- */
359
- export interface EmojiRecord {
360
- id?: number;
361
- fileName: string;
362
- description: string; // AI 生成的描述
363
- emotion: string; // 情感标签
364
- usageCount: number;
365
- createdAt: number;
366
- }
367
-
368
377
  /**
369
378
  * 图片记录
370
379
  */
@@ -372,11 +381,8 @@ export interface ImageRecord {
372
381
  id?: number;
373
382
  hash: string; // 图片哈希
374
383
  url: string; // 原始 URL
375
- type: "meme" | "image"; // 图片类型
384
+ type: "image";
376
385
  description: string; // AI 生成的简要描述
377
- emotion?: string; // 情感标签(仅表情包)
378
- character?: string; // 角色名称(仅表情包)
379
- filePath?: string; // 本地文件路径(仅表情包)
380
386
  createdAt: number;
381
387
  }
382
388
 
@@ -0,0 +1,57 @@
1
+ function isConfigObject(value: unknown): value is Record<string, any> {
2
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
3
+ }
4
+
5
+ export function mergeChatConfig<T extends Record<string, any>>(
6
+ defaults: T,
7
+ overrides: Record<string, any>,
8
+ ): T {
9
+ const result: Record<string, any> = {};
10
+
11
+ for (const [key, value] of Object.entries(defaults)) {
12
+ if (isConfigObject(value)) {
13
+ result[key] = mergeChatConfig(value, {});
14
+ } else if (Array.isArray(value)) {
15
+ result[key] = [...value];
16
+ } else {
17
+ result[key] = value;
18
+ }
19
+ }
20
+
21
+ for (const [key, value] of Object.entries(overrides)) {
22
+ if (value === undefined || value === null) continue;
23
+ if (isConfigObject(value)) {
24
+ const baseValue = isConfigObject(result[key]) ? result[key] : {};
25
+ result[key] = mergeChatConfig(baseValue, value);
26
+ } else if (Array.isArray(value)) {
27
+ result[key] = [...value];
28
+ } else {
29
+ result[key] = value;
30
+ }
31
+ }
32
+
33
+ return result as T;
34
+ }
35
+
36
+ export function normalizeIdList(input: unknown): number[] {
37
+ if (!Array.isArray(input)) return [];
38
+ return Array.from(
39
+ new Set(
40
+ input
41
+ .map((item) => Math.floor(Number(item)))
42
+ .filter((id) => Number.isFinite(id) && id > 0),
43
+ ),
44
+ );
45
+ }
46
+
47
+ export function normalizeMediaAnalysisBlacklist(
48
+ config: Record<string, any>,
49
+ ): number[] {
50
+ const current = config.mediaAnalysisBlacklistUsers;
51
+ const legacy = config.imageAnalysisBlacklistUsers;
52
+ const source =
53
+ legacy !== undefined && Array.isArray(current) && current.length === 0
54
+ ? legacy
55
+ : (current ?? legacy);
56
+ return normalizeIdList(source);
57
+ }
@@ -0,0 +1,70 @@
1
+ import type { ChatConfig, ChatGroupOverrides } from "../types";
2
+
3
+ const OVERRIDABLE_SUB_OBJECTS = [
4
+ "emoji",
5
+ "expression",
6
+ "retention",
7
+ "memory",
8
+ "topic",
9
+ "planner",
10
+ "audio",
11
+ "searxng",
12
+ "webReader",
13
+ "dynamicDelay",
14
+ ] as const;
15
+
16
+ const OVERRIDABLE_BOOLEAN_FIELDS = [
17
+ "enableMarkdownScreenshot",
18
+ "enableMediaRecognition",
19
+ ] as const;
20
+
21
+ /**
22
+ * 把每个群的覆盖配置浅合并到全局配置上
23
+ * - 子对象(如 emoji)只覆盖其中存在的字段,未填字段继承全局
24
+ * - 顶层布尔字段(enableMarkdownScreenshot 等)若覆盖中存在则整体替换
25
+ * - 缺失字段、格式异常的覆盖会被静默忽略
26
+ */
27
+ export function mergeGroupOverrides(
28
+ base: ChatConfig,
29
+ overrides: ChatGroupOverrides | undefined | null,
30
+ ): ChatConfig {
31
+ if (!overrides || typeof overrides !== "object") return base;
32
+ if (typeof base !== "object" || base === null) return base;
33
+
34
+ const result: ChatConfig = { ...base };
35
+ const resultMap = result as unknown as Record<string, unknown>;
36
+ const overridesMap = overrides as Record<string, unknown>;
37
+
38
+ for (const key of OVERRIDABLE_SUB_OBJECTS) {
39
+ const overrideSub = overridesMap[key];
40
+ if (overrideSub && typeof overrideSub === "object") {
41
+ const baseSub = (base as unknown as Record<string, unknown>)[key];
42
+ if (baseSub && typeof baseSub === "object") {
43
+ resultMap[key] = {
44
+ ...(baseSub as Record<string, unknown>),
45
+ ...(overrideSub as Record<string, unknown>),
46
+ };
47
+ }
48
+ }
49
+ }
50
+
51
+ for (const key of OVERRIDABLE_BOOLEAN_FIELDS) {
52
+ const value = overridesMap[key];
53
+ if (typeof value === "boolean") {
54
+ resultMap[key] = value;
55
+ }
56
+ }
57
+
58
+ return result;
59
+ }
60
+
61
+ /**
62
+ * 从 sessionId(如 "group:123")提取群号,个人会话返回 undefined
63
+ */
64
+ export function extractGroupIdFromSession(sessionId: string): number | undefined {
65
+ if (!sessionId || typeof sessionId !== "string") return undefined;
66
+ if (!sessionId.startsWith("group:")) return undefined;
67
+ const raw = sessionId.slice("group:".length);
68
+ const parsed = parseInt(raw, 10);
69
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
70
+ }
package/utils/message.ts CHANGED
@@ -308,12 +308,12 @@ async function getImageTagWithHashCache(
308
308
  ctx: HistoryFormatContext,
309
309
  ): Promise<string> {
310
310
  const exact = db.getImageByUrl?.(imageUrl);
311
- if (exact) return `[${exact.type}:${exact.description}]`;
311
+ if (exact) return `[image:${exact.description}]`;
312
312
 
313
313
  const normalized = normalizeImageUrl(imageUrl);
314
314
  if (normalized && normalized !== imageUrl) {
315
315
  const hit = db.getImageByUrl?.(normalized);
316
- if (hit) return `[${hit.type}:${hit.description}]`;
316
+ if (hit) return `[image:${hit.description}]`;
317
317
  }
318
318
 
319
319
  let hash = ctx.hashLookupCache.get(`hash:${imageUrl}`);
@@ -329,17 +329,14 @@ async function getImageTagWithHashCache(
329
329
  db.saveImage?.({
330
330
  hash: byHash.hash,
331
331
  url: normalized || imageUrl,
332
- type: byHash.type,
332
+ type: "image",
333
333
  description: byHash.description,
334
- emotion: byHash.emotion,
335
- character: byHash.character,
336
- filePath: byHash.filePath,
337
334
  createdAt: byHash.createdAt,
338
335
  });
339
336
  } catch {
340
337
  // best-effort URL caching for next call
341
338
  }
342
- return `[${byHash.type}:${byHash.description}]`;
339
+ return `[image:${byHash.description}]`;
343
340
  }
344
341
 
345
342
  return "[image]";
package/utils/queue.ts CHANGED
@@ -88,6 +88,11 @@ export class MessageQueueManager {
88
88
  const queue = this.pendingMessages.get(groupSessionId);
89
89
  return !!queue && queue.length > 0;
90
90
  }
91
+
92
+ dispose(): void {
93
+ this.pendingMessages.clear();
94
+ this.activeTargetMessages.clear();
95
+ }
91
96
  }
92
97
 
93
98
  /**