koishi-plugin-mai-bridge 0.1.0 → 0.1.2

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
 
@@ -193,6 +193,7 @@ async function sessionToMaimMessage(session, route, apiKey, options = {}) {
193
193
  additional_config: {
194
194
  koishi_route_id: route.routeId,
195
195
  koishi_self_id: session.selfId,
196
+ platform_io_account_id: session.selfId,
196
197
  koishi_platform: session.platform,
197
198
  koishi_channel_id: session.channelId || '',
198
199
  koishi_guild_id: session.guildId,
@@ -20,6 +20,9 @@ export declare class MaibotDockerManager {
20
20
  private buildEnv;
21
21
  private volumeMap;
22
22
  private ensureVolumeDirs;
23
+ private resolveDockerVolumes;
24
+ private resolveDockerHostPath;
25
+ private resolveDockerNetwork;
23
26
  private containerExists;
24
27
  private removeContainer;
25
28
  private docker;
@@ -106,12 +106,14 @@ class MaibotDockerManager {
106
106
  throw new Error(`blocked: ${agreements.reason}`);
107
107
  }
108
108
  this.ensureVolumeDirs();
109
- const args = this.createRunArgs(agreements.env);
109
+ const dockerNetwork = await this.resolveDockerNetwork();
110
+ const dockerVolumes = await this.resolveDockerVolumes();
111
+ const args = this.createRunArgs(agreements.env, dockerNetwork, dockerVolumes);
110
112
  this.pushLog(`creating Docker container: ${this.config.dockerContainerName}`);
111
113
  const result = await this.docker(args);
112
114
  (0, spawn_1.assertSuccessful)(result, 'docker create');
113
115
  }
114
- createRunArgs(agreementEnv) {
116
+ createRunArgs(agreementEnv, dockerNetwork = this.config.dockerNetwork, dockerVolumes = this.volumeMap()) {
115
117
  const args = [
116
118
  'create',
117
119
  '--name',
@@ -119,8 +121,8 @@ class MaibotDockerManager {
119
121
  '--restart',
120
122
  'unless-stopped',
121
123
  ];
122
- if (this.config.dockerNetwork) {
123
- args.push('--network', this.config.dockerNetwork);
124
+ if (dockerNetwork) {
125
+ args.push('--network', dockerNetwork);
124
126
  }
125
127
  if (this.config.dockerPublishedWebuiPort > 0) {
126
128
  args.push('-p', `${this.config.dockerPublishedWebuiPort}:${this.config.webuiPort}`);
@@ -129,7 +131,7 @@ class MaibotDockerManager {
129
131
  for (const [key, value] of Object.entries(env)) {
130
132
  args.push('-e', `${key}=${value}`);
131
133
  }
132
- for (const [source, target] of this.volumeMap()) {
134
+ for (const [source, target] of dockerVolumes) {
133
135
  args.push('-v', `${source}:${target}`);
134
136
  }
135
137
  args.push(this.config.dockerImageName);
@@ -167,8 +169,74 @@ class MaibotDockerManager {
167
169
  (0, fs_1.mkdirSync)(source, { recursive: true });
168
170
  }
169
171
  }
172
+ async resolveDockerVolumes() {
173
+ const volumes = [];
174
+ for (const [source, target] of this.volumeMap()) {
175
+ volumes.push([await this.resolveDockerHostPath(source), target]);
176
+ }
177
+ return volumes;
178
+ }
179
+ async resolveDockerHostPath(containerPath) {
180
+ const hostname = process.env.HOSTNAME;
181
+ if (!hostname)
182
+ return containerPath;
183
+ const result = await this.run(this.config.dockerCommand, [
184
+ 'container',
185
+ 'inspect',
186
+ hostname,
187
+ '--format',
188
+ '{{json .Mounts}}',
189
+ ], false);
190
+ if (result.code !== 0)
191
+ return containerPath;
192
+ try {
193
+ const mounts = JSON.parse(result.stdout);
194
+ const normalizedPath = (0, path_1.resolve)(containerPath);
195
+ const candidates = mounts
196
+ .filter((mount) => mount.Source && mount.Destination)
197
+ .map((mount) => ({
198
+ source: mount.Source,
199
+ destination: (0, path_1.resolve)(mount.Destination),
200
+ }))
201
+ .sort((a, b) => b.destination.length - a.destination.length);
202
+ for (const mount of candidates) {
203
+ const rest = (0, path_1.relative)(mount.destination, normalizedPath);
204
+ if (rest === '' || (rest && !rest.startsWith('..') && !(0, path_1.isAbsolute)(rest))) {
205
+ return (0, path_1.join)(mount.source, rest);
206
+ }
207
+ }
208
+ }
209
+ catch (error) {
210
+ this.logger.debug(error);
211
+ }
212
+ return containerPath;
213
+ }
214
+ async resolveDockerNetwork() {
215
+ if (this.config.dockerNetwork)
216
+ return this.config.dockerNetwork;
217
+ const hostname = process.env.HOSTNAME;
218
+ if (!hostname)
219
+ return '';
220
+ const result = await this.run(this.config.dockerCommand, [
221
+ 'container',
222
+ 'inspect',
223
+ hostname,
224
+ '--format',
225
+ '{{range $name, $_ := .NetworkSettings.Networks}}{{println $name}}{{end}}',
226
+ ], false);
227
+ if (result.code !== 0)
228
+ return '';
229
+ const network = result.stdout
230
+ .split(/\r?\n/)
231
+ .map((line) => line.trim())
232
+ .find((line) => line && !['bridge', 'host', 'none'].includes(line));
233
+ if (!network)
234
+ return '';
235
+ this.pushLog(`using Docker network from Koishi container: ${network}`);
236
+ return network;
237
+ }
170
238
  async containerExists() {
171
- const result = await this.run(this.config.dockerCommand, ['inspect', this.config.dockerContainerName], false);
239
+ const result = await this.run(this.config.dockerCommand, ['container', 'inspect', this.config.dockerContainerName], false);
172
240
  return result.code === 0;
173
241
  }
174
242
  async removeContainer(force = false) {
@@ -0,0 +1,16 @@
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
+ }
9
+ export declare class GroupMessageTrigger {
10
+ private threshold;
11
+ private counters;
12
+ constructor(threshold: number);
13
+ test(session: Session, route: KoishiRoute): GroupTriggerResult;
14
+ clear(): void;
15
+ private createKey;
16
+ }
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GroupMessageTrigger = void 0;
4
+ class GroupMessageTrigger {
5
+ threshold;
6
+ counters = 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
+ if (threshold <= 1 || session.isDirect || !route.channelId) {
13
+ return { shouldForward: true, count: 1, threshold };
14
+ }
15
+ const key = this.createKey(route);
16
+ const count = (this.counters.get(key) || 0) + 1;
17
+ if (count >= threshold) {
18
+ this.counters.delete(key);
19
+ return { shouldForward: true, count, threshold, key };
20
+ }
21
+ this.counters.set(key, count);
22
+ return { shouldForward: false, count, threshold, key };
23
+ }
24
+ clear() {
25
+ this.counters.clear();
26
+ }
27
+ createKey(route) {
28
+ return [
29
+ route.platform,
30
+ route.botSelfId,
31
+ route.guildId || '',
32
+ route.channelId,
33
+ ].join(':');
34
+ }
35
+ }
36
+ 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
@@ -19,7 +19,7 @@ exports.Config = koishi_1.Schema.intersect([
19
19
  pythonCommand: koishi_1.Schema.string().default('python3').description('用于启动 mai.ko 的 Python 命令。'),
20
20
  entryScript: koishi_1.Schema.string().default('bot.py').description('相对于 mai.ko 根目录的启动脚本。'),
21
21
  autoStart: koishi_1.Schema.boolean().default(true).description('Koishi ready 后自动启动 mai.ko。'),
22
- acceptMaibotAgreements: koishi_1.Schema.boolean().default(false).description('是否由插件注入 EULA/Privacy 确认哈希。默认关闭。'),
22
+ acceptMaibotAgreements: koishi_1.Schema.boolean().default(false).description('确认同意 MaiBot 的 EULA/Privacy'),
23
23
  }).description('mai.ko 进程'),
24
24
  koishi_1.Schema.object({
25
25
  apiHost: koishi_1.Schema.string().default('maimai-ko').description('Koishi 连接 maim_message 新版 API Server 的地址。Docker 模式默认使用容器名。'),
@@ -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/console.js CHANGED
@@ -5,7 +5,7 @@ const path_1 = require("path");
5
5
  function registerConsole(ctx, config) {
6
6
  if (!config.enableConsole)
7
7
  return;
8
- ctx.inject(['console'], (ctx) => {
8
+ ctx.inject(['console', 'maimai'], (ctx) => {
9
9
  const options = { authority: config.commandAuthority };
10
10
  ctx.console.addListener('mai-ko/status', () => ctx.maimai.getStatus(), options);
11
11
  ctx.console.addListener('mai-ko/start', () => ctx.maimai.launch(), options);
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;
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,7 +249,6 @@ class MaibotService extends koishi_1.Service {
232
249
  return '';
233
250
  return next();
234
251
  }
235
- const route = this.routes.remember(session);
236
252
  const replyContext = this.history.resolveReplyContext(session, route);
237
253
  const message = await (0, convert_1.sessionToMaimMessage)(session, route, this.apiKey, {
238
254
  resolveImage: (source) => this.resolveImageToBase64(source),
@@ -260,6 +276,7 @@ class MaibotService extends koishi_1.Service {
260
276
  async dispose() {
261
277
  await this.shutdown();
262
278
  this.routes.clear();
279
+ this.groupTrigger.clear();
263
280
  this.history.clear();
264
281
  }
265
282
  logMessageDetail(message) {
@@ -503,7 +520,10 @@ class MaibotService extends koishi_1.Service {
503
520
  if (host === '::')
504
521
  host = '::1';
505
522
  const normalizedHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
506
- return `http://${normalizedHost}:${this.pluginConfig.webuiPort}`;
523
+ const port = this.pluginConfig.processMode === 'docker' && this.pluginConfig.dockerPublishedWebuiPort > 0
524
+ ? this.pluginConfig.dockerPublishedWebuiPort
525
+ : this.pluginConfig.webuiPort;
526
+ return `http://${normalizedHost}:${port}`;
507
527
  }
508
528
  async handleMaimMessage(message) {
509
529
  this.bridge.maimReceived += 1;
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.0",
3
+ "version": "0.1.2",
4
4
  "description": "将 maibot 作为 docker 托管的消息回复后端运行,使用 mai.ko 桥接消息收发。",
5
5
  "author": "Jelly10086 <wow40469600@gmail.com>",
6
6
  "contributors": [
@@ -551,3 +551,48 @@ index 23af394..41860ae 100644
551
551
  @staticmethod
552
552
  def _first_non_empty_string(*values: Any) -> str:
553
553
  """返回第一个非空字符串表示。"""
554
+ diff --git a/src/services/send_service.py b/src/services/send_service.py
555
+ index 6e8aea1..f63933a 100644
556
+ --- a/src/services/send_service.py
557
+ +++ b/src/services/send_service.py
558
+ @@ -288,6 +288,31 @@ def _inherit_platform_io_route_metadata(target_stream: BotChatSession) -> Dict[s
559
+ return inherited_metadata
560
+
561
+
562
+ +def _get_target_stream_bot_account(target_stream: BotChatSession) -> str:
563
+ + """获取目标会话对应的机器人账号。"""
564
+ +
565
+ + bot_account = get_bot_account(target_stream.platform)
566
+ + if bot_account:
567
+ + return bot_account
568
+ +
569
+ + context_message = target_stream.context.message if target_stream.context else None
570
+ + if context_message is None:
571
+ + return ""
572
+ +
573
+ + additional_config = context_message.message_info.additional_config
574
+ + if not isinstance(additional_config, dict):
575
+ + return ""
576
+ +
577
+ + for key in (*RouteKeyFactory.ACCOUNT_ID_KEYS, "koishi_self_id"):
578
+ + value = additional_config.get(key)
579
+ + if value is None:
580
+ + continue
581
+ + normalized_value = str(value).strip()
582
+ + if normalized_value:
583
+ + return normalized_value
584
+ + return ""
585
+ +
586
+ +
587
+ def _build_binary_component_from_base64(component_type: str, raw_data: str) -> StandardMessageComponents:
588
+ """根据 Base64 数据构造二进制消息组件。
589
+
590
+ @@ -538,7 +563,7 @@ def _build_outbound_session_message(
591
+ logger.error(f"[SendService] 未找到聊天流: {stream_id}")
592
+ return None
593
+
594
+ - bot_user_id = get_bot_account(target_stream.platform)
595
+ + bot_user_id = _get_target_stream_bot_account(target_stream)
596
+ if not bot_user_id:
597
+ logger.error(f"[SendService] 平台 {target_stream.platform} 未配置机器人账号,无法发送消息")
598
+ return None
@@ -216,6 +216,7 @@ export async function sessionToMaimMessage(
216
216
  additional_config: {
217
217
  koishi_route_id: route.routeId,
218
218
  koishi_self_id: session.selfId,
219
+ platform_io_account_id: session.selfId,
219
220
  koishi_platform: session.platform,
220
221
  koishi_channel_id: session.channelId || '',
221
222
  koishi_guild_id: session.guildId,
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync } from 'fs'
2
- import { join } from 'path'
2
+ import { isAbsolute, join, relative, resolve } from 'path'
3
3
  import { Logger } from 'koishi'
4
4
  import type { Config } from '../config'
5
5
  import type { DockerState, RuntimeStatus } from '../types'
@@ -8,6 +8,13 @@ import { assertSuccessful, runCommand, type CommandResult } from '../utils/spawn
8
8
  import { redactSecret } from '../utils/command'
9
9
  import { prepareAgreementEnv } from './agreements'
10
10
 
11
+ interface DockerMount {
12
+ Source?: string
13
+ Destination?: string
14
+ }
15
+
16
+ type DockerVolume = readonly [string, string]
17
+
11
18
  export class MaibotDockerManager {
12
19
  private logger = new Logger('mai.ko/docker')
13
20
  private state: DockerState = 'idle'
@@ -110,13 +117,19 @@ export class MaibotDockerManager {
110
117
  }
111
118
 
112
119
  this.ensureVolumeDirs()
113
- const args = this.createRunArgs(agreements.env)
120
+ const dockerNetwork = await this.resolveDockerNetwork()
121
+ const dockerVolumes = await this.resolveDockerVolumes()
122
+ const args = this.createRunArgs(agreements.env, dockerNetwork, dockerVolumes)
114
123
  this.pushLog(`creating Docker container: ${this.config.dockerContainerName}`)
115
124
  const result = await this.docker(args)
116
125
  assertSuccessful(result, 'docker create')
117
126
  }
118
127
 
119
- private createRunArgs(agreementEnv: Record<string, string>) {
128
+ private createRunArgs(
129
+ agreementEnv: Record<string, string>,
130
+ dockerNetwork = this.config.dockerNetwork,
131
+ dockerVolumes: readonly DockerVolume[] = this.volumeMap(),
132
+ ) {
120
133
  const args = [
121
134
  'create',
122
135
  '--name',
@@ -125,8 +138,8 @@ export class MaibotDockerManager {
125
138
  'unless-stopped',
126
139
  ]
127
140
 
128
- if (this.config.dockerNetwork) {
129
- args.push('--network', this.config.dockerNetwork)
141
+ if (dockerNetwork) {
142
+ args.push('--network', dockerNetwork)
130
143
  }
131
144
 
132
145
  if (this.config.dockerPublishedWebuiPort > 0) {
@@ -138,7 +151,7 @@ export class MaibotDockerManager {
138
151
  args.push('-e', `${key}=${value}`)
139
152
  }
140
153
 
141
- for (const [source, target] of this.volumeMap()) {
154
+ for (const [source, target] of dockerVolumes) {
142
155
  args.push('-v', `${source}:${target}`)
143
156
  }
144
157
 
@@ -181,8 +194,76 @@ export class MaibotDockerManager {
181
194
  }
182
195
  }
183
196
 
197
+ private async resolveDockerVolumes() {
198
+ const volumes: DockerVolume[] = []
199
+ for (const [source, target] of this.volumeMap()) {
200
+ volumes.push([await this.resolveDockerHostPath(source), target])
201
+ }
202
+ return volumes
203
+ }
204
+
205
+ private async resolveDockerHostPath(containerPath: string) {
206
+ const hostname = process.env.HOSTNAME
207
+ if (!hostname) return containerPath
208
+
209
+ const result = await this.run(this.config.dockerCommand, [
210
+ 'container',
211
+ 'inspect',
212
+ hostname,
213
+ '--format',
214
+ '{{json .Mounts}}',
215
+ ], false)
216
+ if (result.code !== 0) return containerPath
217
+
218
+ try {
219
+ const mounts = JSON.parse(result.stdout) as DockerMount[]
220
+ const normalizedPath = resolve(containerPath)
221
+ const candidates = mounts
222
+ .filter((mount) => mount.Source && mount.Destination)
223
+ .map((mount) => ({
224
+ source: mount.Source!,
225
+ destination: resolve(mount.Destination!),
226
+ }))
227
+ .sort((a, b) => b.destination.length - a.destination.length)
228
+
229
+ for (const mount of candidates) {
230
+ const rest = relative(mount.destination, normalizedPath)
231
+ if (rest === '' || (rest && !rest.startsWith('..') && !isAbsolute(rest))) {
232
+ return join(mount.source, rest)
233
+ }
234
+ }
235
+ } catch (error) {
236
+ this.logger.debug(error)
237
+ }
238
+
239
+ return containerPath
240
+ }
241
+
242
+ private async resolveDockerNetwork() {
243
+ if (this.config.dockerNetwork) return this.config.dockerNetwork
244
+ const hostname = process.env.HOSTNAME
245
+ if (!hostname) return ''
246
+
247
+ const result = await this.run(this.config.dockerCommand, [
248
+ 'container',
249
+ 'inspect',
250
+ hostname,
251
+ '--format',
252
+ '{{range $name, $_ := .NetworkSettings.Networks}}{{println $name}}{{end}}',
253
+ ], false)
254
+ if (result.code !== 0) return ''
255
+
256
+ const network = result.stdout
257
+ .split(/\r?\n/)
258
+ .map((line) => line.trim())
259
+ .find((line) => line && !['bridge', 'host', 'none'].includes(line))
260
+ if (!network) return ''
261
+ this.pushLog(`using Docker network from Koishi container: ${network}`)
262
+ return network
263
+ }
264
+
184
265
  private async containerExists() {
185
- const result = await this.run(this.config.dockerCommand, ['inspect', this.config.dockerContainerName], false)
266
+ const result = await this.run(this.config.dockerCommand, ['container', 'inspect', this.config.dockerContainerName], false)
186
267
  return result.code === 0
187
268
  }
188
269
 
@@ -0,0 +1,45 @@
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
+ }
10
+
11
+ export class GroupMessageTrigger {
12
+ private counters = new Map<string, number>()
13
+
14
+ constructor(private threshold: number) {}
15
+
16
+ test(session: Session, route: KoishiRoute): GroupTriggerResult {
17
+ const threshold = Math.max(1, Math.floor(this.threshold || 1))
18
+ if (threshold <= 1 || session.isDirect || !route.channelId) {
19
+ return { shouldForward: true, count: 1, threshold }
20
+ }
21
+
22
+ const key = this.createKey(route)
23
+ const count = (this.counters.get(key) || 0) + 1
24
+ if (count >= threshold) {
25
+ this.counters.delete(key)
26
+ return { shouldForward: true, count, threshold, key }
27
+ }
28
+
29
+ this.counters.set(key, count)
30
+ return { shouldForward: false, count, threshold, key }
31
+ }
32
+
33
+ clear() {
34
+ this.counters.clear()
35
+ }
36
+
37
+ private createKey(route: KoishiRoute) {
38
+ return [
39
+ route.platform,
40
+ route.botSelfId,
41
+ route.guildId || '',
42
+ route.channelId,
43
+ ].join(':')
44
+ }
45
+ }
@@ -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
@@ -68,7 +69,7 @@ export const Config: Schema<Config> = Schema.intersect([
68
69
  pythonCommand: Schema.string().default('python3').description('用于启动 mai.ko 的 Python 命令。'),
69
70
  entryScript: Schema.string().default('bot.py').description('相对于 mai.ko 根目录的启动脚本。'),
70
71
  autoStart: Schema.boolean().default(true).description('Koishi ready 后自动启动 mai.ko。'),
71
- acceptMaibotAgreements: Schema.boolean().default(false).description('是否由插件注入 EULA/Privacy 确认哈希。默认关闭。'),
72
+ acceptMaibotAgreements: Schema.boolean().default(false).description('确认同意 MaiBot 的 EULA/Privacy'),
72
73
  }).description('mai.ko 进程'),
73
74
  Schema.object({
74
75
  apiHost: Schema.string().default('maimai-ko').description('Koishi 连接 maim_message 新版 API Server 的地址。Docker 模式默认使用容器名。'),
@@ -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/console.ts CHANGED
@@ -20,7 +20,7 @@ declare module '@koishijs/plugin-console' {
20
20
 
21
21
  export function registerConsole(ctx: Context, config: Config) {
22
22
  if (!config.enableConsole) return
23
- ctx.inject(['console'], (ctx) => {
23
+ ctx.inject(['console', 'maimai'], (ctx) => {
24
24
  const options = { authority: config.commandAuthority }
25
25
  ctx.console.addListener('mai-ko/status', () => ctx.maimai.getStatus(), options)
26
26
  ctx.console.addListener('mai-ko/start', () => ctx.maimai.launch(), options)
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,7 +254,6 @@ export class MaibotService extends Service {
237
254
  return next()
238
255
  }
239
256
 
240
- const route = this.routes.remember(session)
241
257
  const replyContext = this.history.resolveReplyContext(session, route)
242
258
  const message = await sessionToMaimMessage(session, route, this.apiKey, {
243
259
  resolveImage: (source) => this.resolveImageToBase64(source),
@@ -265,6 +281,7 @@ export class MaibotService extends Service {
265
281
  async dispose() {
266
282
  await this.shutdown()
267
283
  this.routes.clear()
284
+ this.groupTrigger.clear()
268
285
  this.history.clear()
269
286
  }
270
287
 
@@ -511,7 +528,10 @@ export class MaibotService extends Service {
511
528
  if (host === '0.0.0.0') host = '127.0.0.1'
512
529
  if (host === '::') host = '::1'
513
530
  const normalizedHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host
514
- return `http://${normalizedHost}:${this.pluginConfig.webuiPort}`
531
+ const port = this.pluginConfig.processMode === 'docker' && this.pluginConfig.dockerPublishedWebuiPort > 0
532
+ ? this.pluginConfig.dockerPublishedWebuiPort
533
+ : this.pluginConfig.webuiPort
534
+ return `http://${normalizedHost}:${port}`
515
535
  }
516
536
 
517
537
  private async handleMaimMessage(message: MaimApiMessage) {
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: {