mioku-plugin-chat 2.5.0 → 2.6.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.
@@ -2,7 +2,7 @@ import type { MiokiContext } from "mioki";
2
2
  import type { AIInstance } from "mioku";
3
3
  import type { ChatConfig, TargetMessage } from "../types";
4
4
  import type { ChatDatabase } from "../db";
5
- import type { HumanizeEngine } from "../humanize";
5
+ import type { HumanizeEngine, ChatConfigProvider } from "../humanize";
6
6
  import type { SkillSessionManager } from "./skill-session";
7
7
  import type { GroupStructuredHistoryManager, StructuredUserInput } from "./group-structured-history";
8
8
  import { buildStructuredUserInputFromEvent } from "./group-structured-history";
@@ -35,7 +35,8 @@ export class QueueProcessor {
35
35
  private dynamicDelayQueues = new Map<string, DynamicDelayQueueData>();
36
36
 
37
37
  private ctx: MiokiContext;
38
- private cfg: ChatConfig;
38
+ private configProvider: ChatConfigProvider;
39
+ private defaultConfig: ChatConfig;
39
40
  private db: ChatDatabase;
40
41
  private humanize: HumanizeEngine;
41
42
  private aiInstance: AIInstance;
@@ -60,7 +61,8 @@ export class QueueProcessor {
60
61
 
61
62
  constructor(pluginCtx: ChatPluginContext) {
62
63
  this.ctx = pluginCtx.ctx;
63
- this.cfg = pluginCtx.config;
64
+ this.configProvider = pluginCtx.configProvider;
65
+ this.defaultConfig = pluginCtx.defaultConfig;
64
66
  this.db = pluginCtx.db;
65
67
  this.humanize = pluginCtx.humanize;
66
68
  this.aiInstance = pluginCtx.aiInstance;
@@ -147,14 +149,15 @@ export class QueueProcessor {
147
149
  content: mergedContent, messageId: firstMsg.messageId, timestamp: Date.now(),
148
150
  };
149
151
 
152
+ const cfg = this.configProvider(groupId);
150
153
  const botRole = await getBotRole(groupId, this.ctx, selfId);
151
- const botNickname = this.cfg.nicknames[0] || this.ctx.pickBot(selfId).nickname || "Bot";
154
+ const botNickname = cfg.nicknames[0] || this.ctx.pickBot(selfId).nickname || "Bot";
152
155
  const { groupName, memberCount } = await this.getGroupInfoData(this.ctx, groupId, selfId, String(groupId));
153
- const { history } = await this.getGroupHistoryMessages(groupId, groupSessionId, this.ctx, this.cfg.historyCount, this.db, selfId, this.buildHistoryMediaOptions(this.aiInstance, this.cfg));
156
+ const { history } = await this.getGroupHistoryMessages(groupId, groupSessionId, this.ctx, cfg.historyCount, this.db, selfId, this.buildHistoryMediaOptions(this.aiInstance, cfg));
154
157
 
155
158
  const toolCtx = this.buildToolContext({
156
159
  ctx: this.ctx, event: firstMsg.event, groupSessionId, groupId, userId: firstMsg.userId,
157
- config: this.cfg, aiService: this.aiService, db: this.db, botRole, humanize: this.humanize, targetMessage, selfId,
160
+ config: cfg, aiService: this.aiService, db: this.db, botRole, humanize: this.humanize, targetMessage, selfId,
158
161
  });
159
162
 
160
163
  this.sessionManager.getOrCreate(groupSessionId, "group", groupId);
@@ -162,18 +165,18 @@ export class QueueProcessor {
162
165
 
163
166
  const result = await this.runWithRateLimitGuard(
164
167
  () => this.runChat(this.aiInstance, toolCtx, history, targetMessage, {
165
- config: this.cfg, groupName, memberCount, botNickname, botRole, aiService: this.aiService, isGroup: true,
168
+ config: cfg, groupName, memberCount, botNickname, botRole, aiService: this.aiService, isGroup: true,
166
169
  memoryContext: contexts.memoryContext, topicContext: contexts.topicContext, expressionContext: contexts.expressionContext,
167
170
  replyContext: { type: "review", targetUser: targetMessage.userName, targetMessage: targetMessage.content },
168
171
  reviewMessages: { contents: mergedContents, userNames, messageIds },
169
172
  }, this.humanize, this.skillManager, {
170
- manager: this.groupStructuredHistory, ttlMs: this.cfg.groupStructuredHistoryTtlMs, currentUserInputs: structuredUserInputs,
173
+ manager: this.groupStructuredHistory, ttlMs: cfg.groupStructuredHistoryTtlMs, currentUserInputs: structuredUserInputs,
171
174
  }),
172
175
  { userId: targetMessage.userId, groupId, label: "dynamic-delay" },
173
176
  );
174
177
  if (!result) return;
175
178
 
176
- await this.sendAIResponse({ ctx: this.ctx, groupId, messages: result.messages, config: this.cfg, sentIndices: toolCtx.sentMessageIndices }, selfId);
179
+ await this.sendAIResponse({ ctx: this.ctx, groupId, messages: result.messages, config: cfg, sentIndices: toolCtx.sentMessageIndices }, selfId);
177
180
  this.startCooldownTimer(groupSessionId, groupId, selfId);
178
181
  }
179
182
 
@@ -191,8 +194,10 @@ export class QueueProcessor {
191
194
  const queuedContents: string[] = [];
192
195
  const structuredUserInputs: StructuredUserInput[] = [];
193
196
 
197
+ const cfg = this.configProvider(groupId);
198
+
194
199
  for (const item of queue) {
195
- const { text: extractedText, multimodal } = extractContent(item.event, this.cfg, this.ctx);
200
+ const { text: extractedText, multimodal } = extractContent(item.event, cfg, this.ctx);
196
201
  const content = multimodal ? JSON.stringify(multimodal) : extractedText;
197
202
  if (content) {
198
203
  queuedContents.push(content);
@@ -219,32 +224,32 @@ export class QueueProcessor {
219
224
  };
220
225
 
221
226
  const botRole = await getBotRole(groupId, this.ctx, selfId);
222
- const botNickname = this.cfg.nicknames[0] || this.ctx.pickBot(selfId).nickname || "Bot";
227
+ const botNickname = cfg.nicknames[0] || this.ctx.pickBot(selfId).nickname || "Bot";
223
228
  const toolCtx = this.buildToolContext({
224
229
  ctx: this.ctx, event: null, groupSessionId, groupId, userId: targetMessage.userId,
225
- config: this.cfg, aiService: this.aiService, db: this.db, botRole, humanize: this.humanize, targetMessage, selfId,
230
+ config: cfg, aiService: this.aiService, db: this.db, botRole, humanize: this.humanize, targetMessage, selfId,
226
231
  });
227
232
 
228
- const { history } = await this.getGroupHistoryMessages(groupId, groupSessionId, this.ctx, this.cfg.historyCount, this.db, selfId, this.buildHistoryMediaOptions(this.aiInstance, this.cfg));
233
+ const { history } = await this.getGroupHistoryMessages(groupId, groupSessionId, this.ctx, cfg.historyCount, this.db, selfId, this.buildHistoryMediaOptions(this.aiInstance, cfg));
229
234
  const contexts = await this.getHumanizeContexts(this.humanize, groupSessionId, targetMessage.userName, history, targetMessage.userId);
230
235
  const { groupName, memberCount } = await this.getGroupInfoData(this.ctx, groupId, selfId);
231
236
 
232
237
  const result = await this.runWithRateLimitGuard(
233
238
  () => this.runChat(this.aiInstance, toolCtx, history, targetMessage, {
234
- config: this.cfg, groupName, memberCount, botNickname, botRole: toolCtx.botRole, aiService: this.aiService, isGroup: true,
239
+ config: cfg, groupName, memberCount, botNickname, botRole: toolCtx.botRole, aiService: this.aiService, isGroup: true,
235
240
  memoryContext: contexts.memoryContext, topicContext: contexts.topicContext, expressionContext: contexts.expressionContext,
236
241
  replyContext: { type: "comment", targetUser: targetMessage.userName, targetMessage: targetMessage.content },
237
242
  }, this.humanize, this.skillManager, {
238
- manager: this.groupStructuredHistory, ttlMs: this.cfg.groupStructuredHistoryTtlMs, currentUserInputs: structuredUserInputs,
243
+ manager: this.groupStructuredHistory, ttlMs: cfg.groupStructuredHistoryTtlMs, currentUserInputs: structuredUserInputs,
239
244
  }),
240
245
  { userId: targetMessage.userId, groupId, label: "queue" },
241
246
  );
242
247
  if (!result) return;
243
248
 
244
- await this.sendAIResponse({ ctx: this.ctx, groupId, messages: result.messages, config: this.cfg, sentIndices: toolCtx.sentMessageIndices }, selfId);
249
+ await this.sendAIResponse({ ctx: this.ctx, groupId, messages: result.messages, config: cfg, sentIndices: toolCtx.sentMessageIndices }, selfId);
245
250
  await this.sendEmoji(this.ctx, groupId, result.emojiPath, selfId);
246
251
  const now = Date.now();
247
- this.saveBotMessages(groupId, groupSessionId, result.messages, now, this.cfg, this.db, this.ctx, selfId);
252
+ this.saveBotMessages(groupId, groupSessionId, result.messages, now, cfg, this.db, this.ctx, selfId);
248
253
  this.sessionManager.touch(groupSessionId);
249
254
  this.ctx.logger.info(`[Queue] group ${groupSessionId} done`);
250
255
  } catch (err) {
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-plugin-chat",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "AI 智能聊天插件",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -12,7 +12,7 @@ import { executeChatRuntimeRequest } from "../core/chat-turn";
12
12
 
13
13
  export function createChatRuntime(
14
14
  pluginCtx: ChatPluginContext,
15
- getConfig: () => Promise<ChatConfig>,
15
+ getConfig: (groupId?: number) => Promise<ChatConfig>,
16
16
  ): ChatRuntime {
17
17
  return {
18
18
  async requestInformation(
package/types.ts CHANGED
@@ -219,6 +219,29 @@ export interface ChatConfig {
219
219
  retention: RetentionConfig;
220
220
  }
221
221
 
222
+ /**
223
+ * 每群单独的开关覆盖
224
+ * 不填写的开关回退到全局默认;私聊会话不使用此覆盖
225
+ */
226
+ export interface ChatGroupOverrides {
227
+ emoji?: { enabled?: boolean };
228
+ expression?: { enabled?: boolean };
229
+ retention?: { enabled?: boolean };
230
+ memory?: { enabled?: boolean };
231
+ topic?: { enabled?: boolean };
232
+ planner?: { enabled?: boolean };
233
+ audio?: { enabled?: boolean };
234
+ searxng?: { enabled?: boolean };
235
+ webReader?: { enabled?: boolean };
236
+ dynamicDelay?: { enabled?: boolean };
237
+ enableMarkdownScreenshot?: boolean;
238
+ enableMediaRecognition?: boolean;
239
+ }
240
+
241
+ export type ChatGroupsFile = {
242
+ groups: Record<string, ChatGroupOverrides>;
243
+ };
244
+
222
245
  /**
223
246
  * 会话类型
224
247
  */
@@ -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
@@ -14,7 +14,10 @@ import {
14
14
  type HistoryMediaProcessingOptions,
15
15
  type MediaMessageSegment,
16
16
  } from "../core/media/history-media";
17
- import { getImageTag } from "../core/media/image-analyzer";
17
+ import {
18
+ calculateImageHash,
19
+ normalizeImageUrl,
20
+ } from "../core/media/image-analyzer";
18
21
 
19
22
  const HISTORY_MEDIA_CONCURRENCY = 8;
20
23
 
@@ -217,11 +220,14 @@ interface HistoryFormatContext {
217
220
  db:
218
221
  | {
219
222
  getImageByUrl?(url: string): ImageRecord | null;
223
+ getImageByHash?(hash: string): ImageRecord | null;
224
+ saveImage?(image: ImageRecord): void;
220
225
  }
221
226
  | undefined;
222
227
  historyMediaOptions: HistoryMediaProcessingOptions;
223
228
  botUin: number;
224
229
  memberNameCache: Map<string, string>;
230
+ hashLookupCache: Map<string, string | null>;
225
231
  }
226
232
 
227
233
  async function resolveMemberName(
@@ -286,7 +292,7 @@ function buildReplyAnnotation(source: any, ctx: HistoryFormatContext): string |
286
292
  if (sourceUserId == null && !sourceText && !sourceNickname) return null;
287
293
 
288
294
  const idStr = sourceId != null ? String(sourceId) : "?";
289
- let displayName = sourceNickname;
295
+ let displayName: string | undefined;
290
296
  if (sourceUserId != null) {
291
297
  displayName = ctx.memberNameCache.get(String(sourceUserId)) || String(sourceUserId);
292
298
  }
@@ -296,6 +302,49 @@ function buildReplyAnnotation(source: any, ctx: HistoryFormatContext): string |
296
302
  return `↪ reply to #${idStr} ${displayName}: "${text}"`;
297
303
  }
298
304
 
305
+ async function getImageTagWithHashCache(
306
+ imageUrl: string,
307
+ db: NonNullable<HistoryFormatContext["db"]>,
308
+ ctx: HistoryFormatContext,
309
+ ): Promise<string> {
310
+ const exact = db.getImageByUrl?.(imageUrl);
311
+ if (exact) return `[${exact.type}:${exact.description}]`;
312
+
313
+ const normalized = normalizeImageUrl(imageUrl);
314
+ if (normalized && normalized !== imageUrl) {
315
+ const hit = db.getImageByUrl?.(normalized);
316
+ if (hit) return `[${hit.type}:${hit.description}]`;
317
+ }
318
+
319
+ let hash = ctx.hashLookupCache.get(`hash:${imageUrl}`);
320
+ if (hash === undefined) {
321
+ hash = (await calculateImageHash(imageUrl)) || null;
322
+ ctx.hashLookupCache.set(`hash:${imageUrl}`, hash);
323
+ }
324
+ if (!hash) return "[image]";
325
+
326
+ const byHash = db.getImageByHash?.(hash);
327
+ if (byHash) {
328
+ try {
329
+ db.saveImage?.({
330
+ hash: byHash.hash,
331
+ url: normalized || imageUrl,
332
+ type: byHash.type,
333
+ description: byHash.description,
334
+ emotion: byHash.emotion,
335
+ character: byHash.character,
336
+ filePath: byHash.filePath,
337
+ createdAt: byHash.createdAt,
338
+ });
339
+ } catch {
340
+ // best-effort URL caching for next call
341
+ }
342
+ return `[${byHash.type}:${byHash.description}]`;
343
+ }
344
+
345
+ return "[image]";
346
+ }
347
+
299
348
  async function formatHistoryMessage(
300
349
  msg: any,
301
350
  ctx: HistoryFormatContext,
@@ -359,15 +408,30 @@ async function buildMessageContent(
359
408
  if (atContent) parts.push(atContent);
360
409
  if (textContent) parts.push(textContent);
361
410
 
362
- for (const imageSeg of msg.message.filter((seg: any) => seg.type === "image")) {
363
- const imageUrl = imageSeg.url || imageSeg.data?.url;
364
- if (imageUrl) {
365
- parts.push(
366
- db?.getImageByUrl
367
- ? getImageTag(String(imageUrl), db as any)
368
- : "[image]",
369
- );
370
- }
411
+ const imageSegments = msg.message.filter((seg: any) => seg.type === "image");
412
+ if (imageSegments.length > 0 && db?.getImageByUrl) {
413
+ const imageTags = await Promise.all(
414
+ imageSegments.map(async (seg: any) => {
415
+ const imageUrl = seg.url || seg.data?.url;
416
+ if (!imageUrl) return "[image]";
417
+
418
+ const cached = ctx.hashLookupCache.get(imageUrl);
419
+ if (cached !== undefined) {
420
+ return cached;
421
+ }
422
+
423
+ const tag = await getImageTagWithHashCache(
424
+ String(imageUrl),
425
+ db as any,
426
+ ctx,
427
+ );
428
+ ctx.hashLookupCache.set(imageUrl, tag);
429
+ return tag;
430
+ }),
431
+ );
432
+ for (const tag of imageTags) parts.push(tag);
433
+ } else if (imageSegments.length > 0) {
434
+ for (const _ of imageSegments) parts.push("[image]");
371
435
  }
372
436
 
373
437
  for (const videoSeg of msg.message.filter(
@@ -393,7 +457,7 @@ async function buildMessageContent(
393
457
  }
394
458
 
395
459
  for (const cardSeg of msg.message.filter((seg: any) =>
396
- ["xml", "json", "lightapp", "ark"].includes(cardSeg.type),
460
+ ["xml", "json", "lightapp", "ark"].includes(seg.type),
397
461
  )) {
398
462
  const cardData =
399
463
  cardSeg.data?.data || cardSeg.data?.xml || cardSeg.data || cardSeg.xml || "";
@@ -530,6 +594,7 @@ export async function getGroupHistory(
530
594
 
531
595
  const botUin = selfId;
532
596
  const memberNameCache = new Map<string, string>();
597
+ const hashLookupCache = new Map<string, string | null>();
533
598
 
534
599
  for (const m of messages) {
535
600
  const uid = m?.user_id;
@@ -544,6 +609,7 @@ export async function getGroupHistory(
544
609
  historyMediaOptions,
545
610
  botUin,
546
611
  memberNameCache,
612
+ hashLookupCache,
547
613
  };
548
614
 
549
615
  const formattedResults = await mapWithConcurrency(