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 +2 -0
- package/out/bridge/convert.js +1 -0
- package/out/bridge/docker.d.ts +3 -0
- package/out/bridge/docker.js +74 -6
- package/out/bridge/group-trigger.d.ts +16 -0
- package/out/bridge/group-trigger.js +36 -0
- package/out/bridge/prepare.d.ts +5 -0
- package/out/bridge/prepare.js +75 -0
- package/out/config.d.ts +1 -0
- package/out/config.js +2 -1
- package/out/console.js +1 -1
- package/out/service.d.ts +1 -0
- package/out/service.js +22 -2
- package/out/types.d.ts +3 -0
- package/package.json +1 -1
- package/patches/maimai-koishi.patch +45 -0
- package/src/bridge/convert.ts +1 -0
- package/src/bridge/docker.ts +88 -7
- package/src/bridge/group-trigger.ts +45 -0
- package/src/bridge/prepare.ts +72 -0
- package/src/config.ts +3 -1
- package/src/console.ts +1 -1
- package/src/service.ts +22 -2
- package/src/types.ts +3 -0
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
|
|
package/out/bridge/convert.js
CHANGED
|
@@ -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,
|
package/out/bridge/docker.d.ts
CHANGED
|
@@ -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;
|
package/out/bridge/docker.js
CHANGED
|
@@ -106,12 +106,14 @@ class MaibotDockerManager {
|
|
|
106
106
|
throw new Error(`blocked: ${agreements.reason}`);
|
|
107
107
|
}
|
|
108
108
|
this.ensureVolumeDirs();
|
|
109
|
-
const
|
|
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 (
|
|
123
|
-
args.push('--network',
|
|
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
|
|
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;
|
package/out/bridge/prepare.d.ts
CHANGED
|
@@ -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;
|
package/out/bridge/prepare.js
CHANGED
|
@@ -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
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('
|
|
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
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
|
-
|
|
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
|
@@ -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
|
package/src/bridge/convert.ts
CHANGED
|
@@ -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,
|
package/src/bridge/docker.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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 (
|
|
129
|
-
args.push('--network',
|
|
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
|
|
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
|
+
}
|
package/src/bridge/prepare.ts
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
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: {
|