koishi-plugin-mai-bridge 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -58,6 +58,7 @@ webuiEnabled: true
58
58
  webuiHost: 0.0.0.0
59
59
  webuiPort: 8002
60
60
  messageMode: coexist
61
+ groupMessageTriggerCount: 1
61
62
  ```
62
63
 
63
64
  说明:
@@ -67,6 +68,7 @@ messageMode: coexist
67
68
  - `apiKey` 可以留空。插件会生成并复用运行期密钥。
68
69
  - `dockerNetwork` 按你的 Koishi Docker 网络填写。Koishi 需要能通过 `apiHost` 访问 `maimai-ko`。
69
70
  - `webuiPublicUrl` 可填写反代或宿主机映射后的 WebUI 地址,用于 Koishi 控制台显示入口。
71
+ - `groupMessageTriggerCount` 控制群聊累计多少条消息后批量转发并强制触发 maibot 思考。设为 `3` 时,同一群前两条先缓存,第 3 条会连同前两条一起转发;私聊不受影响。
70
72
 
71
73
  ## 消息模式
72
74
 
@@ -4,6 +4,7 @@ import type { KoishiRoute, MaimApiMessage } from '../types';
4
4
  export interface SessionToMaimOptions {
5
5
  resolveImage?: (source: string) => Awaitable<string | undefined>;
6
6
  replyContext?: ReplyContext;
7
+ forceMention?: boolean;
7
8
  }
8
9
  export interface ReplyContext {
9
10
  targetMessageId: string;
@@ -181,6 +181,8 @@ async function sessionToMaimMessage(session, route, apiKey, options = {}) {
181
181
  const messageId = String(session.messageId || session.id || `koishi-${Date.now()}`);
182
182
  const senderInfo = buildSenderInfo(session);
183
183
  const atBot = isAtBot(session);
184
+ const forceMention = !!options.forceMention;
185
+ const mentioned = atBot || forceMention;
184
186
  const replyContext = options.replyContext;
185
187
  return {
186
188
  message_info: {
@@ -202,8 +204,9 @@ async function sessionToMaimMessage(session, route, apiKey, options = {}) {
202
204
  koishi_reply_to_message_id: replyContext?.targetMessageId,
203
205
  koishi_reply_context_count: replyContext?.contextCount,
204
206
  koishi_is_direct: !!session.isDirect,
205
- at_bot: atBot,
206
- is_mentioned: atBot,
207
+ koishi_group_trigger_forced: forceMention,
208
+ at_bot: mentioned,
209
+ is_mentioned: mentioned,
207
210
  },
208
211
  format_info: {
209
212
  content_format: ['text', 'image'],
@@ -0,0 +1,22 @@
1
+ import type { Session } from 'koishi';
2
+ import type { KoishiRoute } from '../types';
3
+ export interface GroupTriggerResult {
4
+ shouldForward: boolean;
5
+ count: number;
6
+ threshold: number;
7
+ key?: string;
8
+ entries: GroupTriggerEntry[];
9
+ forceMention?: boolean;
10
+ }
11
+ export interface GroupTriggerEntry {
12
+ session: Session;
13
+ route: KoishiRoute;
14
+ }
15
+ export declare class GroupMessageTrigger {
16
+ private threshold;
17
+ private pending;
18
+ constructor(threshold: number);
19
+ test(session: Session, route: KoishiRoute): GroupTriggerResult;
20
+ clear(): void;
21
+ private createKey;
22
+ }
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GroupMessageTrigger = void 0;
4
+ class GroupMessageTrigger {
5
+ threshold;
6
+ pending = new Map();
7
+ constructor(threshold) {
8
+ this.threshold = threshold;
9
+ }
10
+ test(session, route) {
11
+ const threshold = Math.max(1, Math.floor(this.threshold || 1));
12
+ const entry = { session, route };
13
+ if (threshold <= 1 || session.isDirect || !route.channelId) {
14
+ return { shouldForward: true, count: 1, threshold, entries: [entry] };
15
+ }
16
+ const key = this.createKey(route);
17
+ const entries = [...this.pending.get(key) || [], entry];
18
+ if (entries.length >= threshold) {
19
+ this.pending.delete(key);
20
+ return { shouldForward: true, count: entries.length, threshold, key, entries, forceMention: true };
21
+ }
22
+ this.pending.set(key, entries);
23
+ return { shouldForward: false, count: entries.length, threshold, key, entries: [] };
24
+ }
25
+ clear() {
26
+ this.pending.clear();
27
+ }
28
+ createKey(route) {
29
+ return [
30
+ route.platform,
31
+ route.botSelfId,
32
+ route.guildId || '',
33
+ route.channelId,
34
+ ].join(':');
35
+ }
36
+ }
37
+ exports.GroupMessageTrigger = GroupMessageTrigger;
@@ -32,7 +32,12 @@ export declare class MaibotPrepareManager {
32
32
  private isValidGitRepository;
33
33
  private isRecoverablePartialDirectory;
34
34
  private ensurePatch;
35
+ private applyPatch;
35
36
  private checkPatchState;
37
+ private refreshManagedPatch;
38
+ private readMarker;
39
+ private listPatchPaths;
40
+ private listDirtyTrackedFiles;
36
41
  private readCommit;
37
42
  private writeMarker;
38
43
  private resolvePatchPath;
@@ -133,6 +133,13 @@ class MaibotPrepareManager {
133
133
  throw new Error(`blocked: bundled patch not found: ${patchPath}`);
134
134
  }
135
135
  if (patchState === 'conflict') {
136
+ const refreshed = await this.refreshManagedPatch(patchPath);
137
+ if (refreshed === 'applied')
138
+ return;
139
+ if (refreshed === 'pending') {
140
+ await this.applyPatch(patchPath);
141
+ return;
142
+ }
136
143
  throw new Error('blocked: bundled patch cannot be applied cleanly; check MaiBot ref or update maimai-koishi.patch');
137
144
  }
138
145
  if (patchState === 'applied') {
@@ -140,6 +147,9 @@ class MaibotPrepareManager {
140
147
  this.pushLog('bundled patch already applied');
141
148
  return;
142
149
  }
150
+ await this.applyPatch(patchPath);
151
+ }
152
+ async applyPatch(patchPath) {
143
153
  this.pushLog(`applying bundled patch: ${patchPath}`);
144
154
  await this.git(['apply', patchPath], this.root);
145
155
  this.patchApplied = true;
@@ -155,6 +165,71 @@ class MaibotPrepareManager {
155
165
  return 'applied';
156
166
  return 'conflict';
157
167
  }
168
+ async refreshManagedPatch(patchPath) {
169
+ const marker = this.readMarker();
170
+ if (!marker?.patchApplied)
171
+ return;
172
+ if (marker.gitUrl !== this.config.maibotGitUrl || marker.gitRef !== this.config.maibotGitRef)
173
+ return;
174
+ if (!marker.patchChecksum || marker.patchChecksum === this.patchChecksum)
175
+ return;
176
+ const patchPaths = new Set(this.listPatchPaths(patchPath));
177
+ const dirtyFiles = await this.listDirtyTrackedFiles();
178
+ if (!dirtyFiles.length)
179
+ return;
180
+ const foreignFiles = dirtyFiles.filter((file) => !patchPaths.has(file));
181
+ if (foreignFiles.length) {
182
+ throw new Error(`blocked: MaiBot has local changes outside bundled patch: ${foreignFiles.slice(0, 5).join(', ')}`);
183
+ }
184
+ this.pushLog('refreshing managed bundled patch');
185
+ await this.git(['restore', '--source', 'HEAD', '--', ...dirtyFiles], this.root);
186
+ const patchState = await this.checkPatchState(patchPath);
187
+ if (patchState === 'pending')
188
+ return 'pending';
189
+ if (patchState === 'applied') {
190
+ this.patchApplied = true;
191
+ this.pushLog('bundled patch already applied');
192
+ return 'applied';
193
+ }
194
+ throw new Error('blocked: bundled patch cannot be refreshed cleanly; check MaiBot ref or update maimai-koishi.patch');
195
+ }
196
+ readMarker() {
197
+ try {
198
+ const marker = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(this.root, '.mai-ko-prepare.json'), 'utf8'));
199
+ if (!marker || typeof marker !== 'object')
200
+ return;
201
+ return marker;
202
+ }
203
+ catch {
204
+ return;
205
+ }
206
+ }
207
+ listPatchPaths(patchPath) {
208
+ const paths = new Set();
209
+ const patch = (0, fs_1.readFileSync)(patchPath, 'utf8');
210
+ for (const line of patch.split(/\r?\n/)) {
211
+ const match = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
212
+ if (!match)
213
+ continue;
214
+ if (match[2] !== '/dev/null')
215
+ paths.add(match[2]);
216
+ }
217
+ return [...paths];
218
+ }
219
+ async listDirtyTrackedFiles() {
220
+ const result = await this.git(['status', '--porcelain', '--untracked-files=no'], this.root, true, false);
221
+ if (result.code !== 0)
222
+ return [];
223
+ return result.stdout
224
+ .split(/\r?\n/)
225
+ .map((line) => {
226
+ if (!line.trim())
227
+ return '';
228
+ const path = line.slice(3);
229
+ return path.includes(' -> ') ? path.split(' -> ').pop() : path;
230
+ })
231
+ .filter(Boolean);
232
+ }
158
233
  async readCommit() {
159
234
  const result = await this.git(['rev-parse', 'HEAD'], this.root, true, false);
160
235
  return result.code === 0 ? result.stdout.trim() : undefined;
package/out/config.d.ts CHANGED
@@ -23,6 +23,7 @@ export interface Config {
23
23
  showWebuiToken: boolean;
24
24
  messageMode: MessageMode;
25
25
  commandPrefix: string;
26
+ groupMessageTriggerCount: number;
26
27
  imageDownloadEnabled: boolean;
27
28
  imageDownloadTimeout: number;
28
29
  imageDownloadMaxBytes: number;
package/out/config.js CHANGED
@@ -43,6 +43,7 @@ exports.Config = koishi_1.Schema.intersect([
43
43
  koishi_1.Schema.const('command').description('命令:仅命中指令前缀时转发。'),
44
44
  ]).default('coexist').description('消息转发模式。'),
45
45
  commandPrefix: koishi_1.Schema.string().default('mai.ko').description('command 模式下触发 mai.ko 的文本前缀。'),
46
+ groupMessageTriggerCount: koishi_1.Schema.number().min(1).default(1).description('同一群聊累计达到多少条消息后批量转发并强制触发 mai.ko 思考;1 表示每条群消息都转发。私聊不受影响。'),
46
47
  imageDownloadEnabled: koishi_1.Schema.boolean().default(true).description('转发图片消息前由 Koishi 下载图片并转为 mai.ko 可识别的 base64 图片段。'),
47
48
  imageDownloadTimeout: koishi_1.Schema.number().min(1000).default(10000).description('下载单张入站图片的超时时间,单位毫秒。'),
48
49
  imageDownloadMaxBytes: koishi_1.Schema.number().min(1024).default(10 * 1024 * 1024).description('允许转发给 mai.ko 的单张图片最大字节数。'),
package/out/service.d.ts CHANGED
@@ -10,6 +10,7 @@ export declare class MaibotService extends Service {
10
10
  private docker;
11
11
  private process;
12
12
  private externalLogs;
13
+ private groupTrigger;
13
14
  private history;
14
15
  private routes;
15
16
  private transport;
@@ -37,6 +38,7 @@ export declare class MaibotService extends Service {
37
38
  dockerRestart(): Promise<RuntimeStatus>;
38
39
  getStatus(): RuntimeStatus;
39
40
  handleSession(session: Session, next: () => Awaitable<void | Fragment>): Promise<void | Fragment>;
41
+ private forwardSessionToMaim;
40
42
  dispose(): Promise<void>;
41
43
  private logMessageDetail;
42
44
  private logMessageSummary;
package/out/service.js CHANGED
@@ -5,6 +5,7 @@ const koishi_1 = require("koishi");
5
5
  const convert_1 = require("./bridge/convert");
6
6
  const docker_1 = require("./bridge/docker");
7
7
  const external_logs_1 = require("./bridge/external-logs");
8
+ const group_trigger_1 = require("./bridge/group-trigger");
8
9
  const history_1 = require("./bridge/history");
9
10
  const prepare_1 = require("./bridge/prepare");
10
11
  const process_1 = require("./bridge/process");
@@ -21,6 +22,7 @@ class MaibotService extends koishi_1.Service {
21
22
  docker;
22
23
  process;
23
24
  externalLogs;
25
+ groupTrigger;
24
26
  history;
25
27
  routes;
26
28
  transport;
@@ -43,6 +45,7 @@ class MaibotService extends koishi_1.Service {
43
45
  koishiSent: 0,
44
46
  routeMissed: 0,
45
47
  sendFailed: 0,
48
+ groupTriggerSkipped: 0,
46
49
  };
47
50
  constructor(ctx, pluginConfig) {
48
51
  super(ctx, 'maimai');
@@ -53,6 +56,7 @@ class MaibotService extends koishi_1.Service {
53
56
  this.docker = new docker_1.MaibotDockerManager(pluginConfig, this.apiKey);
54
57
  this.process = new process_1.MaibotProcessManager(ctx, pluginConfig, this.apiKey);
55
58
  this.externalLogs = new external_logs_1.ExternalLogForwarder(ctx, pluginConfig, [this.apiKey, pluginConfig.apiKey]);
59
+ this.groupTrigger = new group_trigger_1.GroupMessageTrigger(pluginConfig.groupMessageTriggerCount);
56
60
  this.history = new history_1.MessageHistory(pluginConfig.routeTtl);
57
61
  this.routes = new routes_1.RouteRegistry(pluginConfig.routeTtl);
58
62
  this.transport = new transport_1.MaimTransport(ctx, pluginConfig, this.apiKey, {
@@ -221,6 +225,19 @@ class MaibotService extends koishi_1.Service {
221
225
  this.bridge.lastMessageId = String(session.messageId || session.id || '');
222
226
  this.logMessageDetail(`koishi message received: ${(0, logging_1.describeSession)(session)}`);
223
227
  try {
228
+ const route = this.routes.remember(session);
229
+ const trigger = this.groupTrigger.test(session, route);
230
+ if (!trigger.shouldForward) {
231
+ this.history.rememberSession(session, route);
232
+ this.bridge.groupTriggerSkipped += 1;
233
+ this.bridge.lastGroupTriggerCount = trigger.count;
234
+ this.bridge.lastGroupTriggerThreshold = trigger.threshold;
235
+ this.bridge.lastRouteId = route.routeId;
236
+ this.logMessageDetail(`koishi -> maimai gated: route=${route.routeId} count=${trigger.count}/${trigger.threshold} ${(0, logging_1.describeSession)(session)}`);
237
+ if (this.pluginConfig.messageMode === 'exclusive')
238
+ return '';
239
+ return next();
240
+ }
224
241
  if (this.transportState !== 'connected') {
225
242
  const error = `bridge is not connected: state=${this.transportState}`;
226
243
  this.bridge.sendFailed += 1;
@@ -232,20 +249,15 @@ class MaibotService extends koishi_1.Service {
232
249
  return '';
233
250
  return next();
234
251
  }
235
- const route = this.routes.remember(session);
236
- const replyContext = this.history.resolveReplyContext(session, route);
237
- const message = await (0, convert_1.sessionToMaimMessage)(session, route, this.apiKey, {
238
- resolveImage: (source) => this.resolveImageToBase64(source),
239
- replyContext,
240
- });
241
- this.transport.sendMessage(message);
242
- this.history.rememberSession(session, route);
243
- this.bridge.maimSent += 1;
244
- this.bridge.lastMaimSendAt = Date.now();
245
- this.bridge.lastRouteId = route.routeId;
246
- this.bridge.lastMessageId = message.message_info.message_id;
247
- const replyLog = replyContext ? ` reply=${replyContext.targetMessageId} context=${replyContext.contextCount || 0}` : '';
248
- this.logMessageDetail(`koishi -> maimai forwarded: route=${route.routeId}${replyLog} ${(0, logging_1.describeSegment)(message.message_segment)}`);
252
+ const entries = trigger.entries.length ? trigger.entries : [{ session, route }];
253
+ if (trigger.forceMention) {
254
+ this.logMessageSummary(`group trigger reached: route=${route.routeId} count=${trigger.count}/${trigger.threshold} flushing=${entries.length}`);
255
+ }
256
+ for (let index = 0; index < entries.length; index++) {
257
+ const entry = entries[index];
258
+ const forceMention = !!trigger.forceMention && index === entries.length - 1;
259
+ await this.forwardSessionToMaim(entry.session, entry.route, forceMention);
260
+ }
249
261
  if (this.pluginConfig.messageMode === 'exclusive')
250
262
  return '';
251
263
  }
@@ -257,9 +269,27 @@ class MaibotService extends koishi_1.Service {
257
269
  }
258
270
  return next();
259
271
  }
272
+ async forwardSessionToMaim(session, route, forceMention = false) {
273
+ const replyContext = this.history.resolveReplyContext(session, route);
274
+ const message = await (0, convert_1.sessionToMaimMessage)(session, route, this.apiKey, {
275
+ resolveImage: (source) => this.resolveImageToBase64(source),
276
+ replyContext,
277
+ forceMention,
278
+ });
279
+ this.transport.sendMessage(message);
280
+ this.history.rememberSession(session, route);
281
+ this.bridge.maimSent += 1;
282
+ this.bridge.lastMaimSendAt = Date.now();
283
+ this.bridge.lastRouteId = route.routeId;
284
+ this.bridge.lastMessageId = message.message_info.message_id;
285
+ const replyLog = replyContext ? ` reply=${replyContext.targetMessageId} context=${replyContext.contextCount || 0}` : '';
286
+ const forceLog = forceMention ? ' forced=group-trigger' : '';
287
+ this.logMessageDetail(`koishi -> maimai forwarded: route=${route.routeId}${replyLog}${forceLog} ${(0, logging_1.describeSegment)(message.message_segment)}`);
288
+ }
260
289
  async dispose() {
261
290
  await this.shutdown();
262
291
  this.routes.clear();
292
+ this.groupTrigger.clear();
263
293
  this.history.clear();
264
294
  }
265
295
  logMessageDetail(message) {
package/out/types.d.ts CHANGED
@@ -51,12 +51,15 @@ export interface RuntimeStatus {
51
51
  koishiSent: number;
52
52
  routeMissed: number;
53
53
  sendFailed: number;
54
+ groupTriggerSkipped: number;
54
55
  lastKoishiMessageAt?: number;
55
56
  lastMaimSendAt?: number;
56
57
  lastMaimMessageAt?: number;
57
58
  lastKoishiSendAt?: number;
58
59
  lastRouteId?: string;
59
60
  lastMessageId?: string;
61
+ lastGroupTriggerCount?: number;
62
+ lastGroupTriggerThreshold?: number;
60
63
  lastError?: string;
61
64
  };
62
65
  webui: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-mai-bridge",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "将 maibot 作为 docker 托管的消息回复后端运行,使用 mai.ko 桥接消息收发。",
5
5
  "author": "Jelly10086 <wow40469600@gmail.com>",
6
6
  "contributors": [
@@ -7,6 +7,7 @@ const PLATFORM = 'koishi'
7
7
  export interface SessionToMaimOptions {
8
8
  resolveImage?: (source: string) => Awaitable<string | undefined>
9
9
  replyContext?: ReplyContext
10
+ forceMention?: boolean
10
11
  }
11
12
 
12
13
  export interface ReplyContext {
@@ -204,6 +205,8 @@ export async function sessionToMaimMessage(
204
205
  const messageId = String(session.messageId || session.id || `koishi-${Date.now()}`)
205
206
  const senderInfo = buildSenderInfo(session)
206
207
  const atBot = isAtBot(session)
208
+ const forceMention = !!options.forceMention
209
+ const mentioned = atBot || forceMention
207
210
  const replyContext = options.replyContext
208
211
  return {
209
212
  message_info: {
@@ -225,8 +228,9 @@ export async function sessionToMaimMessage(
225
228
  koishi_reply_to_message_id: replyContext?.targetMessageId,
226
229
  koishi_reply_context_count: replyContext?.contextCount,
227
230
  koishi_is_direct: !!session.isDirect,
228
- at_bot: atBot,
229
- is_mentioned: atBot,
231
+ koishi_group_trigger_forced: forceMention,
232
+ at_bot: mentioned,
233
+ is_mentioned: mentioned,
230
234
  },
231
235
  format_info: {
232
236
  content_format: ['text', 'image'],
@@ -0,0 +1,53 @@
1
+ import type { Session } from 'koishi'
2
+ import type { KoishiRoute } from '../types'
3
+
4
+ export interface GroupTriggerResult {
5
+ shouldForward: boolean
6
+ count: number
7
+ threshold: number
8
+ key?: string
9
+ entries: GroupTriggerEntry[]
10
+ forceMention?: boolean
11
+ }
12
+
13
+ export interface GroupTriggerEntry {
14
+ session: Session
15
+ route: KoishiRoute
16
+ }
17
+
18
+ export class GroupMessageTrigger {
19
+ private pending = new Map<string, GroupTriggerEntry[]>()
20
+
21
+ constructor(private threshold: number) {}
22
+
23
+ test(session: Session, route: KoishiRoute): GroupTriggerResult {
24
+ const threshold = Math.max(1, Math.floor(this.threshold || 1))
25
+ const entry = { session, route }
26
+ if (threshold <= 1 || session.isDirect || !route.channelId) {
27
+ return { shouldForward: true, count: 1, threshold, entries: [entry] }
28
+ }
29
+
30
+ const key = this.createKey(route)
31
+ const entries = [...this.pending.get(key) || [], entry]
32
+ if (entries.length >= threshold) {
33
+ this.pending.delete(key)
34
+ return { shouldForward: true, count: entries.length, threshold, key, entries, forceMention: true }
35
+ }
36
+
37
+ this.pending.set(key, entries)
38
+ return { shouldForward: false, count: entries.length, threshold, key, entries: [] }
39
+ }
40
+
41
+ clear() {
42
+ this.pending.clear()
43
+ }
44
+
45
+ private createKey(route: KoishiRoute) {
46
+ return [
47
+ route.platform,
48
+ route.botSelfId,
49
+ route.guildId || '',
50
+ route.channelId,
51
+ ].join(':')
52
+ }
53
+ }
@@ -158,6 +158,12 @@ export class MaibotPrepareManager {
158
158
  throw new Error(`blocked: bundled patch not found: ${patchPath}`)
159
159
  }
160
160
  if (patchState === 'conflict') {
161
+ const refreshed = await this.refreshManagedPatch(patchPath)
162
+ if (refreshed === 'applied') return
163
+ if (refreshed === 'pending') {
164
+ await this.applyPatch(patchPath)
165
+ return
166
+ }
161
167
  throw new Error('blocked: bundled patch cannot be applied cleanly; check MaiBot ref or update maimai-koishi.patch')
162
168
  }
163
169
  if (patchState === 'applied') {
@@ -166,6 +172,10 @@ export class MaibotPrepareManager {
166
172
  return
167
173
  }
168
174
 
175
+ await this.applyPatch(patchPath)
176
+ }
177
+
178
+ private async applyPatch(patchPath: string) {
169
179
  this.pushLog(`applying bundled patch: ${patchPath}`)
170
180
  await this.git(['apply', patchPath], this.root)
171
181
  this.patchApplied = true
@@ -180,6 +190,68 @@ export class MaibotPrepareManager {
180
190
  return 'conflict'
181
191
  }
182
192
 
193
+ private async refreshManagedPatch(patchPath: string): Promise<'pending' | 'applied' | undefined> {
194
+ const marker = this.readMarker()
195
+ if (!marker?.patchApplied) return
196
+ if (marker.gitUrl !== this.config.maibotGitUrl || marker.gitRef !== this.config.maibotGitRef) return
197
+ if (!marker.patchChecksum || marker.patchChecksum === this.patchChecksum) return
198
+
199
+ const patchPaths = new Set(this.listPatchPaths(patchPath))
200
+ const dirtyFiles = await this.listDirtyTrackedFiles()
201
+ if (!dirtyFiles.length) return
202
+
203
+ const foreignFiles = dirtyFiles.filter((file) => !patchPaths.has(file))
204
+ if (foreignFiles.length) {
205
+ throw new Error(`blocked: MaiBot has local changes outside bundled patch: ${foreignFiles.slice(0, 5).join(', ')}`)
206
+ }
207
+
208
+ this.pushLog('refreshing managed bundled patch')
209
+ await this.git(['restore', '--source', 'HEAD', '--', ...dirtyFiles], this.root)
210
+
211
+ const patchState = await this.checkPatchState(patchPath)
212
+ if (patchState === 'pending') return 'pending'
213
+ if (patchState === 'applied') {
214
+ this.patchApplied = true
215
+ this.pushLog('bundled patch already applied')
216
+ return 'applied'
217
+ }
218
+ throw new Error('blocked: bundled patch cannot be refreshed cleanly; check MaiBot ref or update maimai-koishi.patch')
219
+ }
220
+
221
+ private readMarker(): PrepareMarker | undefined {
222
+ try {
223
+ const marker = JSON.parse(readFileSync(join(this.root, '.mai-ko-prepare.json'), 'utf8')) as PrepareMarker
224
+ if (!marker || typeof marker !== 'object') return
225
+ return marker
226
+ } catch {
227
+ return
228
+ }
229
+ }
230
+
231
+ private listPatchPaths(patchPath: string) {
232
+ const paths = new Set<string>()
233
+ const patch = readFileSync(patchPath, 'utf8')
234
+ for (const line of patch.split(/\r?\n/)) {
235
+ const match = /^diff --git a\/(.+) b\/(.+)$/.exec(line)
236
+ if (!match) continue
237
+ if (match[2] !== '/dev/null') paths.add(match[2])
238
+ }
239
+ return [...paths]
240
+ }
241
+
242
+ private async listDirtyTrackedFiles() {
243
+ const result = await this.git(['status', '--porcelain', '--untracked-files=no'], this.root, true, false)
244
+ if (result.code !== 0) return []
245
+ return result.stdout
246
+ .split(/\r?\n/)
247
+ .map((line) => {
248
+ if (!line.trim()) return ''
249
+ const path = line.slice(3)
250
+ return path.includes(' -> ') ? path.split(' -> ').pop()! : path
251
+ })
252
+ .filter(Boolean)
253
+ }
254
+
183
255
  private async readCommit() {
184
256
  const result = await this.git(['rev-parse', 'HEAD'], this.root, true, false)
185
257
  return result.code === 0 ? result.stdout.trim() : undefined
package/src/config.ts CHANGED
@@ -24,6 +24,7 @@ export interface Config {
24
24
  showWebuiToken: boolean
25
25
  messageMode: MessageMode
26
26
  commandPrefix: string
27
+ groupMessageTriggerCount: number
27
28
  imageDownloadEnabled: boolean
28
29
  imageDownloadTimeout: number
29
30
  imageDownloadMaxBytes: number
@@ -92,6 +93,7 @@ export const Config: Schema<Config> = Schema.intersect([
92
93
  Schema.const('command').description('命令:仅命中指令前缀时转发。'),
93
94
  ]).default('coexist').description('消息转发模式。'),
94
95
  commandPrefix: Schema.string().default('mai.ko').description('command 模式下触发 mai.ko 的文本前缀。'),
96
+ groupMessageTriggerCount: Schema.number().min(1).default(1).description('同一群聊累计达到多少条消息后批量转发并强制触发 mai.ko 思考;1 表示每条群消息都转发。私聊不受影响。'),
95
97
  imageDownloadEnabled: Schema.boolean().default(true).description('转发图片消息前由 Koishi 下载图片并转为 mai.ko 可识别的 base64 图片段。'),
96
98
  imageDownloadTimeout: Schema.number().min(1000).default(10000).description('下载单张入站图片的超时时间,单位毫秒。'),
97
99
  imageDownloadMaxBytes: Schema.number().min(1024).default(10 * 1024 * 1024).description('允许转发给 mai.ko 的单张图片最大字节数。'),
package/src/service.ts CHANGED
@@ -3,6 +3,7 @@ import type { Config } from './config'
3
3
  import { getFallbackRouteHints, getRouteIdFromMaim, maimMessageToFragment, sessionToMaimMessage, shouldForwardSession } from './bridge/convert'
4
4
  import { MaibotDockerManager } from './bridge/docker'
5
5
  import { ExternalLogForwarder } from './bridge/external-logs'
6
+ import { GroupMessageTrigger } from './bridge/group-trigger'
6
7
  import { MessageHistory } from './bridge/history'
7
8
  import { MaibotPrepareManager } from './bridge/prepare'
8
9
  import { MaibotProcessManager, createApiKey } from './bridge/process'
@@ -19,6 +20,7 @@ export class MaibotService extends Service {
19
20
  private docker: MaibotDockerManager
20
21
  private process: MaibotProcessManager
21
22
  private externalLogs: ExternalLogForwarder
23
+ private groupTrigger: GroupMessageTrigger
22
24
  private history: MessageHistory
23
25
  private routes: RouteRegistry
24
26
  private transport: MaimTransport
@@ -41,6 +43,7 @@ export class MaibotService extends Service {
41
43
  koishiSent: 0,
42
44
  routeMissed: 0,
43
45
  sendFailed: 0,
46
+ groupTriggerSkipped: 0,
44
47
  }
45
48
 
46
49
  constructor(public ctx: Context, private pluginConfig: Config) {
@@ -50,6 +53,7 @@ export class MaibotService extends Service {
50
53
  this.docker = new MaibotDockerManager(pluginConfig, this.apiKey)
51
54
  this.process = new MaibotProcessManager(ctx, pluginConfig, this.apiKey)
52
55
  this.externalLogs = new ExternalLogForwarder(ctx, pluginConfig, [this.apiKey, pluginConfig.apiKey])
56
+ this.groupTrigger = new GroupMessageTrigger(pluginConfig.groupMessageTriggerCount)
53
57
  this.history = new MessageHistory(pluginConfig.routeTtl)
54
58
  this.routes = new RouteRegistry(pluginConfig.routeTtl)
55
59
  this.transport = new MaimTransport(ctx, pluginConfig, this.apiKey, {
@@ -227,6 +231,19 @@ export class MaibotService extends Service {
227
231
  this.logMessageDetail(`koishi message received: ${describeSession(session)}`)
228
232
 
229
233
  try {
234
+ const route = this.routes.remember(session)
235
+ const trigger = this.groupTrigger.test(session, route)
236
+ if (!trigger.shouldForward) {
237
+ this.history.rememberSession(session, route)
238
+ this.bridge.groupTriggerSkipped += 1
239
+ this.bridge.lastGroupTriggerCount = trigger.count
240
+ this.bridge.lastGroupTriggerThreshold = trigger.threshold
241
+ this.bridge.lastRouteId = route.routeId
242
+ this.logMessageDetail(`koishi -> maimai gated: route=${route.routeId} count=${trigger.count}/${trigger.threshold} ${describeSession(session)}`)
243
+ if (this.pluginConfig.messageMode === 'exclusive') return ''
244
+ return next()
245
+ }
246
+
230
247
  if (this.transportState !== 'connected') {
231
248
  const error = `bridge is not connected: state=${this.transportState}`
232
249
  this.bridge.sendFailed += 1
@@ -237,20 +254,17 @@ export class MaibotService extends Service {
237
254
  return next()
238
255
  }
239
256
 
240
- const route = this.routes.remember(session)
241
- const replyContext = this.history.resolveReplyContext(session, route)
242
- const message = await sessionToMaimMessage(session, route, this.apiKey, {
243
- resolveImage: (source) => this.resolveImageToBase64(source),
244
- replyContext,
245
- })
246
- this.transport.sendMessage(message)
247
- this.history.rememberSession(session, route)
248
- this.bridge.maimSent += 1
249
- this.bridge.lastMaimSendAt = Date.now()
250
- this.bridge.lastRouteId = route.routeId
251
- this.bridge.lastMessageId = message.message_info.message_id
252
- const replyLog = replyContext ? ` reply=${replyContext.targetMessageId} context=${replyContext.contextCount || 0}` : ''
253
- this.logMessageDetail(`koishi -> maimai forwarded: route=${route.routeId}${replyLog} ${describeSegment(message.message_segment)}`)
257
+ const entries = trigger.entries.length ? trigger.entries : [{ session, route }]
258
+ if (trigger.forceMention) {
259
+ this.logMessageSummary(`group trigger reached: route=${route.routeId} count=${trigger.count}/${trigger.threshold} flushing=${entries.length}`)
260
+ }
261
+
262
+ for (let index = 0; index < entries.length; index++) {
263
+ const entry = entries[index]
264
+ const forceMention = !!trigger.forceMention && index === entries.length - 1
265
+ await this.forwardSessionToMaim(entry.session, entry.route, forceMention)
266
+ }
267
+
254
268
  if (this.pluginConfig.messageMode === 'exclusive') return ''
255
269
  } catch (error) {
256
270
  const message = error instanceof Error ? error.message : String(error)
@@ -262,9 +276,28 @@ export class MaibotService extends Service {
262
276
  return next()
263
277
  }
264
278
 
279
+ private async forwardSessionToMaim(session: Session, route: ReturnType<RouteRegistry['remember']>, forceMention = false) {
280
+ const replyContext = this.history.resolveReplyContext(session, route)
281
+ const message = await sessionToMaimMessage(session, route, this.apiKey, {
282
+ resolveImage: (source) => this.resolveImageToBase64(source),
283
+ replyContext,
284
+ forceMention,
285
+ })
286
+ this.transport.sendMessage(message)
287
+ this.history.rememberSession(session, route)
288
+ this.bridge.maimSent += 1
289
+ this.bridge.lastMaimSendAt = Date.now()
290
+ this.bridge.lastRouteId = route.routeId
291
+ this.bridge.lastMessageId = message.message_info.message_id
292
+ const replyLog = replyContext ? ` reply=${replyContext.targetMessageId} context=${replyContext.contextCount || 0}` : ''
293
+ const forceLog = forceMention ? ' forced=group-trigger' : ''
294
+ this.logMessageDetail(`koishi -> maimai forwarded: route=${route.routeId}${replyLog}${forceLog} ${describeSegment(message.message_segment)}`)
295
+ }
296
+
265
297
  async dispose() {
266
298
  await this.shutdown()
267
299
  this.routes.clear()
300
+ this.groupTrigger.clear()
268
301
  this.history.clear()
269
302
  }
270
303
 
package/src/types.ts CHANGED
@@ -53,12 +53,15 @@ export interface RuntimeStatus {
53
53
  koishiSent: number
54
54
  routeMissed: number
55
55
  sendFailed: number
56
+ groupTriggerSkipped: number
56
57
  lastKoishiMessageAt?: number
57
58
  lastMaimSendAt?: number
58
59
  lastMaimMessageAt?: number
59
60
  lastKoishiSendAt?: number
60
61
  lastRouteId?: string
61
62
  lastMessageId?: string
63
+ lastGroupTriggerCount?: number
64
+ lastGroupTriggerThreshold?: number
62
65
  lastError?: string
63
66
  }
64
67
  webui: {