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.
- package/configs/groups.ts +5 -0
- package/configs/personalization.ts +1 -1
- package/context.ts +7 -3
- package/core/base.ts +1 -1
- package/core/chat-engine.ts +18 -7
- package/core/chat-turn.ts +28 -10
- package/core/media/image-analyzer.ts +63 -397
- package/core/prompt.ts +11 -20
- package/core/tools/web.ts +1 -1
- package/db.ts +8 -12
- package/handlers/idle-debug.ts +96 -97
- package/handlers/message.ts +38 -37
- package/handlers/poke.ts +104 -126
- package/humanize/emoji-agent.ts +210 -321
- package/humanize/emotion-agent.ts +33 -26
- package/humanize/expression.ts +24 -16
- package/humanize/index.ts +9 -8
- package/humanize/memory.ts +10 -7
- package/humanize/planner.ts +11 -16
- package/humanize/topic.ts +22 -15
- package/index.ts +120 -45
- package/manage/cooldown.ts +83 -116
- package/manage/idle-check.ts +173 -75
- package/manage/queue-processor.ts +92 -44
- package/manage/rate-limiter.ts +56 -80
- package/manage/session-turn-scheduler.ts +130 -0
- package/package.json +1 -1
- package/runtime/chat-runtime.ts +15 -3
- package/types.ts +25 -19
- package/utils/config.ts +57 -0
- package/utils/group-config.ts +70 -0
- package/utils/message.ts +4 -7
- package/utils/queue.ts +5 -0
package/manage/rate-limiter.ts
CHANGED
|
@@ -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
|
|
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.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
114
|
+
const dynamicDelay = this.getDynamicDelay(groupId);
|
|
115
|
+
if (!dynamicDelay.enabled) return;
|
|
114
116
|
|
|
115
117
|
const now = Date.now();
|
|
116
|
-
const windowMs =
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 } =
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
package/runtime/chat-runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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: "
|
|
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
|
|
package/utils/config.ts
ADDED
|
@@ -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 `[
|
|
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 `[
|
|
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:
|
|
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 `[
|
|
339
|
+
return `[image:${byHash.description}]`;
|
|
343
340
|
}
|
|
344
341
|
|
|
345
342
|
return "[image]";
|
package/utils/queue.ts
CHANGED