koishi-plugin-msg-router 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.d.ts +39 -0
- package/lib/index.js +675 -0
- package/package.json +38 -0
- package/readme.md +165 -0
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Context, Schema } from 'koishi';
|
|
2
|
+
export declare const name = "msg-router";
|
|
3
|
+
export interface ClientRouteConfig {
|
|
4
|
+
name: string;
|
|
5
|
+
mode: 'client';
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
endpoint: string;
|
|
8
|
+
token: string;
|
|
9
|
+
secret: string;
|
|
10
|
+
commands: string[];
|
|
11
|
+
action: string;
|
|
12
|
+
timeout: number;
|
|
13
|
+
heartbeatInterval: number;
|
|
14
|
+
reconnectInterval: number;
|
|
15
|
+
maxConcurrency: number;
|
|
16
|
+
}
|
|
17
|
+
export interface ServerRouteConfig {
|
|
18
|
+
name: string;
|
|
19
|
+
mode: 'server';
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
listenHost: string;
|
|
22
|
+
listenPort: number;
|
|
23
|
+
token: string;
|
|
24
|
+
secret: string;
|
|
25
|
+
commands: string[];
|
|
26
|
+
action: string;
|
|
27
|
+
timeout: number;
|
|
28
|
+
heartbeatInterval: number;
|
|
29
|
+
reconnectInterval: number;
|
|
30
|
+
maxConcurrency: number;
|
|
31
|
+
}
|
|
32
|
+
export type RouteConfig = ClientRouteConfig | ServerRouteConfig;
|
|
33
|
+
export interface Config {
|
|
34
|
+
debug: boolean;
|
|
35
|
+
clientRoutes: ClientRouteConfig[];
|
|
36
|
+
serverRoutes: ServerRouteConfig[];
|
|
37
|
+
}
|
|
38
|
+
export declare const Config: Schema<Config>;
|
|
39
|
+
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.Config = exports.name = void 0;
|
|
7
|
+
exports.apply = apply;
|
|
8
|
+
const ws_1 = __importDefault(require("ws"));
|
|
9
|
+
const koishi_1 = require("koishi");
|
|
10
|
+
exports.name = 'msg-router';
|
|
11
|
+
const CommonRouteConfig = {
|
|
12
|
+
name: koishi_1.Schema.string().default('后端路由').description('这条路由的显示名称'),
|
|
13
|
+
enabled: koishi_1.Schema.boolean().default(true).description('是否启用这条路由'),
|
|
14
|
+
token: koishi_1.Schema.string().role('secret').default('').description('后端鉴权令牌,可留空'),
|
|
15
|
+
secret: koishi_1.Schema.string().role('secret').default('').description('HMAC 签名密钥,可留空'),
|
|
16
|
+
commands: koishi_1.Schema.array(koishi_1.Schema.string()).default([]).description('触发这条路由的命令列表'),
|
|
17
|
+
action: koishi_1.Schema.string().default('msg_router.forward_command').description('发送给后端的 action 名称'),
|
|
18
|
+
timeout: koishi_1.Schema.number().default(10000).description('单次请求超时时间,单位毫秒'),
|
|
19
|
+
heartbeatInterval: koishi_1.Schema.number().default(30000).description('心跳间隔,单位毫秒'),
|
|
20
|
+
reconnectInterval: koishi_1.Schema.number().default(5000).description('断线后重连间隔,单位毫秒'),
|
|
21
|
+
maxConcurrency: koishi_1.Schema.number().min(1).default(16).description('这条路由同时处理的最大并发数'),
|
|
22
|
+
};
|
|
23
|
+
const ClientRouteConfig = koishi_1.Schema.object({
|
|
24
|
+
...CommonRouteConfig,
|
|
25
|
+
mode: koishi_1.Schema.const('client').description('插件作为客户端连接后端'),
|
|
26
|
+
endpoint: koishi_1.Schema.string().default('ws://127.0.0.1:8080').description('OneBot v11 WebSocket 地址'),
|
|
27
|
+
});
|
|
28
|
+
const ServerRouteConfig = koishi_1.Schema.object({
|
|
29
|
+
...CommonRouteConfig,
|
|
30
|
+
mode: koishi_1.Schema.const('server').description('插件作为服务端等待后端连接'),
|
|
31
|
+
listenHost: koishi_1.Schema.string().default('127.0.0.1').description('server 模式下的监听主机'),
|
|
32
|
+
listenPort: koishi_1.Schema.natural().default(8080).description('server 模式下的监听端口'),
|
|
33
|
+
});
|
|
34
|
+
const RouteConfig = koishi_1.Schema.union([
|
|
35
|
+
ClientRouteConfig,
|
|
36
|
+
ServerRouteConfig,
|
|
37
|
+
]).role('table');
|
|
38
|
+
exports.Config = koishi_1.Schema.object({
|
|
39
|
+
debug: koishi_1.Schema.boolean().default(false).description('是否输出调试日志'),
|
|
40
|
+
clientRoutes: koishi_1.Schema.array(ClientRouteConfig)
|
|
41
|
+
.role('table')
|
|
42
|
+
.default([])
|
|
43
|
+
.description('正向 WS 路由列表,插件主动连接后端'),
|
|
44
|
+
serverRoutes: koishi_1.Schema.array(ServerRouteConfig)
|
|
45
|
+
.role('table')
|
|
46
|
+
.default([])
|
|
47
|
+
.description('反向 WS 路由列表,插件监听等待后端连接'),
|
|
48
|
+
});
|
|
49
|
+
function toOneBotId(value) {
|
|
50
|
+
if (value == null)
|
|
51
|
+
return undefined;
|
|
52
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
53
|
+
return value;
|
|
54
|
+
if (typeof value === 'string' && value.trim()) {
|
|
55
|
+
const parsed = Number(value);
|
|
56
|
+
if (Number.isFinite(parsed))
|
|
57
|
+
return parsed;
|
|
58
|
+
return value; // Return original string if it's not a number (e.g. UUID)
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
function toElements(value) {
|
|
63
|
+
if (value == null)
|
|
64
|
+
return [];
|
|
65
|
+
if (typeof value === 'string')
|
|
66
|
+
return [koishi_1.h.text(value)];
|
|
67
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
68
|
+
return [koishi_1.h.text(String(value))];
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
return value.flatMap((item) => toElements(item));
|
|
71
|
+
}
|
|
72
|
+
if (typeof value === 'object') {
|
|
73
|
+
const segment = value;
|
|
74
|
+
if (segment.type === 'text') {
|
|
75
|
+
return [koishi_1.h.text(segment.data?.text || '')];
|
|
76
|
+
}
|
|
77
|
+
if (segment.type === 'image' || segment.type === 'img') {
|
|
78
|
+
let src = segment.data?.file || segment.data?.url || '';
|
|
79
|
+
if (typeof src === 'string' && src.startsWith('base64://')) {
|
|
80
|
+
src = 'data:image/png;base64,' + src.slice(9);
|
|
81
|
+
}
|
|
82
|
+
return [koishi_1.h.image(src)];
|
|
83
|
+
}
|
|
84
|
+
if (segment.type === 'at') {
|
|
85
|
+
if (segment.data?.qq === 'all')
|
|
86
|
+
return [(0, koishi_1.h)('at', { type: 'all' })];
|
|
87
|
+
return [(0, koishi_1.h)('at', { id: segment.data?.qq })];
|
|
88
|
+
}
|
|
89
|
+
if (segment.type === 'record' || segment.type === 'audio') {
|
|
90
|
+
let src = segment.data?.file || segment.data?.url || '';
|
|
91
|
+
if (typeof src === 'string' && src.startsWith('base64://')) {
|
|
92
|
+
src = 'data:audio/mp3;base64,' + src.slice(9);
|
|
93
|
+
}
|
|
94
|
+
return [koishi_1.h.audio(src)];
|
|
95
|
+
}
|
|
96
|
+
if (segment.type === 'video') {
|
|
97
|
+
let src = segment.data?.file || segment.data?.url || '';
|
|
98
|
+
if (typeof src === 'string' && src.startsWith('base64://')) {
|
|
99
|
+
src = 'data:video/mp4;base64,' + src.slice(9);
|
|
100
|
+
}
|
|
101
|
+
return [koishi_1.h.video(src)];
|
|
102
|
+
}
|
|
103
|
+
if (segment.type === 'face') {
|
|
104
|
+
return [(0, koishi_1.h)('face', { id: segment.data?.id || '' })];
|
|
105
|
+
}
|
|
106
|
+
if (segment.type === 'reply') {
|
|
107
|
+
return [(0, koishi_1.h)('quote', { id: segment.data?.id || '' })];
|
|
108
|
+
}
|
|
109
|
+
if (segment.reply !== undefined)
|
|
110
|
+
return toElements(segment.reply);
|
|
111
|
+
if (segment.message !== undefined)
|
|
112
|
+
return toElements(segment.message);
|
|
113
|
+
if (segment.text !== undefined)
|
|
114
|
+
return [koishi_1.h.text(String(segment.text))];
|
|
115
|
+
}
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
function transformElements(elements) {
|
|
119
|
+
const result = [];
|
|
120
|
+
const extractSrc = (el) => {
|
|
121
|
+
let src = el.attrs.src || el.attrs.url || '';
|
|
122
|
+
if (typeof src === 'string' && src.startsWith('data:')) {
|
|
123
|
+
const match = src.match(/^data:.*?;base64,(.*)$/);
|
|
124
|
+
if (match) {
|
|
125
|
+
src = 'base64://' + match[1];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return src;
|
|
129
|
+
};
|
|
130
|
+
for (const el of elements) {
|
|
131
|
+
if (el.type === 'text') {
|
|
132
|
+
result.push({ type: 'text', data: { text: el.attrs.content || '' } });
|
|
133
|
+
}
|
|
134
|
+
else if (el.type === 'at') {
|
|
135
|
+
if (el.attrs.type === 'all' || el.attrs.role === 'all') {
|
|
136
|
+
result.push({ type: 'at', data: { qq: 'all' } });
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
result.push({ type: 'at', data: { qq: el.attrs.id } });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else if (el.type === 'image' || el.type === 'img') {
|
|
143
|
+
result.push({ type: 'image', data: { file: extractSrc(el) } });
|
|
144
|
+
}
|
|
145
|
+
else if (el.type === 'audio' || el.type === 'record') {
|
|
146
|
+
result.push({ type: 'record', data: { file: extractSrc(el) } });
|
|
147
|
+
}
|
|
148
|
+
else if (el.type === 'video') {
|
|
149
|
+
result.push({ type: 'video', data: { file: extractSrc(el) } });
|
|
150
|
+
}
|
|
151
|
+
else if (el.type === 'face') {
|
|
152
|
+
result.push({ type: 'face', data: { id: el.attrs.id } });
|
|
153
|
+
}
|
|
154
|
+
else if (el.type === 'quote') {
|
|
155
|
+
result.push({ type: 'reply', data: { id: el.attrs.id } });
|
|
156
|
+
}
|
|
157
|
+
else if (el.children && el.children.length > 0) {
|
|
158
|
+
result.push(...transformElements(el.children));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
function buildOneBotMessageEvent(session, commandName, content) {
|
|
164
|
+
const text = session?.content || (commandName + ' ' + (content ?? '')).trim();
|
|
165
|
+
const isGroup = Boolean(session?.guildId != null
|
|
166
|
+
|| (session?.channelId != null && String(session.channelId) !== String(session?.userId ?? '')));
|
|
167
|
+
const userId = toOneBotId(session?.userId);
|
|
168
|
+
const groupId = isGroup ? toOneBotId(session?.guildId ?? session?.channelId) : undefined;
|
|
169
|
+
const selfId = toOneBotId(session?.selfId ?? session?.bot?.selfId);
|
|
170
|
+
const messageId = toOneBotId(session?.messageId);
|
|
171
|
+
const elements = session?.elements || koishi_1.h.parse(text);
|
|
172
|
+
const messageSegments = transformElements(elements);
|
|
173
|
+
return {
|
|
174
|
+
time: Math.floor(Date.now() / 1000),
|
|
175
|
+
self_id: selfId ?? 0,
|
|
176
|
+
post_type: 'message',
|
|
177
|
+
message_type: isGroup ? 'group' : 'private',
|
|
178
|
+
sub_type: 'normal',
|
|
179
|
+
message_id: messageId ?? 0,
|
|
180
|
+
user_id: userId ?? 0,
|
|
181
|
+
group_id: isGroup ? (groupId ?? 0) : undefined,
|
|
182
|
+
message: messageSegments.length > 0 ? messageSegments : [{ type: 'text', data: { text } }],
|
|
183
|
+
raw_message: text,
|
|
184
|
+
font: 0,
|
|
185
|
+
sender: {
|
|
186
|
+
user_id: userId,
|
|
187
|
+
nickname: session?.username ?? session?.nickname,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
class RouteRuntime {
|
|
192
|
+
ctx;
|
|
193
|
+
config;
|
|
194
|
+
route;
|
|
195
|
+
socket = null;
|
|
196
|
+
server = null;
|
|
197
|
+
connecting = null;
|
|
198
|
+
reconnectTimer = null;
|
|
199
|
+
heartbeatTimer = null;
|
|
200
|
+
serverReadyWaiter = null;
|
|
201
|
+
pending = new Map();
|
|
202
|
+
inflight = 0;
|
|
203
|
+
queue = [];
|
|
204
|
+
closed = false;
|
|
205
|
+
lastPong = 0;
|
|
206
|
+
constructor(ctx, config, route) {
|
|
207
|
+
this.ctx = ctx;
|
|
208
|
+
this.config = config;
|
|
209
|
+
this.route = route;
|
|
210
|
+
}
|
|
211
|
+
get kind() {
|
|
212
|
+
if (this.route.mode === 'client')
|
|
213
|
+
return 'client';
|
|
214
|
+
if ('endpoint' in this.route && this.route.endpoint)
|
|
215
|
+
return 'client';
|
|
216
|
+
return 'server';
|
|
217
|
+
}
|
|
218
|
+
get routeLabel() {
|
|
219
|
+
return `${this.route.name}(${this.kind})`;
|
|
220
|
+
}
|
|
221
|
+
get enabled() {
|
|
222
|
+
const commands = Array.isArray(this.route.commands) ? this.route.commands : [];
|
|
223
|
+
if (!this.route.enabled || !commands.length)
|
|
224
|
+
return false;
|
|
225
|
+
if (this.kind === 'client')
|
|
226
|
+
return !!('endpoint' in this.route && this.route.endpoint && this.route.endpoint.length > 0);
|
|
227
|
+
return !!('listenHost' in this.route && this.route.listenHost) && !!('listenPort' in this.route && this.route.listenPort && this.route.listenPort > 0);
|
|
228
|
+
}
|
|
229
|
+
start() {
|
|
230
|
+
if (!this.enabled)
|
|
231
|
+
return;
|
|
232
|
+
this.ctx.logger(exports.name).info(`route ${this.routeLabel} starting`);
|
|
233
|
+
if (this.kind === 'server') {
|
|
234
|
+
this.startServer();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
void this.connect().catch((error) => {
|
|
238
|
+
this.ctx.logger(exports.name).warn(`route ${this.route.name} initial connect failed: ${error.message}`);
|
|
239
|
+
this.scheduleReconnect();
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
stop() {
|
|
243
|
+
this.closed = true;
|
|
244
|
+
this.ctx.logger(exports.name).info(`route ${this.routeLabel} stopping`);
|
|
245
|
+
if (this.reconnectTimer) {
|
|
246
|
+
clearTimeout(this.reconnectTimer);
|
|
247
|
+
this.reconnectTimer = null;
|
|
248
|
+
}
|
|
249
|
+
if (this.heartbeatTimer) {
|
|
250
|
+
clearInterval(this.heartbeatTimer);
|
|
251
|
+
this.heartbeatTimer = null;
|
|
252
|
+
}
|
|
253
|
+
if (this.server) {
|
|
254
|
+
this.server.close();
|
|
255
|
+
this.server = null;
|
|
256
|
+
}
|
|
257
|
+
this.rejectServerReadyWaiter(new Error(`route ${this.route.name} closed`));
|
|
258
|
+
this.rejectAll(new Error(`route ${this.route.name} closed`));
|
|
259
|
+
if (this.socket && this.socket.readyState === ws_1.default.OPEN) {
|
|
260
|
+
this.socket.close(1000, 'plugin disposed');
|
|
261
|
+
}
|
|
262
|
+
else if (this.socket) {
|
|
263
|
+
this.socket.terminate();
|
|
264
|
+
}
|
|
265
|
+
this.socket = null;
|
|
266
|
+
}
|
|
267
|
+
async forward(session, commandName, content = '') {
|
|
268
|
+
if (!Array.isArray(this.route.commands)) {
|
|
269
|
+
throw new Error(`route ${this.route.name} has no commands configured`);
|
|
270
|
+
}
|
|
271
|
+
this.ctx.logger(exports.name).info(`route ${this.routeLabel} command trigger: command=${commandName} user=${session.userId ?? 'unknown'} channel=${session.channelId ?? 'unknown'}`);
|
|
272
|
+
if (this.config.debug) {
|
|
273
|
+
this.ctx.logger(exports.name).debug(`route ${this.routeLabel} command content: ${JSON.stringify(content)}`);
|
|
274
|
+
}
|
|
275
|
+
const event = buildOneBotMessageEvent(session, commandName, content);
|
|
276
|
+
this.sendEvent(event);
|
|
277
|
+
return undefined; // We do not return a reply here. Standard OB11 backends reply using send_msg API calls.
|
|
278
|
+
}
|
|
279
|
+
sendEvent(event) {
|
|
280
|
+
if (this.closed)
|
|
281
|
+
return;
|
|
282
|
+
const push = async () => {
|
|
283
|
+
if (this.kind === 'server') {
|
|
284
|
+
await this.waitForServerSocket();
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
await this.connect();
|
|
288
|
+
}
|
|
289
|
+
const socket = this.socket;
|
|
290
|
+
if (!socket || socket.readyState !== ws_1.default.OPEN) {
|
|
291
|
+
throw new Error(`route ${this.route.name} is not connected`);
|
|
292
|
+
}
|
|
293
|
+
if (this.config.debug) {
|
|
294
|
+
this.ctx.logger(exports.name).debug(`route ${this.routeLabel} push event => ${JSON.stringify(event)}`);
|
|
295
|
+
}
|
|
296
|
+
socket.send(JSON.stringify(event), (error) => {
|
|
297
|
+
if (error) {
|
|
298
|
+
this.ctx.logger(exports.name).warn(`route ${this.routeLabel} push event failed: ${error.message}`);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
};
|
|
302
|
+
push().catch(e => this.ctx.logger(exports.name).warn(e));
|
|
303
|
+
}
|
|
304
|
+
acquireSlot() {
|
|
305
|
+
if (this.inflight < this.route.maxConcurrency) {
|
|
306
|
+
this.inflight += 1;
|
|
307
|
+
return Promise.resolve();
|
|
308
|
+
}
|
|
309
|
+
return new Promise((resolve) => {
|
|
310
|
+
this.queue.push(() => {
|
|
311
|
+
this.inflight += 1;
|
|
312
|
+
resolve();
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
releaseSlot() {
|
|
317
|
+
this.inflight = Math.max(0, this.inflight - 1);
|
|
318
|
+
const next = this.queue.shift();
|
|
319
|
+
if (next)
|
|
320
|
+
next();
|
|
321
|
+
}
|
|
322
|
+
async connect() {
|
|
323
|
+
if (this.closed) {
|
|
324
|
+
throw new Error(`route ${this.route.name} is disposed`);
|
|
325
|
+
}
|
|
326
|
+
if (this.socket?.readyState === ws_1.default.OPEN)
|
|
327
|
+
return;
|
|
328
|
+
if (this.connecting)
|
|
329
|
+
return this.connecting;
|
|
330
|
+
this.connecting = new Promise((resolve, reject) => {
|
|
331
|
+
let settled = false;
|
|
332
|
+
const finish = (fn) => {
|
|
333
|
+
if (settled)
|
|
334
|
+
return;
|
|
335
|
+
settled = true;
|
|
336
|
+
fn();
|
|
337
|
+
};
|
|
338
|
+
if (this.kind !== 'client') {
|
|
339
|
+
finish(() => reject(new Error(`route ${this.route.name} is not in client mode`)));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const endpoint = 'endpoint' in this.route ? this.route.endpoint : '';
|
|
343
|
+
this.ctx.logger(exports.name).info(`route ${this.routeLabel} connecting to ${endpoint}`);
|
|
344
|
+
const socket = new ws_1.default(endpoint, {
|
|
345
|
+
handshakeTimeout: this.route.timeout,
|
|
346
|
+
headers: this.route.token
|
|
347
|
+
? {
|
|
348
|
+
authorization: `Bearer ${this.route.token}`,
|
|
349
|
+
}
|
|
350
|
+
: undefined,
|
|
351
|
+
});
|
|
352
|
+
socket.once('open', () => {
|
|
353
|
+
this.socket = socket;
|
|
354
|
+
this.lastPong = Date.now();
|
|
355
|
+
this.bindSocket(socket);
|
|
356
|
+
this.startHeartbeat();
|
|
357
|
+
this.ctx.logger(exports.name).info(`route ${this.routeLabel} connected`);
|
|
358
|
+
// Push standard OB11 lifecycle meta_event
|
|
359
|
+
const selfId = toOneBotId(this.ctx.bots[0]?.selfId) || 0;
|
|
360
|
+
const connectEvent = {
|
|
361
|
+
time: Math.floor(Date.now() / 1000),
|
|
362
|
+
self_id: selfId,
|
|
363
|
+
post_type: 'meta_event',
|
|
364
|
+
meta_event_type: 'lifecycle',
|
|
365
|
+
sub_type: 'connect'
|
|
366
|
+
};
|
|
367
|
+
if (socket.readyState === ws_1.default.OPEN) {
|
|
368
|
+
socket.send(JSON.stringify(connectEvent));
|
|
369
|
+
}
|
|
370
|
+
finish(resolve);
|
|
371
|
+
});
|
|
372
|
+
socket.once('error', (error) => {
|
|
373
|
+
this.ctx.logger(exports.name).warn(`route ${this.routeLabel} connect error: ${error.message}`);
|
|
374
|
+
finish(() => reject(error));
|
|
375
|
+
});
|
|
376
|
+
socket.once('close', (code, reason) => {
|
|
377
|
+
const text = Buffer.isBuffer(reason) ? reason.toString() : String(reason ?? '');
|
|
378
|
+
this.ctx.logger(exports.name).warn(`route ${this.routeLabel} connect closed: code=${code} reason=${text || 'none'}`);
|
|
379
|
+
finish(() => reject(new Error(`route ${this.route.name} connection closed during handshake`)));
|
|
380
|
+
});
|
|
381
|
+
}).finally(() => {
|
|
382
|
+
this.connecting = null;
|
|
383
|
+
});
|
|
384
|
+
return this.connecting;
|
|
385
|
+
}
|
|
386
|
+
startServer() {
|
|
387
|
+
if (this.server || this.closed)
|
|
388
|
+
return;
|
|
389
|
+
if (this.kind !== 'server') {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const listenHost = 'listenHost' in this.route ? this.route.listenHost : '127.0.0.1';
|
|
393
|
+
const listenPort = 'listenPort' in this.route ? this.route.listenPort : 0;
|
|
394
|
+
this.server = new ws_1.default.Server({
|
|
395
|
+
port: listenPort,
|
|
396
|
+
host: listenHost,
|
|
397
|
+
});
|
|
398
|
+
this.server.on('connection', (socket, request) => {
|
|
399
|
+
const remote = request.socket?.remoteAddress ?? 'unknown';
|
|
400
|
+
this.ctx.logger(exports.name).info(`route ${this.routeLabel} accepted connection from ${remote}`);
|
|
401
|
+
if (this.route.token) {
|
|
402
|
+
const auth = request.headers.authorization ?? '';
|
|
403
|
+
if (auth !== `Bearer ${this.route.token}`) {
|
|
404
|
+
this.ctx.logger(exports.name).warn(`route ${this.routeLabel} rejected connection from ${remote}: unauthorized`);
|
|
405
|
+
socket.close(1008, 'unauthorized');
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (this.socket && this.socket !== socket) {
|
|
410
|
+
this.socket.terminate();
|
|
411
|
+
}
|
|
412
|
+
this.socket = socket;
|
|
413
|
+
this.lastPong = Date.now();
|
|
414
|
+
this.bindSocket(socket);
|
|
415
|
+
this.startHeartbeat();
|
|
416
|
+
this.resolveServerReadyWaiter();
|
|
417
|
+
// Push standard OB11 lifecycle meta_event
|
|
418
|
+
const selfId = toOneBotId(this.ctx.bots[0]?.selfId) || 0;
|
|
419
|
+
const connectEvent = {
|
|
420
|
+
time: Math.floor(Date.now() / 1000),
|
|
421
|
+
self_id: selfId,
|
|
422
|
+
post_type: 'meta_event',
|
|
423
|
+
meta_event_type: 'lifecycle',
|
|
424
|
+
sub_type: 'connect'
|
|
425
|
+
};
|
|
426
|
+
if (socket.readyState === ws_1.default.OPEN) {
|
|
427
|
+
socket.send(JSON.stringify(connectEvent));
|
|
428
|
+
}
|
|
429
|
+
socket.on('close', () => {
|
|
430
|
+
if (this.socket === socket) {
|
|
431
|
+
this.handleDisconnect();
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
this.server.on('listening', () => {
|
|
436
|
+
this.ctx.logger(exports.name).info(`route ${this.routeLabel} listening on ws://${listenHost}:${listenPort}`);
|
|
437
|
+
});
|
|
438
|
+
this.server.on('error', (error) => {
|
|
439
|
+
this.ctx.logger(exports.name).warn(`route ${this.routeLabel} server error: ${error.message}`);
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
async waitForServerSocket() {
|
|
443
|
+
if (this.socket?.readyState === ws_1.default.OPEN)
|
|
444
|
+
return;
|
|
445
|
+
const waiter = this.getServerReadyWaiter();
|
|
446
|
+
await waiter.promise;
|
|
447
|
+
}
|
|
448
|
+
bindSocket(socket) {
|
|
449
|
+
socket.on('message', async (raw) => {
|
|
450
|
+
const data = this.parseIncoming(raw);
|
|
451
|
+
if (!data)
|
|
452
|
+
return;
|
|
453
|
+
if (this.config.debug) {
|
|
454
|
+
this.ctx.logger(exports.name).debug(`route ${this.routeLabel} <= raw=${typeof raw === 'string' ? raw : raw.toString()}`);
|
|
455
|
+
this.ctx.logger(exports.name).debug(`route ${this.routeLabel} <= ${JSON.stringify(data)}`);
|
|
456
|
+
}
|
|
457
|
+
if (data.action) {
|
|
458
|
+
const sendResponse = (response) => {
|
|
459
|
+
if (data.echo)
|
|
460
|
+
response.echo = data.echo;
|
|
461
|
+
if (socket.readyState === ws_1.default.OPEN) {
|
|
462
|
+
socket.send(JSON.stringify(response));
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
if (data.action === 'send_private_msg' || (data.action === 'send_msg' && data.params?.message_type === 'private')) {
|
|
466
|
+
const userId = data.params?.user_id;
|
|
467
|
+
const message = data.params?.message;
|
|
468
|
+
if (userId == null || message == null) {
|
|
469
|
+
return sendResponse({ status: 'failed', retcode: 100, msg: 'Missing user_id or message', data: null });
|
|
470
|
+
}
|
|
471
|
+
const bot = this.ctx.bots.find(b => b.selfId === String(data.params?.self_id)) || this.ctx.bots[0];
|
|
472
|
+
if (bot) {
|
|
473
|
+
try {
|
|
474
|
+
const msgIds = await bot.sendPrivateMessage(String(userId), toElements(message));
|
|
475
|
+
const msgId = msgIds && msgIds.length > 0 ? (Number(msgIds[0]) || Math.floor(Math.random() * 1000000)) : Math.floor(Math.random() * 1000000);
|
|
476
|
+
sendResponse({ status: 'ok', retcode: 0, data: { message_id: msgId } });
|
|
477
|
+
}
|
|
478
|
+
catch (e) {
|
|
479
|
+
this.ctx.logger(exports.name).error(`[send_private_msg] Failed to send to ${userId}:`, e);
|
|
480
|
+
sendResponse({ status: 'failed', retcode: 100, msg: String(e), data: null });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
sendResponse({ status: 'failed', retcode: 100, msg: 'No Koishi bot available', data: null });
|
|
485
|
+
}
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (data.action === 'send_group_msg' || (data.action === 'send_msg' && data.params?.message_type === 'group')) {
|
|
489
|
+
const groupId = data.params?.group_id;
|
|
490
|
+
const message = data.params?.message;
|
|
491
|
+
if (groupId == null || message == null) {
|
|
492
|
+
return sendResponse({ status: 'failed', retcode: 100, msg: 'Missing group_id or message', data: null });
|
|
493
|
+
}
|
|
494
|
+
const bot = this.ctx.bots.find(b => b.selfId === String(data.params?.self_id)) || this.ctx.bots[0];
|
|
495
|
+
if (bot) {
|
|
496
|
+
try {
|
|
497
|
+
const msgIds = await bot.sendMessage(String(groupId), toElements(message));
|
|
498
|
+
const msgId = msgIds && msgIds.length > 0 ? (Number(msgIds[0]) || Math.floor(Math.random() * 1000000)) : Math.floor(Math.random() * 1000000);
|
|
499
|
+
sendResponse({ status: 'ok', retcode: 0, data: { message_id: msgId } });
|
|
500
|
+
}
|
|
501
|
+
catch (e) {
|
|
502
|
+
this.ctx.logger(exports.name).error(`[send_group_msg] Failed to send to ${groupId}:`, e);
|
|
503
|
+
sendResponse({ status: 'failed', retcode: 100, msg: String(e), data: null });
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
sendResponse({ status: 'failed', retcode: 100, msg: 'No Koishi bot available', data: null });
|
|
508
|
+
}
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
// Unsupported action fallback
|
|
512
|
+
sendResponse({ status: 'failed', retcode: 102, msg: 'Unsupported API action', data: null });
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
socket.on('pong', () => {
|
|
517
|
+
this.lastPong = Date.now();
|
|
518
|
+
});
|
|
519
|
+
socket.on('close', (code, reason) => {
|
|
520
|
+
const text = Buffer.isBuffer(reason) ? reason.toString() : String(reason ?? '');
|
|
521
|
+
this.ctx.logger(exports.name).warn(`route ${this.routeLabel} socket closed: code=${code} reason=${text || 'none'}`);
|
|
522
|
+
this.handleDisconnect();
|
|
523
|
+
});
|
|
524
|
+
socket.on('error', (error) => {
|
|
525
|
+
this.ctx.logger(exports.name).warn(`route ${this.routeLabel} websocket error: ${error.message}`);
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
parseIncoming(raw) {
|
|
529
|
+
const text = typeof raw === 'string' ? raw : raw.toString();
|
|
530
|
+
try {
|
|
531
|
+
return JSON.parse(text);
|
|
532
|
+
}
|
|
533
|
+
catch (e) {
|
|
534
|
+
if (this.config.debug) {
|
|
535
|
+
this.ctx.logger(exports.name).debug(`route ${this.routeLabel} <= failed to parse JSON: ${text}`);
|
|
536
|
+
}
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
startHeartbeat() {
|
|
541
|
+
if (this.heartbeatTimer)
|
|
542
|
+
clearInterval(this.heartbeatTimer);
|
|
543
|
+
this.heartbeatTimer = setInterval(() => {
|
|
544
|
+
const socket = this.socket;
|
|
545
|
+
if (!socket || socket.readyState !== ws_1.default.OPEN)
|
|
546
|
+
return;
|
|
547
|
+
const elapsed = Date.now() - this.lastPong;
|
|
548
|
+
if (elapsed > this.route.heartbeatInterval * 2) {
|
|
549
|
+
socket.terminate();
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
socket.ping();
|
|
553
|
+
}, this.route.heartbeatInterval);
|
|
554
|
+
}
|
|
555
|
+
handleDisconnect() {
|
|
556
|
+
this.socket = null;
|
|
557
|
+
if (this.heartbeatTimer) {
|
|
558
|
+
clearInterval(this.heartbeatTimer);
|
|
559
|
+
this.heartbeatTimer = null;
|
|
560
|
+
}
|
|
561
|
+
this.rejectServerReadyWaiter(new Error(`route ${this.route.name} disconnected`));
|
|
562
|
+
this.rejectAll(new Error(`route ${this.route.name} disconnected`));
|
|
563
|
+
if (!this.closed && this.kind === 'client') {
|
|
564
|
+
this.ctx.logger(exports.name).warn(`route ${this.routeLabel} scheduling reconnect in ${this.route.reconnectInterval}ms`);
|
|
565
|
+
this.scheduleReconnect();
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
scheduleReconnect() {
|
|
569
|
+
if (this.reconnectTimer || this.closed)
|
|
570
|
+
return;
|
|
571
|
+
this.reconnectTimer = setTimeout(() => {
|
|
572
|
+
this.reconnectTimer = null;
|
|
573
|
+
this.ctx.logger(exports.name).info(`route ${this.routeLabel} reconnecting`);
|
|
574
|
+
void this.connect().catch((error) => {
|
|
575
|
+
this.ctx.logger(exports.name).warn(`route ${this.routeLabel} reconnect failed: ${error.message}`);
|
|
576
|
+
this.scheduleReconnect();
|
|
577
|
+
});
|
|
578
|
+
}, this.route.reconnectInterval);
|
|
579
|
+
}
|
|
580
|
+
rejectAll(error) {
|
|
581
|
+
for (const [echo, pending] of this.pending) {
|
|
582
|
+
clearTimeout(pending.timer);
|
|
583
|
+
pending.reject(error);
|
|
584
|
+
this.pending.delete(echo);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
getServerReadyWaiter() {
|
|
588
|
+
if (this.serverReadyWaiter)
|
|
589
|
+
return this.serverReadyWaiter;
|
|
590
|
+
let resolve;
|
|
591
|
+
let reject;
|
|
592
|
+
const promise = new Promise((res, rej) => {
|
|
593
|
+
resolve = res;
|
|
594
|
+
reject = rej;
|
|
595
|
+
});
|
|
596
|
+
const timer = setTimeout(() => {
|
|
597
|
+
if (this.serverReadyWaiter?.promise === promise) {
|
|
598
|
+
this.serverReadyWaiter = null;
|
|
599
|
+
}
|
|
600
|
+
reject(new Error(`route ${this.route.name} is waiting for backend connection`));
|
|
601
|
+
}, this.route.timeout);
|
|
602
|
+
this.serverReadyWaiter = { promise, resolve, reject, timer };
|
|
603
|
+
return this.serverReadyWaiter;
|
|
604
|
+
}
|
|
605
|
+
resolveServerReadyWaiter() {
|
|
606
|
+
if (!this.serverReadyWaiter)
|
|
607
|
+
return;
|
|
608
|
+
clearTimeout(this.serverReadyWaiter.timer);
|
|
609
|
+
this.serverReadyWaiter.resolve();
|
|
610
|
+
this.serverReadyWaiter = null;
|
|
611
|
+
}
|
|
612
|
+
rejectServerReadyWaiter(error) {
|
|
613
|
+
if (!this.serverReadyWaiter)
|
|
614
|
+
return;
|
|
615
|
+
clearTimeout(this.serverReadyWaiter.timer);
|
|
616
|
+
this.serverReadyWaiter.reject(error);
|
|
617
|
+
this.serverReadyWaiter = null;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
function apply(ctx, config) {
|
|
621
|
+
const logger = ctx.logger(exports.name);
|
|
622
|
+
const runtimes = new Map();
|
|
623
|
+
const registered = new Set();
|
|
624
|
+
const routes = [...config.clientRoutes, ...config.serverRoutes];
|
|
625
|
+
for (const route of routes) {
|
|
626
|
+
const runtime = new RouteRuntime(ctx, config, route);
|
|
627
|
+
runtimes.set(route.name, runtime);
|
|
628
|
+
if (!route.enabled)
|
|
629
|
+
continue;
|
|
630
|
+
const kind = route.mode === 'client' || ('endpoint' in route && route.endpoint) ? 'client' : 'server';
|
|
631
|
+
if (kind === 'client') {
|
|
632
|
+
if (!('endpoint' in route) || !route.endpoint) {
|
|
633
|
+
logger.warn(`skip route ${route.name}: endpoint is empty`);
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
if (!('listenHost' in route) || !route.listenHost || !('listenPort' in route) || !route.listenPort) {
|
|
639
|
+
logger.warn(`skip route ${route.name}: listenHost or listenPort is empty`);
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (!Array.isArray(route.commands) || !route.commands.length) {
|
|
644
|
+
logger.warn(`skip route ${route.name}: no commands configured`);
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
for (const commandName of route.commands) {
|
|
648
|
+
const normalized = commandName.trim();
|
|
649
|
+
if (!normalized)
|
|
650
|
+
continue;
|
|
651
|
+
if (registered.has(normalized)) {
|
|
652
|
+
logger.warn(`skip duplicated command ${normalized} from route ${route.name}`);
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
registered.add(normalized);
|
|
656
|
+
ctx.command(`${normalized} [content:text]`).action(async ({ session }, content) => {
|
|
657
|
+
if (config.debug) {
|
|
658
|
+
logger.debug(`command trigger: ${normalized} route=${route.name} user=${session?.userId ?? 'unknown'} channel=${session?.channelId ?? 'unknown'} content=${JSON.stringify(content ?? '')}`);
|
|
659
|
+
}
|
|
660
|
+
await runtime.forward(session, normalized, content ?? '');
|
|
661
|
+
return undefined;
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
ctx.on('ready', () => {
|
|
666
|
+
for (const runtime of runtimes.values()) {
|
|
667
|
+
runtime.start();
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
ctx.on('dispose', () => {
|
|
671
|
+
for (const runtime of runtimes.values()) {
|
|
672
|
+
runtime.stop();
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "koishi-plugin-msg-router",
|
|
3
|
+
"description": "OneBot v11 WebSocket 消息转发插件",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"typings": "lib/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib",
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc -b",
|
|
14
|
+
"dev": "tsc -b -w"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"chatbot",
|
|
18
|
+
"koishi",
|
|
19
|
+
"plugin",
|
|
20
|
+
"onebot",
|
|
21
|
+
"onebot11",
|
|
22
|
+
"websocket",
|
|
23
|
+
"router"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"ws": "^8.18.3"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"koishi": "^4.18.0"
|
|
31
|
+
},
|
|
32
|
+
"koishi": {
|
|
33
|
+
"description": {
|
|
34
|
+
"zh": "标准 OneBot v11 WebSocket 消息路由与通信中间件",
|
|
35
|
+
"en": "Standard OneBot v11 WebSocket message routing and communication middleware"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# koishi-plugin-msg-router
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/koishi-plugin-msg-router)
|
|
4
|
+
|
|
5
|
+
这是一个用于 Koishi 的消息转发插件。它把你配置好的指令转发到外部 OneBot v11 WebSocket 后端,再把后端返回内容回传给用户。
|
|
6
|
+
|
|
7
|
+
## 主要用途
|
|
8
|
+
|
|
9
|
+
- 把某些 Koishi 指令交给外部后端程序处理
|
|
10
|
+
- 支持正向 WS 和反向 WS 两种模式
|
|
11
|
+
- 后端可通过 WebSocket 常驻连接接收请求
|
|
12
|
+
- 支持令牌鉴权,`token` 可留空
|
|
13
|
+
- 支持可选的 HMAC 签名
|
|
14
|
+
- 支持断线重连、心跳保活、超时控制、并发控制
|
|
15
|
+
|
|
16
|
+
## 控制台配置说明
|
|
17
|
+
|
|
18
|
+
### 总开关
|
|
19
|
+
|
|
20
|
+
- `debug`:是否输出调试信息
|
|
21
|
+
|
|
22
|
+
### 正向 WS 路由列表
|
|
23
|
+
|
|
24
|
+
这张表用于“插件主动连接后端”的场景。
|
|
25
|
+
|
|
26
|
+
- `name`:路由名称
|
|
27
|
+
- `enabled`:是否启用
|
|
28
|
+
- `mode`:固定为 `client`
|
|
29
|
+
- `endpoint`:OneBot v11 WebSocket 地址
|
|
30
|
+
- `token`:后端鉴权令牌,可不填
|
|
31
|
+
- `secret`:HMAC 签名密钥,可不填
|
|
32
|
+
- `commands`:触发这条路由的 Koishi 指令
|
|
33
|
+
- `action`:发送给后端的 action 名称
|
|
34
|
+
- `timeout`:单次请求等待时间,单位毫秒
|
|
35
|
+
- `heartbeatInterval`:发送 ping 的间隔,单位毫秒
|
|
36
|
+
- `reconnectInterval`:断线后的重连间隔,单位毫秒
|
|
37
|
+
- `maxConcurrency`:同时允许的请求数量
|
|
38
|
+
|
|
39
|
+
### 反向 WS 路由列表
|
|
40
|
+
|
|
41
|
+
这张表用于“插件监听,后端主动连进来”的场景。
|
|
42
|
+
|
|
43
|
+
- `name`:路由名称
|
|
44
|
+
- `enabled`:是否启用
|
|
45
|
+
- `mode`:固定为 `server`
|
|
46
|
+
- `listenHost`:监听主机
|
|
47
|
+
- `listenPort`:监听端口
|
|
48
|
+
- `token`:后端鉴权令牌,可不填
|
|
49
|
+
- `secret`:HMAC 签名密钥,可不填
|
|
50
|
+
- `commands`:触发这条路由的 Koishi 指令
|
|
51
|
+
- `action`:发送给后端的 action 名称
|
|
52
|
+
- `timeout`:单次请求等待时间,单位毫秒
|
|
53
|
+
- `heartbeatInterval`:发送 ping 的间隔,单位毫秒
|
|
54
|
+
- `reconnectInterval`:断线后的重连间隔,单位毫秒
|
|
55
|
+
- `maxConcurrency`:同时允许的请求数量
|
|
56
|
+
|
|
57
|
+
## 推荐配置示例
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"debug": false,
|
|
62
|
+
"clientRoutes": [
|
|
63
|
+
{
|
|
64
|
+
"name": "AI 后端",
|
|
65
|
+
"enabled": true,
|
|
66
|
+
"mode": "client",
|
|
67
|
+
"endpoint": "ws://127.0.0.1:8080",
|
|
68
|
+
"token": "",
|
|
69
|
+
"secret": "",
|
|
70
|
+
"commands": ["ask", "translate", "summarize"],
|
|
71
|
+
"action": "msg_router.forward_command",
|
|
72
|
+
"timeout": 10000,
|
|
73
|
+
"heartbeatInterval": 30000,
|
|
74
|
+
"reconnectInterval": 5000,
|
|
75
|
+
"maxConcurrency": 16
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"serverRoutes": [
|
|
79
|
+
{
|
|
80
|
+
"name": "反向后端",
|
|
81
|
+
"enabled": true,
|
|
82
|
+
"mode": "server",
|
|
83
|
+
"listenHost": "127.0.0.1",
|
|
84
|
+
"listenPort": 8080,
|
|
85
|
+
"token": "",
|
|
86
|
+
"secret": "",
|
|
87
|
+
"commands": ["reply", "summarize"],
|
|
88
|
+
"action": "msg_router.forward_command",
|
|
89
|
+
"timeout": 10000,
|
|
90
|
+
"heartbeatInterval": 30000,
|
|
91
|
+
"reconnectInterval": 5000,
|
|
92
|
+
"maxConcurrency": 16
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## 模式说明
|
|
99
|
+
|
|
100
|
+
- `client` 模式:插件主动连接后端
|
|
101
|
+
- `server` 模式:插件自己监听,等待后端连接
|
|
102
|
+
- `server` 模式下,如果后端还没连上来,命令会等待到超时为止
|
|
103
|
+
|
|
104
|
+
## 协议约定
|
|
105
|
+
|
|
106
|
+
插件向后端发送带 `action` / `echo` 外壳的请求,其中 `params` 使用 OneBot v11 的标准消息事件结构。当前只把文本内容封装为 `message` 段:
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"action": "msg_router.forward_command",
|
|
111
|
+
"params": {
|
|
112
|
+
"time": 1710000000,
|
|
113
|
+
"self_id": 123456789,
|
|
114
|
+
"post_type": "message",
|
|
115
|
+
"message_type": "group",
|
|
116
|
+
"sub_type": "normal",
|
|
117
|
+
"message_id": 10001,
|
|
118
|
+
"user_id": 123456,
|
|
119
|
+
"group_id": 654321,
|
|
120
|
+
"message": [
|
|
121
|
+
{
|
|
122
|
+
"type": "text",
|
|
123
|
+
"data": {
|
|
124
|
+
"text": "hello"
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
],
|
|
128
|
+
"raw_message": "hello",
|
|
129
|
+
"font": 0,
|
|
130
|
+
"sender": {
|
|
131
|
+
"user_id": 123456,
|
|
132
|
+
"nickname": "Alice"
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"auth": {
|
|
136
|
+
"algorithm": "hmac-sha256",
|
|
137
|
+
"signature": "hex-string",
|
|
138
|
+
"timestamp": 1710000000000
|
|
139
|
+
},
|
|
140
|
+
"echo": "uuid"
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
后端响应示例:
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{
|
|
148
|
+
"status": "ok",
|
|
149
|
+
"retcode": 0,
|
|
150
|
+
"data": {
|
|
151
|
+
"text": "reply text"
|
|
152
|
+
},
|
|
153
|
+
"echo": "uuid"
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
如果 `data.text`、`data.message` 或 `data.reply` 存在,插件会直接把它返回给用户。`data.message` 支持 OneBot 风格的文本段数组。
|
|
158
|
+
|
|
159
|
+
## 兼容说明
|
|
160
|
+
|
|
161
|
+
- `token` 是可选项,不填时不会附加 `Authorization` 头
|
|
162
|
+
- `secret` 也是可选项,不填时不会生成 `auth`
|
|
163
|
+
- 如果后端没有返回可显示内容,插件会保持静默
|
|
164
|
+
- 请求超时只会记录警告,不会把命令直接抛成错误
|
|
165
|
+
- 同名指令不建议在多个路由里重复配置
|