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 +2 -0
- package/out/bridge/convert.d.ts +1 -0
- package/out/bridge/convert.js +5 -2
- package/out/bridge/group-trigger.d.ts +22 -0
- package/out/bridge/group-trigger.js +37 -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 +1 -0
- package/out/service.d.ts +2 -0
- package/out/service.js +44 -14
- package/out/types.d.ts +3 -0
- package/package.json +1 -1
- package/src/bridge/convert.ts +6 -2
- package/src/bridge/group-trigger.ts +53 -0
- package/src/bridge/prepare.ts +72 -0
- package/src/config.ts +2 -0
- package/src/service.ts +47 -14
- 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.d.ts
CHANGED
|
@@ -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;
|
package/out/bridge/convert.js
CHANGED
|
@@ -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
|
-
|
|
206
|
-
|
|
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;
|
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
|
@@ -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
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
package/src/bridge/convert.ts
CHANGED
|
@@ -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
|
-
|
|
229
|
-
|
|
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
|
+
}
|
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
|
|
@@ -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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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: {
|