@zhin.js/adapter-onebot11 4.0.2 → 5.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/CHANGELOG.md +62 -0
- package/README.md +41 -380
- package/adapters/onebot11.ts +39 -0
- package/agent/tools/set_title.ts +2 -2
- package/lib/index.d.ts +7 -0
- package/lib/index.js +6 -0
- package/lib/onebot11-agent-deps.d.ts +15 -0
- package/lib/onebot11-agent-deps.js +30 -0
- package/lib/protocol.d.ts +144 -0
- package/lib/protocol.js +235 -0
- package/lib/ws-endpoint.d.ts +28 -0
- package/lib/ws-endpoint.js +210 -0
- package/lib/ws-transport.d.ts +13 -0
- package/lib/ws-transport.js +74 -0
- package/lib/ws-types.d.ts +17 -0
- package/lib/ws-types.js +1 -0
- package/lib/wss-auth.d.ts +2 -0
- package/lib/wss-auth.js +16 -0
- package/lib/wss-endpoint.d.ts +26 -0
- package/lib/wss-endpoint.js +143 -0
- package/package.json +45 -25
- package/plugin.ts +13 -0
- package/schema.json +61 -0
- package/src/index.ts +56 -53
- package/src/onebot11-agent-deps.ts +39 -7
- package/src/protocol.ts +370 -0
- package/src/ws-endpoint.ts +266 -0
- package/src/ws-transport.ts +101 -0
- package/src/ws-types.ts +20 -0
- package/src/wss-auth.ts +17 -0
- package/src/wss-endpoint.ts +189 -0
- package/lib/agent/tools/set_title.js +0 -21
- package/lib/agent/tools/set_title.js.map +0 -1
- package/lib/src/adapter.js +0 -101
- package/lib/src/adapter.js.map +0 -1
- package/lib/src/endpoint-ws-client.js +0 -394
- package/lib/src/endpoint-ws-client.js.map +0 -1
- package/lib/src/endpoint-ws-server.js +0 -341
- package/lib/src/endpoint-ws-server.js.map +0 -1
- package/lib/src/index.js +0 -41
- package/lib/src/index.js.map +0 -1
- package/lib/src/onebot-get-msg.js +0 -27
- package/lib/src/onebot-get-msg.js.map +0 -1
- package/lib/src/onebot11-agent-deps.js +0 -10
- package/lib/src/onebot11-agent-deps.js.map +0 -1
- package/lib/src/segment-mapper.js +0 -2
- package/lib/src/segment-mapper.js.map +0 -1
- package/lib/src/types.js +0 -5
- package/lib/src/types.js.map +0 -1
- package/plugin.yml +0 -3
- package/src/adapter.ts +0 -114
- package/src/endpoint-ws-client.ts +0 -421
- package/src/endpoint-ws-server.ts +0 -382
- package/src/onebot-get-msg.ts +0 -33
- package/src/segment-mapper.ts +0 -1
- package/src/types.ts +0 -60
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OneBot 11 protocol helpers (no legacy Adapter/Endpoint / segment-mapper).
|
|
3
|
+
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
4
|
+
* Spec: https://github.com/botuniverse/onebot-11
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Transitional legacy endpoint row (`endpoints[]` with `context: onebot11`). */
|
|
8
|
+
export interface OneBot11LegacyEndpointRow {
|
|
9
|
+
readonly context?: string;
|
|
10
|
+
readonly connection?: 'ws' | 'wss';
|
|
11
|
+
/** Legacy alias: `type: 'ws' | 'ws_reverse'` */
|
|
12
|
+
readonly type?: string;
|
|
13
|
+
readonly name?: string;
|
|
14
|
+
readonly access_token?: string;
|
|
15
|
+
readonly url?: string;
|
|
16
|
+
readonly path?: string;
|
|
17
|
+
readonly reconnect_interval?: number;
|
|
18
|
+
readonly heartbeat_interval?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
|
|
22
|
+
export interface OneBot11AdapterConfig {
|
|
23
|
+
readonly connection?: 'ws' | 'wss';
|
|
24
|
+
readonly name?: string;
|
|
25
|
+
readonly access_token?: string;
|
|
26
|
+
readonly url?: string;
|
|
27
|
+
readonly path?: string;
|
|
28
|
+
readonly reconnect_interval?: number;
|
|
29
|
+
readonly heartbeat_interval?: number;
|
|
30
|
+
/** Transitional: legacy root `endpoints[]` with `context: onebot11`. */
|
|
31
|
+
readonly endpoints?: ReadonlyArray<OneBot11LegacyEndpointRow>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface OneBot11ConfigBase {
|
|
35
|
+
readonly context: 'onebot11';
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly access_token?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 正向 WebSocket:应用连 OneBot 实现的 WS 服务器 */
|
|
41
|
+
export interface OneBot11WsConfig extends OneBot11ConfigBase {
|
|
42
|
+
readonly connection: 'ws';
|
|
43
|
+
readonly url: string;
|
|
44
|
+
readonly reconnect_interval: number;
|
|
45
|
+
readonly heartbeat_interval: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 反向 WebSocket(slice 1 deferred — needs httpHostToken) */
|
|
49
|
+
export interface OneBot11WssConfig extends OneBot11ConfigBase {
|
|
50
|
+
readonly connection: 'wss';
|
|
51
|
+
readonly path: string;
|
|
52
|
+
readonly heartbeat_interval: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type ResolvedOneBot11Config = OneBot11WsConfig | OneBot11WssConfig;
|
|
56
|
+
export type OneBot11EndpointConfig = ResolvedOneBot11Config;
|
|
57
|
+
|
|
58
|
+
export interface OneBot11Sender {
|
|
59
|
+
readonly role?: string;
|
|
60
|
+
readonly nickname?: string;
|
|
61
|
+
readonly card?: string;
|
|
62
|
+
readonly title?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface OneBot11Segment {
|
|
66
|
+
type: string;
|
|
67
|
+
data?: Record<string, unknown>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface OneBot11Event {
|
|
71
|
+
post_type: string;
|
|
72
|
+
self_id?: number | string;
|
|
73
|
+
message_type?: string;
|
|
74
|
+
sub_type?: string;
|
|
75
|
+
message_id?: number | string;
|
|
76
|
+
user_id?: number | string;
|
|
77
|
+
group_id?: number | string;
|
|
78
|
+
sender?: OneBot11Sender;
|
|
79
|
+
message?: OneBot11Segment[];
|
|
80
|
+
raw_message?: string;
|
|
81
|
+
time?: number;
|
|
82
|
+
notice_type?: string;
|
|
83
|
+
request_type?: string;
|
|
84
|
+
[key: string]: unknown;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface OneBot11ActionRequest {
|
|
88
|
+
action: string;
|
|
89
|
+
params: Record<string, unknown>;
|
|
90
|
+
echo?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface OneBot11ActionResponse {
|
|
94
|
+
status: string;
|
|
95
|
+
retcode: number;
|
|
96
|
+
data?: unknown;
|
|
97
|
+
echo?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface OneBot11WireSegment {
|
|
101
|
+
readonly type: string;
|
|
102
|
+
readonly data?: Record<string, unknown>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ParsedSendTarget {
|
|
106
|
+
readonly message_type: 'private' | 'group';
|
|
107
|
+
readonly id: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeConnection(
|
|
111
|
+
connection: string | undefined,
|
|
112
|
+
legacyType: string | undefined,
|
|
113
|
+
): 'ws' | 'wss' {
|
|
114
|
+
if (connection === 'ws' || connection === 'wss') return connection;
|
|
115
|
+
if (legacyType === 'ws_reverse' || legacyType === 'wss') return 'wss';
|
|
116
|
+
if (legacyType === 'ws') return 'ws';
|
|
117
|
+
return 'ws';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function resolveOneBot11Config(config: OneBot11AdapterConfig = {}): ResolvedOneBot11Config {
|
|
121
|
+
const entry = config.endpoints?.find((item) => item.context === 'onebot11');
|
|
122
|
+
const connection = normalizeConnection(
|
|
123
|
+
config.connection ?? entry?.connection,
|
|
124
|
+
entry?.type,
|
|
125
|
+
);
|
|
126
|
+
const name = (typeof config.name === 'string' && config.name)
|
|
127
|
+
|| (typeof entry?.name === 'string' && entry.name)
|
|
128
|
+
|| process.env.ONEBOT11_BOT_NAME
|
|
129
|
+
|| 'onebot11-bot';
|
|
130
|
+
const access_token = config.access_token ?? entry?.access_token;
|
|
131
|
+
|
|
132
|
+
if (connection === 'ws') {
|
|
133
|
+
const url = config.url ?? entry?.url;
|
|
134
|
+
if (!url) {
|
|
135
|
+
throw new TypeError(
|
|
136
|
+
'OneBot11 connection:ws requires url (plugins.<key>.url or endpoints with context: onebot11)',
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
context: 'onebot11',
|
|
141
|
+
connection: 'ws',
|
|
142
|
+
name,
|
|
143
|
+
access_token,
|
|
144
|
+
url,
|
|
145
|
+
reconnect_interval: config.reconnect_interval ?? entry?.reconnect_interval ?? 5000,
|
|
146
|
+
heartbeat_interval: config.heartbeat_interval ?? entry?.heartbeat_interval ?? 30_000,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (connection === 'wss') {
|
|
151
|
+
const path = config.path ?? entry?.path;
|
|
152
|
+
if (!path) {
|
|
153
|
+
throw new TypeError('OneBot11 connection:wss requires path');
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
context: 'onebot11',
|
|
157
|
+
connection: 'wss',
|
|
158
|
+
name,
|
|
159
|
+
access_token,
|
|
160
|
+
path,
|
|
161
|
+
heartbeat_interval: config.heartbeat_interval ?? entry?.heartbeat_interval ?? 30_000,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
throw new TypeError(`Unknown OneBot11 connection: ${String(connection)}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 判断是否为消息事件(post_type=message) */
|
|
169
|
+
export function isMessageEvent(
|
|
170
|
+
ev: OneBot11Event,
|
|
171
|
+
): ev is OneBot11Event & { message_id: number | string } {
|
|
172
|
+
return ev.post_type === 'message' && ev.message_id != null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 从事件得到场景 id:私聊 user_id,群 group_id */
|
|
176
|
+
export function getChannelId(ev: OneBot11Event): string {
|
|
177
|
+
if (ev.message_type === 'group' && ev.group_id != null) return String(ev.group_id);
|
|
178
|
+
if (ev.group_id != null && ev.message_type !== 'private') return String(ev.group_id);
|
|
179
|
+
if (ev.user_id != null) return String(ev.user_id);
|
|
180
|
+
return '';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Gateway reply target:`private:uid` / `group:gid`,便于 send() 还原动作参数。
|
|
185
|
+
*/
|
|
186
|
+
export function formatInboundTarget(ev: OneBot11Event): string {
|
|
187
|
+
const messageType = ev.message_type === 'group' || (ev.group_id != null && ev.message_type !== 'private')
|
|
188
|
+
? 'group'
|
|
189
|
+
: 'private';
|
|
190
|
+
return `${messageType}:${getChannelId(ev)}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function parseSendTarget(target: string): ParsedSendTarget {
|
|
194
|
+
const sep = target.indexOf(':');
|
|
195
|
+
if (sep <= 0) {
|
|
196
|
+
return { message_type: 'private', id: target };
|
|
197
|
+
}
|
|
198
|
+
const head = target.slice(0, sep);
|
|
199
|
+
const rest = target.slice(sep + 1);
|
|
200
|
+
if (head === 'group') {
|
|
201
|
+
return { message_type: 'group', id: rest };
|
|
202
|
+
}
|
|
203
|
+
if (head === 'private') {
|
|
204
|
+
return { message_type: 'private', id: rest };
|
|
205
|
+
}
|
|
206
|
+
return { message_type: 'private', id: target };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Build inbound text for MessageGateway.receive */
|
|
210
|
+
export function formatInboundContent(ev: OneBot11Event): string {
|
|
211
|
+
if (typeof ev.raw_message === 'string' && ev.raw_message) return ev.raw_message;
|
|
212
|
+
if (Array.isArray(ev.message)) {
|
|
213
|
+
return ev.message
|
|
214
|
+
.map((seg) => (seg.type === 'text' ? String(seg.data?.text ?? '') : ''))
|
|
215
|
+
.join('');
|
|
216
|
+
}
|
|
217
|
+
return '';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function senderDisplayName(ev: OneBot11Event): string {
|
|
221
|
+
const name = ev.sender?.card || ev.sender?.nickname;
|
|
222
|
+
if (typeof name === 'string' && name) return name;
|
|
223
|
+
return ev.user_id != null ? String(ev.user_id) : '';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Runtime Message.sender 必须是用户 ID(agent bridge 用它与 endpointMaster 比对)。
|
|
228
|
+
* user_id 缺失时兜底为空串,绝不回退到显示名。
|
|
229
|
+
*/
|
|
230
|
+
export function senderUserId(ev: OneBot11Event): string {
|
|
231
|
+
return ev.user_id != null ? String(ev.user_id) : '';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 显示名(群名片优先)放 metadata.nickname;没有则返回 undefined,不写该字段。 */
|
|
235
|
+
export function senderNickname(ev: OneBot11Event): string | undefined {
|
|
236
|
+
const name = ev.sender?.card || ev.sender?.nickname;
|
|
237
|
+
return typeof name === 'string' && name ? name : undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 回复引用 id:优先取 message 数组中的 {type:'reply', data:{id}} 段;
|
|
242
|
+
* 兼容部分实现把 reply 放在事件顶层(标量或带 message_id 的对象)。
|
|
243
|
+
*/
|
|
244
|
+
export function extractQuoteId(ev: OneBot11Event): string | undefined {
|
|
245
|
+
if (Array.isArray(ev.message)) {
|
|
246
|
+
for (const seg of ev.message) {
|
|
247
|
+
if (seg?.type === 'reply' && seg.data?.id != null) return String(seg.data.id);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const reply = ev.reply;
|
|
251
|
+
if (typeof reply === 'number' || typeof reply === 'string') return String(reply);
|
|
252
|
+
if (reply && typeof reply === 'object' && 'message_id' in reply) {
|
|
253
|
+
const id = (reply as { message_id?: unknown }).message_id;
|
|
254
|
+
if (id != null) return String(id);
|
|
255
|
+
}
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** 扫描 message 段中的 at,qq 等于本机 uin(self_id)即视为被 @;`qq:'all'`(@全体)不算。 */
|
|
260
|
+
export function isOneBot11BotMentioned(input: {
|
|
261
|
+
readonly selfId: string | undefined;
|
|
262
|
+
readonly message?: readonly OneBot11Segment[];
|
|
263
|
+
}): boolean {
|
|
264
|
+
if (!input.selfId || !Array.isArray(input.message)) return false;
|
|
265
|
+
return input.message.some((seg) => {
|
|
266
|
+
if (seg?.type !== 'at' || seg.data?.qq == null) return false;
|
|
267
|
+
const qq = String(seg.data.qq);
|
|
268
|
+
return qq !== 'all' && qq === input.selfId;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 构造 gateway.receive 的 metadata(ws / wss 两个 endpoint 共用)。 */
|
|
273
|
+
export function formatInboundMetadata(
|
|
274
|
+
ev: OneBot11Event,
|
|
275
|
+
endpoint: string,
|
|
276
|
+
): Readonly<Record<string, unknown>> {
|
|
277
|
+
const selfId = ev.self_id != null ? String(ev.self_id) : undefined;
|
|
278
|
+
const nickname = senderNickname(ev);
|
|
279
|
+
const quoteId = extractQuoteId(ev);
|
|
280
|
+
const mentioned = isOneBot11BotMentioned({ selfId, message: ev.message });
|
|
281
|
+
return Object.freeze({
|
|
282
|
+
message_type: ev.message_type,
|
|
283
|
+
user_id: ev.user_id != null ? String(ev.user_id) : undefined,
|
|
284
|
+
group_id: ev.group_id != null ? String(ev.group_id) : undefined,
|
|
285
|
+
endpoint,
|
|
286
|
+
time: ev.time,
|
|
287
|
+
self_id: selfId,
|
|
288
|
+
role: ev.sender?.role,
|
|
289
|
+
...(nickname ? { nickname } : {}),
|
|
290
|
+
...(quoteId ? { quote_id: quoteId } : {}),
|
|
291
|
+
...(mentioned ? { mentioned: true } : {}),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Wire-encode an already-rendered outbound payload into OneBot 11 message segments.
|
|
297
|
+
* Segment canonicalization is intentionally not done here.
|
|
298
|
+
*/
|
|
299
|
+
export function formatOutboundSegments(payload: unknown): OneBot11Segment[] {
|
|
300
|
+
if (typeof payload === 'string') {
|
|
301
|
+
return [{ type: 'text', data: { text: payload } }];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const items: Array<string | OneBot11WireSegment> = Array.isArray(payload)
|
|
305
|
+
? payload as Array<string | OneBot11WireSegment>
|
|
306
|
+
: payload && typeof payload === 'object' && 'type' in (payload as object)
|
|
307
|
+
? [payload as OneBot11WireSegment]
|
|
308
|
+
: [];
|
|
309
|
+
|
|
310
|
+
if (items.length === 0) {
|
|
311
|
+
const text = payload == null
|
|
312
|
+
? ''
|
|
313
|
+
: typeof payload === 'object'
|
|
314
|
+
? JSON.stringify(payload)
|
|
315
|
+
: String(payload);
|
|
316
|
+
return [{ type: 'text', data: { text } }];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const segs: OneBot11Segment[] = [];
|
|
320
|
+
for (const item of items) {
|
|
321
|
+
if (typeof item === 'string') {
|
|
322
|
+
segs.push({ type: 'text', data: { text: item } });
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
segs.push({ type: item.type, data: item.data ?? {} });
|
|
326
|
+
}
|
|
327
|
+
return segs.length ? segs : [{ type: 'text', data: { text: '' } }];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function buildSendAction(
|
|
331
|
+
target: string,
|
|
332
|
+
message: OneBot11Segment[],
|
|
333
|
+
): { action: string; params: Record<string, unknown> } {
|
|
334
|
+
const parsed = parseSendTarget(target);
|
|
335
|
+
if (parsed.message_type === 'group') {
|
|
336
|
+
return {
|
|
337
|
+
action: 'send_group_msg',
|
|
338
|
+
params: {
|
|
339
|
+
group_id: Number(parsed.id) || parsed.id,
|
|
340
|
+
message,
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
action: 'send_private_msg',
|
|
346
|
+
params: {
|
|
347
|
+
user_id: Number(parsed.id) || parsed.id,
|
|
348
|
+
message,
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Build WS connect URL + headers (access_token via Bearer + query). */
|
|
354
|
+
export function buildWsConnectOptions(config: OneBot11WsConfig): {
|
|
355
|
+
readonly url: string;
|
|
356
|
+
readonly headers: Record<string, string>;
|
|
357
|
+
readonly safeUrl: string;
|
|
358
|
+
} {
|
|
359
|
+
const headers: Record<string, string> = {};
|
|
360
|
+
let connectUrl = config.url;
|
|
361
|
+
if (config.access_token) {
|
|
362
|
+
headers.Authorization = `Bearer ${config.access_token}`;
|
|
363
|
+
const url = new URL(config.url);
|
|
364
|
+
url.searchParams.set('access_token', config.access_token);
|
|
365
|
+
connectUrl = url.toString();
|
|
366
|
+
}
|
|
367
|
+
const safeUrl = new URL(connectUrl);
|
|
368
|
+
safeUrl.searchParams.delete('access_token');
|
|
369
|
+
return { url: connectUrl, headers, safeUrl: safeUrl.toString() };
|
|
370
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OneBot11 WS client endpoint — outbound connect to OneBot implementation.
|
|
3
|
+
*/
|
|
4
|
+
import WebSocket from 'ws';
|
|
5
|
+
import { clearInterval, clearTimeout, setTimeout } from 'node:timers';
|
|
6
|
+
import type { EndpointInstance } from '@zhin.js/adapter';
|
|
7
|
+
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
8
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
9
|
+
import type { CapabilityId } from '@zhin.js/plugin-runtime';
|
|
10
|
+
import { registerOnebot11AgentEndpoint } from './onebot11-agent-deps.js';
|
|
11
|
+
import {
|
|
12
|
+
buildSendAction,
|
|
13
|
+
buildWsConnectOptions,
|
|
14
|
+
formatInboundContent,
|
|
15
|
+
formatInboundMetadata,
|
|
16
|
+
formatInboundTarget,
|
|
17
|
+
formatOutboundSegments,
|
|
18
|
+
isMessageEvent,
|
|
19
|
+
senderUserId,
|
|
20
|
+
type OneBot11Event,
|
|
21
|
+
type OneBot11WsConfig,
|
|
22
|
+
} from './protocol.js';
|
|
23
|
+
import {
|
|
24
|
+
callOneBot11WsAction,
|
|
25
|
+
handleOneBot11WsMessage,
|
|
26
|
+
rejectAllPending,
|
|
27
|
+
startOneBot11Heartbeat,
|
|
28
|
+
} from './ws-transport.js';
|
|
29
|
+
import {
|
|
30
|
+
type OneBot11PendingAction,
|
|
31
|
+
type OneBot11WsCreateOptions,
|
|
32
|
+
type OneBot11WsSocket,
|
|
33
|
+
} from './ws-types.js';
|
|
34
|
+
|
|
35
|
+
const logger = getLogger('onebot11');
|
|
36
|
+
|
|
37
|
+
export interface OneBot11WsEndpointOptions {
|
|
38
|
+
readonly id: CapabilityId;
|
|
39
|
+
readonly gateway: MessageGateway;
|
|
40
|
+
readonly config: OneBot11WsConfig;
|
|
41
|
+
readonly createWebSocket?: (
|
|
42
|
+
url: string,
|
|
43
|
+
options: OneBot11WsCreateOptions,
|
|
44
|
+
) => OneBot11WsSocket;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class OneBot11WsEndpoint implements EndpointInstance {
|
|
48
|
+
readonly #options: OneBot11WsEndpointOptions;
|
|
49
|
+
#ws?: OneBot11WsSocket;
|
|
50
|
+
#reconnectTimer?: NodeJS.Timeout;
|
|
51
|
+
#heartbeatTimer?: NodeJS.Timeout;
|
|
52
|
+
#requestId = { value: 0 };
|
|
53
|
+
#pending = new Map<string, OneBot11PendingAction>();
|
|
54
|
+
#open = false;
|
|
55
|
+
#started = false;
|
|
56
|
+
#stopping = false;
|
|
57
|
+
#unregisterAgent?: () => void;
|
|
58
|
+
|
|
59
|
+
constructor(options: OneBot11WsEndpointOptions) {
|
|
60
|
+
this.#options = options;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async start(): Promise<void> {
|
|
64
|
+
if (this.#started) return;
|
|
65
|
+
this.#started = true;
|
|
66
|
+
this.#stopping = false;
|
|
67
|
+
this.#unregisterAgent = registerOnebot11AgentEndpoint(this.#options.config.name, this);
|
|
68
|
+
try {
|
|
69
|
+
await this.#connect();
|
|
70
|
+
} catch (err) {
|
|
71
|
+
this.#started = false;
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
open(): void {
|
|
77
|
+
this.#open = true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
close(): void {
|
|
81
|
+
this.#open = false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async stop(): Promise<void> {
|
|
85
|
+
this.#open = false;
|
|
86
|
+
this.#stopping = true;
|
|
87
|
+
this.#started = false;
|
|
88
|
+
this.#unregisterAgent?.();
|
|
89
|
+
this.#unregisterAgent = undefined;
|
|
90
|
+
if (this.#reconnectTimer) {
|
|
91
|
+
clearTimeout(this.#reconnectTimer);
|
|
92
|
+
this.#reconnectTimer = undefined;
|
|
93
|
+
}
|
|
94
|
+
if (this.#heartbeatTimer) {
|
|
95
|
+
clearInterval(this.#heartbeatTimer);
|
|
96
|
+
this.#heartbeatTimer = undefined;
|
|
97
|
+
}
|
|
98
|
+
rejectAllPending(this.#pending);
|
|
99
|
+
if (this.#ws) {
|
|
100
|
+
try {
|
|
101
|
+
this.#ws.close();
|
|
102
|
+
} catch {
|
|
103
|
+
/* ignore */
|
|
104
|
+
}
|
|
105
|
+
this.#ws = undefined;
|
|
106
|
+
}
|
|
107
|
+
logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
|
|
111
|
+
const message = formatOutboundSegments(payload);
|
|
112
|
+
const { action, params } = buildSendAction(target, message);
|
|
113
|
+
const data = await this.callApi(action, params) as { message_id?: number | string } | undefined;
|
|
114
|
+
const messageId = data?.message_id != null ? String(data.message_id) : '';
|
|
115
|
+
logger.debug(formatCompact({
|
|
116
|
+
op: 'onebot11_send',
|
|
117
|
+
endpoint: this.#options.config.name,
|
|
118
|
+
target,
|
|
119
|
+
messageId,
|
|
120
|
+
}));
|
|
121
|
+
return messageId;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Public API for agent tools / callers. */
|
|
125
|
+
callApi(action: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
|
126
|
+
return callOneBot11WsAction(this.#ws, this.#pending, this.#requestId, action, params);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async setTitle(
|
|
130
|
+
groupId: number,
|
|
131
|
+
userId: number,
|
|
132
|
+
title: string,
|
|
133
|
+
duration = -1,
|
|
134
|
+
): Promise<boolean> {
|
|
135
|
+
await this.callApi('set_group_special_title', {
|
|
136
|
+
group_id: groupId,
|
|
137
|
+
user_id: userId,
|
|
138
|
+
special_title: title,
|
|
139
|
+
duration,
|
|
140
|
+
});
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Test / internal: admit a parsed event when the endpoint is open. */
|
|
145
|
+
admit(ev: OneBot11Event): void {
|
|
146
|
+
if (!this.#open || !isMessageEvent(ev)) return;
|
|
147
|
+
const target = formatInboundTarget(ev);
|
|
148
|
+
const content = formatInboundContent(ev);
|
|
149
|
+
void this.#options.gateway.receive({
|
|
150
|
+
adapter: this.#options.id,
|
|
151
|
+
target,
|
|
152
|
+
content,
|
|
153
|
+
sender: senderUserId(ev),
|
|
154
|
+
id: String(ev.message_id),
|
|
155
|
+
metadata: formatInboundMetadata(ev, this.#options.config.name),
|
|
156
|
+
}).catch((err) => {
|
|
157
|
+
logger.warn(formatCompact({
|
|
158
|
+
op: 'onebot11_gateway_receive_failed',
|
|
159
|
+
target,
|
|
160
|
+
error: err instanceof Error ? err.message : String(err),
|
|
161
|
+
}));
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async #connect(): Promise<void> {
|
|
166
|
+
const { url, headers, safeUrl } = buildWsConnectOptions(this.#options.config);
|
|
167
|
+
const create = this.#options.createWebSocket
|
|
168
|
+
?? ((connectUrl: string, options: OneBot11WsCreateOptions) =>
|
|
169
|
+
new WebSocket(connectUrl, { headers: options.headers }) as unknown as OneBot11WsSocket);
|
|
170
|
+
|
|
171
|
+
await new Promise<void>((resolve, reject) => {
|
|
172
|
+
let settled = false;
|
|
173
|
+
const ws = create(url, { headers });
|
|
174
|
+
this.#ws = ws;
|
|
175
|
+
|
|
176
|
+
ws.on('open', () => {
|
|
177
|
+
if (settled) return;
|
|
178
|
+
settled = true;
|
|
179
|
+
if (!this.#options.config.access_token) {
|
|
180
|
+
logger.warn(formatCompact({
|
|
181
|
+
endpoint: this.#options.config.name,
|
|
182
|
+
ok: false,
|
|
183
|
+
error: 'missing access_token',
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
logger.debug(formatCompact({
|
|
187
|
+
endpoint: this.#options.config.name,
|
|
188
|
+
mode: 'ws',
|
|
189
|
+
url: safeUrl,
|
|
190
|
+
}));
|
|
191
|
+
this.#heartbeatTimer = this.#stopping
|
|
192
|
+
? this.#heartbeatTimer
|
|
193
|
+
: startOneBot11Heartbeat(
|
|
194
|
+
this.#ws,
|
|
195
|
+
this.#options.config.heartbeat_interval,
|
|
196
|
+
this.#heartbeatTimer,
|
|
197
|
+
);
|
|
198
|
+
resolve();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
ws.on('message', (data) => {
|
|
202
|
+
handleOneBot11WsMessage(data, {
|
|
203
|
+
endpointName: this.#options.config.name,
|
|
204
|
+
pending: this.#pending,
|
|
205
|
+
admit: (ev) => this.admit(ev),
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
ws.on('close', (code, reason) => {
|
|
210
|
+
const reasonStr = typeof reason === 'string'
|
|
211
|
+
? reason
|
|
212
|
+
: Buffer.isBuffer(reason)
|
|
213
|
+
? reason.toString()
|
|
214
|
+
: String(reason ?? '');
|
|
215
|
+
const codeNum = typeof code === 'number' ? code : Number(code ?? 0);
|
|
216
|
+
const codeHint = codeNum === 1005
|
|
217
|
+
? ' [无状态,多为服务端/代理未发 close 帧即断开]'
|
|
218
|
+
: codeNum === 1006
|
|
219
|
+
? ' [异常关闭]'
|
|
220
|
+
: '';
|
|
221
|
+
logger.warn(formatCompact({
|
|
222
|
+
op: 'disconnect',
|
|
223
|
+
endpoint: this.#options.config.name,
|
|
224
|
+
code: codeNum,
|
|
225
|
+
error: `${reasonStr || 'closed'}${codeHint}`,
|
|
226
|
+
reconnect_ms: this.#options.config.reconnect_interval,
|
|
227
|
+
}));
|
|
228
|
+
if (!settled) {
|
|
229
|
+
settled = true;
|
|
230
|
+
reject(new Error(`OneBot11 WS 关闭: ${codeNum} ${reasonStr}`));
|
|
231
|
+
}
|
|
232
|
+
this.#scheduleReconnect();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
ws.on('error', (err) => {
|
|
236
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
237
|
+
logger.warn(formatCompact({
|
|
238
|
+
op: 'ws_error',
|
|
239
|
+
endpoint: this.#options.config.name,
|
|
240
|
+
ok: false,
|
|
241
|
+
error: error.message,
|
|
242
|
+
}));
|
|
243
|
+
if (!settled) {
|
|
244
|
+
settled = true;
|
|
245
|
+
reject(error);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
#scheduleReconnect(): void {
|
|
252
|
+
if (this.#stopping || !this.#started || this.#reconnectTimer) return;
|
|
253
|
+
const delay = this.#options.config.reconnect_interval;
|
|
254
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
255
|
+
this.#reconnectTimer = undefined;
|
|
256
|
+
void this.#connect().catch((err) => {
|
|
257
|
+
logger.warn(formatCompact({
|
|
258
|
+
op: 'reconnect',
|
|
259
|
+
endpoint: this.#options.config.name,
|
|
260
|
+
ok: false,
|
|
261
|
+
error: err instanceof Error ? err.message : String(err),
|
|
262
|
+
}));
|
|
263
|
+
});
|
|
264
|
+
}, delay);
|
|
265
|
+
}
|
|
266
|
+
}
|