@zhin.js/adapter-onebot11 1.0.98 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +745 -3
- package/PERMITS.md +14 -0
- package/README.md +59 -367
- package/adapters/onebot11/index.js +40 -0
- package/adapters/onebot11/index.ts +53 -0
- package/agents/onebot11/agent.json +20 -0
- package/agents/onebot11/boundaries.md +3 -0
- package/agents/onebot11/conventions.md +3 -0
- package/agents/onebot11/skills/onebot11/SKILL.md +15 -0
- package/agents/onebot11/skills/onebot11/tools/set_title/index.js +25 -0
- package/agents/onebot11/skills/onebot11/tools/set_title/index.ts +24 -0
- package/agents/onebot11/system.md +3 -0
- package/commands/onebot11/endpoint/add/[id]/index.js +3 -0
- package/commands/onebot11/endpoint/add/[id]/index.ts +3 -0
- package/commands/onebot11/endpoint/definition.js +25 -0
- package/commands/onebot11/endpoint/definition.ts +23 -0
- package/commands/onebot11/endpoint/list/index.js +3 -0
- package/commands/onebot11/endpoint/list/index.ts +3 -0
- package/commands/onebot11/endpoint/remove/[id]/index.js +3 -0
- package/commands/onebot11/endpoint/remove/[id]/index.ts +3 -0
- package/lib/client.d.ts +20 -0
- package/lib/client.js +30 -0
- package/lib/content-port.d.ts +4 -0
- package/lib/content-port.js +63 -0
- package/lib/endpoint-management.d.ts +15 -0
- package/lib/endpoint-management.js +47 -0
- package/lib/index.d.ts +10 -16
- package/lib/index.js +7 -53
- package/lib/onebot11-runtime-state.d.ts +1 -0
- package/lib/onebot11-runtime-state.js +6 -0
- package/lib/protocol.d.ts +143 -0
- package/lib/protocol.js +327 -0
- package/lib/side-event-dispatch.d.ts +7 -0
- package/lib/side-event-dispatch.js +36 -0
- package/lib/ws-endpoint.d.ts +22 -0
- package/lib/ws-endpoint.js +209 -0
- package/lib/ws-transport.d.ts +12 -0
- package/lib/ws-transport.js +59 -0
- package/lib/ws-types.d.ts +18 -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 +25 -0
- package/lib/wss-endpoint.js +186 -0
- package/package.json +82 -23
- package/plugin.js +18 -0
- package/schema.json +114 -0
- package/src/client.ts +78 -0
- package/src/content-port.ts +66 -0
- package/src/endpoint-management.ts +72 -0
- package/src/index.ts +60 -65
- package/src/onebot11-runtime-state.ts +7 -0
- package/src/protocol.ts +452 -0
- package/src/side-event-dispatch.ts +48 -0
- package/src/ws-endpoint.ts +272 -0
- package/src/ws-transport.ts +85 -0
- package/src/ws-types.ts +21 -0
- package/src/wss-auth.ts +17 -0
- package/src/wss-endpoint.ts +239 -0
- package/lib/adapter.d.ts +0 -32
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -92
- package/lib/adapter.js.map +0 -1
- package/lib/bot-ws-client.d.ts +0 -40
- package/lib/bot-ws-client.d.ts.map +0 -1
- package/lib/bot-ws-client.js +0 -397
- package/lib/bot-ws-client.js.map +0 -1
- package/lib/bot-ws-server.d.ts +0 -42
- package/lib/bot-ws-server.d.ts.map +0 -1
- package/lib/bot-ws-server.js +0 -344
- package/lib/bot-ws-server.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/onebot-get-msg.d.ts +0 -3
- package/lib/onebot-get-msg.d.ts.map +0 -1
- package/lib/onebot-get-msg.js +0 -27
- package/lib/onebot-get-msg.js.map +0 -1
- package/lib/types.d.ts +0 -54
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -5
- package/lib/types.js.map +0 -1
- package/plugin.yml +0 -3
- package/skills/onebot11/SKILL.md +0 -80
- package/src/adapter.ts +0 -104
- package/src/bot-ws-client.ts +0 -421
- package/src/bot-ws-server.ts +0 -382
- package/src/onebot-get-msg.ts +0 -33
- package/src/types.ts +0 -58
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
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
|
+
import { isMediaRef } from '@zhin.js/im-contract';
|
|
7
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
8
|
+
const logger = getLogger('onebot11');
|
|
9
|
+
function normalizeConnection(connection) {
|
|
10
|
+
if (connection === 'ws' || connection === 'wss')
|
|
11
|
+
return connection;
|
|
12
|
+
return 'ws';
|
|
13
|
+
}
|
|
14
|
+
export function resolveOneBot11Config(config) {
|
|
15
|
+
const connection = normalizeConnection(config.connection);
|
|
16
|
+
const id = requiredEndpointField(config.id, 'id');
|
|
17
|
+
const access_token = optionalEndpointField(config.access_token);
|
|
18
|
+
if (connection === 'ws') {
|
|
19
|
+
const url = requiredEndpointField(config.url, 'url');
|
|
20
|
+
return {
|
|
21
|
+
context: 'onebot11',
|
|
22
|
+
connection: 'ws',
|
|
23
|
+
id,
|
|
24
|
+
access_token,
|
|
25
|
+
url,
|
|
26
|
+
reconnect_interval: config.reconnect_interval ?? 5000,
|
|
27
|
+
heartbeat_interval: config.heartbeat_interval ?? 30_000,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (connection === 'wss') {
|
|
31
|
+
const path = requiredEndpointField(config.path, 'path');
|
|
32
|
+
return {
|
|
33
|
+
context: 'onebot11',
|
|
34
|
+
connection: 'wss',
|
|
35
|
+
id,
|
|
36
|
+
access_token,
|
|
37
|
+
path,
|
|
38
|
+
heartbeat_interval: config.heartbeat_interval ?? 30_000,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
throw new TypeError(`Unknown OneBot11 connection: ${String(connection)}`);
|
|
42
|
+
}
|
|
43
|
+
function requiredEndpointField(value, field) {
|
|
44
|
+
const resolved = optionalEndpointField(value);
|
|
45
|
+
if (!resolved) {
|
|
46
|
+
throw new TypeError(`OneBot11 endpoint requires a non-empty ${field}`);
|
|
47
|
+
}
|
|
48
|
+
return resolved;
|
|
49
|
+
}
|
|
50
|
+
function optionalEndpointField(value) {
|
|
51
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
52
|
+
}
|
|
53
|
+
/** 判断是否为消息事件(post_type=message) */
|
|
54
|
+
export function isMessageEvent(ev) {
|
|
55
|
+
return ev.post_type === 'message' && ev.message_id != null;
|
|
56
|
+
}
|
|
57
|
+
/** 从事件得到场景 id:私聊 user_id,群 group_id */
|
|
58
|
+
export function getChannelId(ev) {
|
|
59
|
+
if (ev.message_type === 'group' && ev.group_id != null)
|
|
60
|
+
return String(ev.group_id);
|
|
61
|
+
if (ev.group_id != null && ev.message_type !== 'private')
|
|
62
|
+
return String(ev.group_id);
|
|
63
|
+
if (ev.user_id != null)
|
|
64
|
+
return String(ev.user_id);
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 入站归一化 → ConversationRef:OneBot 11 `group` 消息 → kind 'group',
|
|
69
|
+
* 其余(private 及带 group_id 的非 private 事件按群处理)→ kind 'private'。
|
|
70
|
+
* OneBot 11 无 guild/temp 容器概念,不填 parent。
|
|
71
|
+
*/
|
|
72
|
+
export function onebot11InboundConversation(endpointKey, ev) {
|
|
73
|
+
const kind = ev.message_type === 'group' || (ev.group_id != null && ev.message_type !== 'private')
|
|
74
|
+
? 'group'
|
|
75
|
+
: 'private';
|
|
76
|
+
return {
|
|
77
|
+
endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
78
|
+
kind,
|
|
79
|
+
id: getChannelId(ev),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Build inbound text for OutboundMessageService.receive */
|
|
83
|
+
export function formatInboundContent(ev) {
|
|
84
|
+
if (Array.isArray(ev.message)) {
|
|
85
|
+
return ev.message
|
|
86
|
+
.map((seg) => (seg.type === 'text' ? String(seg.data?.text ?? '') : ''))
|
|
87
|
+
.join('');
|
|
88
|
+
}
|
|
89
|
+
return typeof ev.raw_message === 'string'
|
|
90
|
+
? stripCqCodes(ev.raw_message).trim()
|
|
91
|
+
: '';
|
|
92
|
+
}
|
|
93
|
+
function stripCqCodes(input) {
|
|
94
|
+
let result = '';
|
|
95
|
+
let cursor = 0;
|
|
96
|
+
while (cursor < input.length) {
|
|
97
|
+
const start = input.indexOf('[CQ:', cursor);
|
|
98
|
+
if (start < 0)
|
|
99
|
+
return result + input.slice(cursor);
|
|
100
|
+
result += input.slice(cursor, start);
|
|
101
|
+
const end = input.indexOf(']', start + 4);
|
|
102
|
+
if (end < 0)
|
|
103
|
+
return result + input.slice(start);
|
|
104
|
+
cursor = end + 1;
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
export function senderDisplayName(ev) {
|
|
109
|
+
const name = ev.sender?.card || ev.sender?.nickname;
|
|
110
|
+
if (typeof name === 'string' && name)
|
|
111
|
+
return name;
|
|
112
|
+
return ev.user_id != null ? String(ev.user_id) : '';
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Runtime Message.sender 必须是用户 ID(agent bridge 用它与 endpointMaster 比对)。
|
|
116
|
+
* user_id 缺失时兜底为空串,绝不回退到显示名。
|
|
117
|
+
*/
|
|
118
|
+
export function senderUserId(ev) {
|
|
119
|
+
return ev.user_id != null ? String(ev.user_id) : '';
|
|
120
|
+
}
|
|
121
|
+
/** 显示名(群名片优先)放 metadata.nickname;没有则返回 undefined,不写该字段。 */
|
|
122
|
+
export function senderNickname(ev) {
|
|
123
|
+
const name = ev.sender?.card || ev.sender?.nickname;
|
|
124
|
+
return typeof name === 'string' && name ? name : undefined;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* 回复引用 id:优先取 message 数组中的 {type:'reply', data:{id}} 段;
|
|
128
|
+
* 兼容部分实现把 reply 放在事件顶层(标量或带 message_id 的对象)。
|
|
129
|
+
*/
|
|
130
|
+
export function extractQuoteId(ev) {
|
|
131
|
+
if (Array.isArray(ev.message)) {
|
|
132
|
+
for (const seg of ev.message) {
|
|
133
|
+
if (seg?.type === 'reply' && seg.data?.id != null)
|
|
134
|
+
return String(seg.data.id);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const reply = ev.reply;
|
|
138
|
+
if (typeof reply === 'number' || typeof reply === 'string')
|
|
139
|
+
return String(reply);
|
|
140
|
+
if (reply && typeof reply === 'object' && 'message_id' in reply) {
|
|
141
|
+
const id = reply.message_id;
|
|
142
|
+
if (id != null)
|
|
143
|
+
return String(id);
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
/** 扫描 message 段中的 at,qq 等于本机 uin(self_id)即视为被 @;`qq:'all'`(@全体)不算。 */
|
|
148
|
+
export function isOneBot11BotMentioned(input) {
|
|
149
|
+
if (!input.selfId || !Array.isArray(input.message))
|
|
150
|
+
return false;
|
|
151
|
+
return input.message.some((seg) => {
|
|
152
|
+
if (seg?.type !== 'at' || seg.data?.qq == null)
|
|
153
|
+
return false;
|
|
154
|
+
const qq = String(seg.data.qq);
|
|
155
|
+
return qq !== 'all' && qq === input.selfId;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/** 构造 gateway.receive 的 metadata 与提升字段(ws / wss 两个 endpoint 共用)。 */
|
|
159
|
+
export function formatInboundMetadata(ev, endpoint) {
|
|
160
|
+
const selfId = ev.self_id != null ? String(ev.self_id) : undefined;
|
|
161
|
+
const nickname = senderNickname(ev);
|
|
162
|
+
const quoteId = extractQuoteId(ev);
|
|
163
|
+
const mentioned = isOneBot11BotMentioned({ selfId, message: ev.message });
|
|
164
|
+
return {
|
|
165
|
+
metadata: Object.freeze({
|
|
166
|
+
message_type: ev.message_type,
|
|
167
|
+
user_id: ev.user_id != null ? String(ev.user_id) : undefined,
|
|
168
|
+
group_id: ev.group_id != null ? String(ev.group_id) : undefined,
|
|
169
|
+
time: ev.time,
|
|
170
|
+
self_id: selfId,
|
|
171
|
+
role: ev.sender?.role,
|
|
172
|
+
...(nickname ? { nickname } : {}),
|
|
173
|
+
}),
|
|
174
|
+
endpointId: endpoint,
|
|
175
|
+
mentioned,
|
|
176
|
+
replyTo: quoteId ? { id: quoteId } : undefined,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* canonical MediaRef → OneBot `file` 参数:url 直传、base64 → `base64://`、
|
|
181
|
+
* 本地路径 → `file://`(路径在 OneBot 实现侧解析)、kind=file 不透明引用原样透传。
|
|
182
|
+
*/
|
|
183
|
+
export function mediaRefToOneBotFile(media) {
|
|
184
|
+
if (media.kind === 'base64') {
|
|
185
|
+
return media.value.startsWith('base64://') ? media.value : `base64://${media.value}`;
|
|
186
|
+
}
|
|
187
|
+
if (media.kind === 'path') {
|
|
188
|
+
return media.value.startsWith('file://') ? media.value : `file://${media.value}`;
|
|
189
|
+
}
|
|
190
|
+
return media.value;
|
|
191
|
+
}
|
|
192
|
+
/** 媒体段 data 里的 canonical-only 字段,不进 OneBot wire。 */
|
|
193
|
+
const MEDIA_DATA_SKIP_KEYS = new Set(['media', 'alt']);
|
|
194
|
+
/**
|
|
195
|
+
* image / record / video 段:只消费 canonical `data.media` MediaRef,
|
|
196
|
+
* 映射为 OneBot `file` 参数;无 MediaRef 时 warn + 丢弃(返回 null)。
|
|
197
|
+
*/
|
|
198
|
+
function oneBotMediaSegment(type, data) {
|
|
199
|
+
if (!isMediaRef(data.media)) {
|
|
200
|
+
logger.warn(formatCompact({
|
|
201
|
+
op: 'onebot11_outbound_media_dropped',
|
|
202
|
+
type,
|
|
203
|
+
reason: 'missing_media_ref',
|
|
204
|
+
}));
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const extra = {};
|
|
208
|
+
for (const [key, value] of Object.entries(data)) {
|
|
209
|
+
if (!MEDIA_DATA_SKIP_KEYS.has(key))
|
|
210
|
+
extra[key] = value;
|
|
211
|
+
}
|
|
212
|
+
return { type, data: { ...extra, file: mediaRefToOneBotFile(data.media) } };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* canonical Segment → OneBot 11 数组段:
|
|
216
|
+
* - mention → at(`qq: target`);
|
|
217
|
+
* - reply(`message_id`)→ reply(`id`);
|
|
218
|
+
* - image / audio→record / video 的 MediaRef → `file`(url / base64:// / file:// / 不透明引用);
|
|
219
|
+
* - file 段无 OneBot 11 消息段承载(文件走 upload_*_file API),warn + 丢弃;
|
|
220
|
+
* - face 取 `id`;
|
|
221
|
+
* - 其余(已是 wire 形状的段、平台扩展段)原样透传。
|
|
222
|
+
* 返回 null 表示该段被丢弃。
|
|
223
|
+
*/
|
|
224
|
+
function canonicalToOneBotSegment(segment) {
|
|
225
|
+
const data = segment.data ?? {};
|
|
226
|
+
switch (segment.type) {
|
|
227
|
+
case 'mention': {
|
|
228
|
+
const target = data.target ?? data.qq ?? data.id;
|
|
229
|
+
if (target == null)
|
|
230
|
+
return { type: segment.type, data };
|
|
231
|
+
return { type: 'at', data: { qq: String(target) } };
|
|
232
|
+
}
|
|
233
|
+
case 'reply': {
|
|
234
|
+
const messageId = data.message_id ?? data.id;
|
|
235
|
+
if (messageId == null)
|
|
236
|
+
return { type: segment.type, data };
|
|
237
|
+
return { type: 'reply', data: { id: String(messageId) } };
|
|
238
|
+
}
|
|
239
|
+
case 'face': {
|
|
240
|
+
if (data.id == null)
|
|
241
|
+
return { type: segment.type, data };
|
|
242
|
+
return { type: 'face', data: { id: data.id } };
|
|
243
|
+
}
|
|
244
|
+
case 'image':
|
|
245
|
+
case 'record':
|
|
246
|
+
case 'video':
|
|
247
|
+
return oneBotMediaSegment(segment.type, data);
|
|
248
|
+
case 'audio':
|
|
249
|
+
return oneBotMediaSegment('record', data);
|
|
250
|
+
case 'file':
|
|
251
|
+
logger.warn(formatCompact({
|
|
252
|
+
op: 'onebot11_outbound_media_dropped',
|
|
253
|
+
type: 'file',
|
|
254
|
+
reason: 'file_segment_not_supported',
|
|
255
|
+
}));
|
|
256
|
+
return null;
|
|
257
|
+
default:
|
|
258
|
+
return { type: segment.type, data };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Wire-encode an already-rendered outbound payload into OneBot 11 message segments.
|
|
263
|
+
* 入参假定已经 core `normalizeOutboundPayload` 归一为 canonical Segment[];
|
|
264
|
+
* 媒体段只消费 canonical `data.media`(无 MediaRef 的媒体段 warn + 丢弃),
|
|
265
|
+
* 其余 wire 形状段(at / 平台扩展段)原样透传。
|
|
266
|
+
*/
|
|
267
|
+
export function formatOutboundSegments(payload) {
|
|
268
|
+
if (typeof payload === 'string') {
|
|
269
|
+
return [{ type: 'text', data: { text: payload } }];
|
|
270
|
+
}
|
|
271
|
+
const items = Array.isArray(payload)
|
|
272
|
+
? payload
|
|
273
|
+
: payload && typeof payload === 'object' && 'type' in payload
|
|
274
|
+
? [payload]
|
|
275
|
+
: [];
|
|
276
|
+
if (items.length === 0) {
|
|
277
|
+
const text = payload == null
|
|
278
|
+
? ''
|
|
279
|
+
: typeof payload === 'object'
|
|
280
|
+
? JSON.stringify(payload)
|
|
281
|
+
: String(payload);
|
|
282
|
+
return [{ type: 'text', data: { text } }];
|
|
283
|
+
}
|
|
284
|
+
const segs = [];
|
|
285
|
+
for (const item of items) {
|
|
286
|
+
if (typeof item === 'string') {
|
|
287
|
+
segs.push({ type: 'text', data: { text: item } });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const seg = canonicalToOneBotSegment(item);
|
|
291
|
+
if (seg)
|
|
292
|
+
segs.push(seg);
|
|
293
|
+
}
|
|
294
|
+
return segs.length ? segs : [{ type: 'text', data: { text: '' } }];
|
|
295
|
+
}
|
|
296
|
+
export function buildSendAction(conversation, message) {
|
|
297
|
+
if (conversation.kind === 'group') {
|
|
298
|
+
return {
|
|
299
|
+
action: 'send_group_msg',
|
|
300
|
+
params: {
|
|
301
|
+
group_id: Number(conversation.id) || conversation.id,
|
|
302
|
+
message,
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
action: 'send_private_msg',
|
|
308
|
+
params: {
|
|
309
|
+
user_id: Number(conversation.id) || conversation.id,
|
|
310
|
+
message,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/** Build WS connect URL + headers (access_token via Bearer + query). */
|
|
315
|
+
export function buildWsConnectOptions(config) {
|
|
316
|
+
const headers = {};
|
|
317
|
+
let connectUrl = config.url;
|
|
318
|
+
if (config.access_token) {
|
|
319
|
+
headers.Authorization = `Bearer ${config.access_token}`;
|
|
320
|
+
const url = new URL(config.url);
|
|
321
|
+
url.searchParams.set('access_token', config.access_token);
|
|
322
|
+
connectUrl = url.toString();
|
|
323
|
+
}
|
|
324
|
+
const safeUrl = new URL(connectUrl);
|
|
325
|
+
safeUrl.searchParams.delete('access_token');
|
|
326
|
+
return { url: connectUrl, headers, safeUrl: safeUrl.toString() };
|
|
327
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { EndpointEventEmitter } from 'zhin.js/adapter';
|
|
2
|
+
import { type getAdapterLogger } from '@zhin.js/logger';
|
|
3
|
+
import type { OneBot11Event } from './protocol.js';
|
|
4
|
+
export interface OneBot11SideEventCaller {
|
|
5
|
+
callApi(action: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
6
|
+
}
|
|
7
|
+
export declare function receiveOneBot11SideEvent(emit: EndpointEventEmitter, endpointKey: string, caller: OneBot11SideEventCaller, raw: OneBot11Event, logger: ReturnType<typeof getAdapterLogger>): void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { receiveOneBotLikeSideEvent } from '@zhin.js/core';
|
|
2
|
+
import { formatCompact } from '@zhin.js/logger';
|
|
3
|
+
export function receiveOneBot11SideEvent(emit, endpointKey, caller, raw, logger) {
|
|
4
|
+
if (!emit)
|
|
5
|
+
return;
|
|
6
|
+
const record = raw;
|
|
7
|
+
const postType = String(record.post_type ?? '');
|
|
8
|
+
const requestType = String(record.request_type ?? '');
|
|
9
|
+
const isRequest = postType === 'request' || postType.startsWith('request.');
|
|
10
|
+
const isFriend = requestType === 'friend' || postType.includes('friend');
|
|
11
|
+
void receiveOneBotLikeSideEvent(emit, {
|
|
12
|
+
adapter: 'onebot11',
|
|
13
|
+
endpointKey,
|
|
14
|
+
platform: 'onebot',
|
|
15
|
+
raw: record,
|
|
16
|
+
...(isRequest ? {
|
|
17
|
+
approve: async (flag, remark) => {
|
|
18
|
+
await (isFriend
|
|
19
|
+
? caller.callApi('set_friend_add_request', { flag, approve: true, remark })
|
|
20
|
+
: caller.callApi('set_group_add_request', { flag, approve: true, reason: remark }));
|
|
21
|
+
},
|
|
22
|
+
reject: async (flag, reason) => {
|
|
23
|
+
await (isFriend
|
|
24
|
+
? caller.callApi('set_friend_add_request', { flag, approve: false })
|
|
25
|
+
: caller.callApi('set_group_add_request', { flag, approve: false, reason }));
|
|
26
|
+
},
|
|
27
|
+
} : {}),
|
|
28
|
+
}).catch((err) => {
|
|
29
|
+
logger.warn(formatCompact({
|
|
30
|
+
op: 'onebot11_side_event_failed',
|
|
31
|
+
endpoint: endpointKey,
|
|
32
|
+
post_type: postType || undefined,
|
|
33
|
+
error: err instanceof Error ? err.message : String(err),
|
|
34
|
+
}));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ClientEndpoint, type EndpointControl, type EndpointManagement, type EndpointSendRequest } from 'zhin.js/adapter';
|
|
2
|
+
import type { CapabilityId } from 'zhin.js';
|
|
3
|
+
import { type OneBot11WsConfig } from './protocol.js';
|
|
4
|
+
import { type OneBot11WsCreateOptions, type OneBot11WsSocket } from './ws-types.js';
|
|
5
|
+
import { type Onebot11Client } from './client.js';
|
|
6
|
+
export interface OneBot11WsEndpointOptions {
|
|
7
|
+
readonly id: CapabilityId;
|
|
8
|
+
readonly config: OneBot11WsConfig;
|
|
9
|
+
readonly createWebSocket?: (url: string, options: OneBot11WsCreateOptions) => OneBot11WsSocket;
|
|
10
|
+
}
|
|
11
|
+
export declare class OneBot11WsEndpoint extends ClientEndpoint<Onebot11Client> {
|
|
12
|
+
#private;
|
|
13
|
+
readonly client: Onebot11Client;
|
|
14
|
+
readonly management: EndpointManagement;
|
|
15
|
+
readonly control: EndpointControl;
|
|
16
|
+
readonly content: import("@zhin.js/adapter").EndpointContentPort;
|
|
17
|
+
constructor(options: OneBot11WsEndpointOptions);
|
|
18
|
+
start(): Promise<void>;
|
|
19
|
+
stop(): Promise<void>;
|
|
20
|
+
send({ conversation, payload }: EndpointSendRequest): Promise<string>;
|
|
21
|
+
recallMessage(messageId: string): Promise<void>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OneBot11 WS client endpoint — outbound connect to OneBot implementation.
|
|
3
|
+
*/
|
|
4
|
+
import WebSocket from 'ws';
|
|
5
|
+
import { ClientEndpoint, createRecallEndpointControl, createEndpointLifecycle, } from 'zhin.js/adapter';
|
|
6
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
7
|
+
import { createOneBot11EndpointManagement } from './endpoint-management.js';
|
|
8
|
+
import { buildSendAction, buildWsConnectOptions, formatInboundContent, formatInboundMetadata, formatOutboundSegments, isMessageEvent, onebot11InboundConversation, senderDisplayName, senderUserId, } from './protocol.js';
|
|
9
|
+
import { receiveOneBot11SideEvent } from './side-event-dispatch.js';
|
|
10
|
+
import { createOneBot11ContentPort } from './content-port.js';
|
|
11
|
+
import { callOneBot11WsAction, handleOneBot11WsMessage, rejectAllPending, } from './ws-transport.js';
|
|
12
|
+
import { callOnebot11Client, createOnebot11EndpointClient, forwardOnebot11ClientEvents } from './client.js';
|
|
13
|
+
export class OneBot11WsEndpoint extends ClientEndpoint {
|
|
14
|
+
client;
|
|
15
|
+
#logger;
|
|
16
|
+
#options;
|
|
17
|
+
management;
|
|
18
|
+
control = createRecallEndpointControl((id) => this.recallMessage(id));
|
|
19
|
+
content;
|
|
20
|
+
#lifecycle;
|
|
21
|
+
#ws;
|
|
22
|
+
#requestId = { value: 0 };
|
|
23
|
+
#pending = new Map();
|
|
24
|
+
constructor(options) {
|
|
25
|
+
super();
|
|
26
|
+
this.#logger = getAdapterLogger('onebot11', options.config.id);
|
|
27
|
+
this.#options = options;
|
|
28
|
+
this.client = createOnebot11EndpointClient(options.config, (action, params) => this.#callApi(action, params));
|
|
29
|
+
const callApi = (action, params) => callOnebot11Client(this.client, action, params);
|
|
30
|
+
this.management = createOneBot11EndpointManagement({ callApi });
|
|
31
|
+
this.content = createOneBot11ContentPort(callApi);
|
|
32
|
+
this.bindClientEvents((receive) => forwardOnebot11ClientEvents(this.client, receive), (name, payload) => {
|
|
33
|
+
if (name === 'event')
|
|
34
|
+
this.#admitRaw(payload);
|
|
35
|
+
}, (_name, error) => this.#warnPlatformEvent(error));
|
|
36
|
+
const { config } = options;
|
|
37
|
+
this.#lifecycle = createEndpointLifecycle({
|
|
38
|
+
name: config.id,
|
|
39
|
+
reconnect: {
|
|
40
|
+
initialIntervalMs: config.reconnect_interval,
|
|
41
|
+
// 固定间隔(multiplier 1、无抖动),对齐旧 reconnect_interval 语义
|
|
42
|
+
multiplier: 1,
|
|
43
|
+
maxIntervalMs: config.reconnect_interval,
|
|
44
|
+
jitterMs: 0,
|
|
45
|
+
},
|
|
46
|
+
heartbeat: { intervalMs: config.heartbeat_interval },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async start() {
|
|
50
|
+
if (this.#lifecycle.started)
|
|
51
|
+
return;
|
|
52
|
+
try {
|
|
53
|
+
await this.#lifecycle.start((handle) => this.#connect(handle));
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
// start 失败必须清理现场(状态复位由基座保证)
|
|
57
|
+
if (this.#ws) {
|
|
58
|
+
try {
|
|
59
|
+
this.#ws.close();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* ignore */
|
|
63
|
+
}
|
|
64
|
+
this.#ws = undefined;
|
|
65
|
+
}
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async stop() {
|
|
70
|
+
this.close();
|
|
71
|
+
// 基座负责:清重连/心跳定时器、强关 ws、唤醒 stop-during-connect 竞态
|
|
72
|
+
await this.#lifecycle.stop();
|
|
73
|
+
rejectAllPending(this.#pending);
|
|
74
|
+
this.#ws = undefined;
|
|
75
|
+
}
|
|
76
|
+
async send({ conversation, payload }) {
|
|
77
|
+
const message = formatOutboundSegments(payload);
|
|
78
|
+
const { action, params } = buildSendAction(conversation, message);
|
|
79
|
+
const data = await callOnebot11Client(this.client, action, params);
|
|
80
|
+
const messageId = data?.message_id != null ? String(data.message_id) : '';
|
|
81
|
+
this.#logger.debug(formatCompact({
|
|
82
|
+
op: 'onebot11_send',
|
|
83
|
+
endpoint: this.#options.config.id,
|
|
84
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
85
|
+
messageId,
|
|
86
|
+
}));
|
|
87
|
+
return messageId;
|
|
88
|
+
}
|
|
89
|
+
async recallMessage(messageId) {
|
|
90
|
+
if (!messageId)
|
|
91
|
+
return;
|
|
92
|
+
await callOnebot11Client(this.client, 'delete_msg', { message_id: Number(messageId) });
|
|
93
|
+
}
|
|
94
|
+
#callApi(action, params = {}) {
|
|
95
|
+
return callOneBot11WsAction(this.#ws, this.#pending, this.#requestId, action, params);
|
|
96
|
+
}
|
|
97
|
+
#admitRaw(ev) {
|
|
98
|
+
if (!isMessageEvent(ev)) {
|
|
99
|
+
receiveOneBot11SideEvent((name, payload) => this.emit(name, payload), this.#options.config.id, { callApi: (action, params) => callOnebot11Client(this.client, action, params) }, ev, this.#logger);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const conversation = onebot11InboundConversation(String(this.#options.id), ev);
|
|
103
|
+
const content = formatInboundContent(ev);
|
|
104
|
+
const inbound = formatInboundMetadata(ev, this.#options.config.id);
|
|
105
|
+
void this.emit('message.receive', {
|
|
106
|
+
conversation,
|
|
107
|
+
message: { conversation, id: String(ev.message_id) },
|
|
108
|
+
content,
|
|
109
|
+
sender: {
|
|
110
|
+
id: senderUserId(ev),
|
|
111
|
+
name: senderDisplayName(ev) || undefined,
|
|
112
|
+
...(ev.sender?.role ? { roles: [ev.sender.role] } : {}),
|
|
113
|
+
},
|
|
114
|
+
endpointId: inbound.endpointId,
|
|
115
|
+
...(inbound.mentioned ? { mentioned: true } : {}),
|
|
116
|
+
...(inbound.replyTo ? { replyTo: inbound.replyTo } : {}),
|
|
117
|
+
metadata: inbound.metadata,
|
|
118
|
+
}).catch((err) => {
|
|
119
|
+
this.#logger.warn(formatCompact({
|
|
120
|
+
op: 'onebot11_gateway_receive_failed',
|
|
121
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
122
|
+
error: err instanceof Error ? err.message : String(err),
|
|
123
|
+
}));
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
#warnPlatformEvent(error) {
|
|
127
|
+
this.#logger.warn(formatCompact({
|
|
128
|
+
op: 'onebot11_platform_event_failed',
|
|
129
|
+
error: error instanceof Error ? error.message : String(error),
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
async #connect(handle) {
|
|
133
|
+
const { url, headers, safeUrl } = buildWsConnectOptions(this.#options.config);
|
|
134
|
+
const create = this.#options.createWebSocket
|
|
135
|
+
?? ((connectUrl, options) => new WebSocket(connectUrl, { headers: options.headers }));
|
|
136
|
+
await new Promise((resolve, reject) => {
|
|
137
|
+
let settled = false;
|
|
138
|
+
const ws = create(url, { headers });
|
|
139
|
+
this.#ws = ws;
|
|
140
|
+
handle.onForceClose(() => {
|
|
141
|
+
try {
|
|
142
|
+
ws.close();
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
/* ignore */
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
ws.on('open', () => {
|
|
149
|
+
if (settled)
|
|
150
|
+
return;
|
|
151
|
+
settled = true;
|
|
152
|
+
if (!this.#options.config.access_token) {
|
|
153
|
+
this.#logger.warn(formatCompact({
|
|
154
|
+
endpoint: this.#options.config.id,
|
|
155
|
+
ok: false,
|
|
156
|
+
error: 'missing access_token',
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
this.#logger.debug(formatCompact({
|
|
160
|
+
endpoint: this.#options.config.id,
|
|
161
|
+
mode: 'ws',
|
|
162
|
+
url: safeUrl,
|
|
163
|
+
}));
|
|
164
|
+
this.#lifecycle.startHeartbeat(() => this.#ws?.ping?.());
|
|
165
|
+
resolve();
|
|
166
|
+
});
|
|
167
|
+
ws.on('message', (data) => {
|
|
168
|
+
handleOneBot11WsMessage(data, {
|
|
169
|
+
endpointId: this.#options.config.id,
|
|
170
|
+
pending: this.#pending,
|
|
171
|
+
ingest: (ev) => this.client.ingest(ev),
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
ws.on('close', (code, reason) => {
|
|
175
|
+
const reasonStr = typeof reason === 'string'
|
|
176
|
+
? reason
|
|
177
|
+
: Buffer.isBuffer(reason)
|
|
178
|
+
? reason.toString()
|
|
179
|
+
: String(reason ?? '');
|
|
180
|
+
const codeNum = typeof code === 'number' ? code : Number(code ?? 0);
|
|
181
|
+
const codeHint = codeNum === 1005
|
|
182
|
+
? ' [无状态,多为服务端/代理未发 close 帧即断开]'
|
|
183
|
+
: codeNum === 1006
|
|
184
|
+
? ' [异常关闭]'
|
|
185
|
+
: '';
|
|
186
|
+
if (!settled) {
|
|
187
|
+
settled = true;
|
|
188
|
+
reject(new Error(`OneBot11 WS 关闭: ${codeNum} ${reasonStr}`));
|
|
189
|
+
}
|
|
190
|
+
// 断开日志与重连武装均由基座负责;仅曾 open 的连接才会武装重连,
|
|
191
|
+
// 初始连接失败由 start() 的拒绝路径复位,不产生僵尸重连。
|
|
192
|
+
handle.notifyClosed(`OneBot11 WS 关闭: ${codeNum} ${reasonStr || 'closed'}${codeHint}`);
|
|
193
|
+
});
|
|
194
|
+
ws.on('error', (err) => {
|
|
195
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
196
|
+
this.#logger.warn(formatCompact({
|
|
197
|
+
op: 'ws_error',
|
|
198
|
+
endpoint: this.#options.config.id,
|
|
199
|
+
ok: false,
|
|
200
|
+
error: error.message,
|
|
201
|
+
}));
|
|
202
|
+
if (!settled) {
|
|
203
|
+
settled = true;
|
|
204
|
+
reject(error);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type OneBot11ActionResponse, type OneBot11Event } from './protocol.js';
|
|
2
|
+
import { type OneBot11PendingAction, type OneBot11WsSocket } from './ws-types.js';
|
|
3
|
+
export declare function decodeWsPayload(data: unknown): string;
|
|
4
|
+
export declare function handleOneBot11WsMessage(data: unknown, options: {
|
|
5
|
+
readonly endpointId: string;
|
|
6
|
+
readonly pending: Map<string, OneBot11PendingAction>;
|
|
7
|
+
readonly ingest: (ev: OneBot11Event) => void;
|
|
8
|
+
}): void;
|
|
9
|
+
export declare function callOneBot11WsAction(ws: OneBot11WsSocket | undefined, pending: Map<string, OneBot11PendingAction>, requestId: {
|
|
10
|
+
value: number;
|
|
11
|
+
}, action: string, params: Record<string, unknown>): Promise<OneBot11ActionResponse>;
|
|
12
|
+
export declare function rejectAllPending(pending: Map<string, OneBot11PendingAction>, message?: string): void;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { clearTimeout, setTimeout } from 'node:timers';
|
|
2
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
3
|
+
import { WS_OPEN, } from './ws-types.js';
|
|
4
|
+
const logger = getLogger('onebot11');
|
|
5
|
+
export function decodeWsPayload(data) {
|
|
6
|
+
if (typeof data === 'string')
|
|
7
|
+
return data;
|
|
8
|
+
if (Buffer.isBuffer(data))
|
|
9
|
+
return data.toString();
|
|
10
|
+
if (data instanceof ArrayBuffer)
|
|
11
|
+
return new TextDecoder().decode(data);
|
|
12
|
+
return String(data ?? '');
|
|
13
|
+
}
|
|
14
|
+
export function handleOneBot11WsMessage(data, options) {
|
|
15
|
+
try {
|
|
16
|
+
const raw = decodeWsPayload(data);
|
|
17
|
+
const msg = JSON.parse(raw);
|
|
18
|
+
if ('echo' in msg && typeof msg.echo === 'string') {
|
|
19
|
+
const resp = msg;
|
|
20
|
+
const pending = options.pending.get(resp.echo);
|
|
21
|
+
if (pending) {
|
|
22
|
+
options.pending.delete(resp.echo);
|
|
23
|
+
clearTimeout(pending.timeout);
|
|
24
|
+
pending.resolve(resp);
|
|
25
|
+
}
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
options.ingest(msg);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
logger.warn(formatCompact({
|
|
32
|
+
op: 'onebot11_parse_failed',
|
|
33
|
+
endpoint: options.endpointId,
|
|
34
|
+
error: error instanceof Error ? error.message : String(error),
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function callOneBot11WsAction(ws, pending, requestId, action, params) {
|
|
39
|
+
if (!ws || ws.readyState !== WS_OPEN) {
|
|
40
|
+
return Promise.reject(new Error('WebSocket 未连接'));
|
|
41
|
+
}
|
|
42
|
+
const echo = `ob11_${++requestId.value}`;
|
|
43
|
+
const req = { action, params, echo };
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const timeout = setTimeout(() => {
|
|
46
|
+
pending.delete(echo);
|
|
47
|
+
reject(new Error(`OneBot11 动作超时: ${action}`));
|
|
48
|
+
}, 30_000);
|
|
49
|
+
pending.set(echo, { resolve, reject, timeout });
|
|
50
|
+
ws.send(JSON.stringify(req));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export function rejectAllPending(pending, message = '连接已关闭') {
|
|
54
|
+
for (const [, entry] of pending) {
|
|
55
|
+
clearTimeout(entry.timeout);
|
|
56
|
+
entry.reject(new Error(message));
|
|
57
|
+
}
|
|
58
|
+
pending.clear();
|
|
59
|
+
}
|