@zhin.js/adapter-onebot12 4.0.0 → 4.0.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/src/protocol.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * Canonicalization is owned by gateway/core before endpoint.send.
4
4
  * Spec: https://12.onebot.dev/
5
5
  */
6
+ import { isMediaRef, mediaRefFromLegacyData, type MediaRef } from '@zhin.js/core';
6
7
 
7
8
  /** Transitional legacy endpoint row (`endpoints[]` with `context: onebot12`). */
8
9
  export interface OneBot12LegacyEndpointRow {
@@ -273,9 +274,85 @@ export function isBotMentioned(ev: OneBot12Event): boolean {
273
274
  );
274
275
  }
275
276
 
277
+ /** OneBot 12 携带媒体的段类型。 */
278
+ const ONEBOT12_MEDIA_TYPES = new Set(['image', 'voice', 'audio', 'video', 'file']);
279
+
280
+ /** 媒体段 data 里的 canonical-only / legacy 媒体字段,归一后不重复进 wire。 */
281
+ const MEDIA_DATA_SKIP_KEYS = new Set([
282
+ 'media', 'alt', 'mime_type', 'url', 'file', 'base64', 'data', 'path',
283
+ ]);
284
+
285
+ /**
286
+ * canonical MediaRef → OneBot 12 媒体字段(扩展字段降级形状)。
287
+ * spec 正式投递形状是 `file_id`(先 upload_file 物化,见
288
+ * {@link uploadOneBot12MediaSegments});上传失败时按常见扩展字段
289
+ * 降级输出:url → `url`、base64 → `data`、本地路径 → `path`。
290
+ */
291
+ export function mediaRefToOneBot12Fields(media: MediaRef): Record<string, unknown> {
292
+ if (media.kind === 'base64') {
293
+ const value = media.value.startsWith('base64://')
294
+ ? media.value.slice('base64://'.length)
295
+ : media.value;
296
+ return { data: value };
297
+ }
298
+ if (media.kind === 'path') {
299
+ return { path: media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value };
300
+ }
301
+ return { url: media.value };
302
+ }
303
+
304
+ function oneBot12MediaExtra(data: Record<string, unknown>): Record<string, unknown> {
305
+ const extra: Record<string, unknown> = {};
306
+ for (const [key, value] of Object.entries(data)) {
307
+ if (!MEDIA_DATA_SKIP_KEYS.has(key)) extra[key] = value;
308
+ }
309
+ return extra;
310
+ }
311
+
312
+ function oneBot12MediaSegment(
313
+ type: string,
314
+ data: Record<string, unknown>,
315
+ ): OneBot12Segment {
316
+ // 已物化为 file_id 的段是 spec 正式形状,原样透传。
317
+ if (typeof data.file_id === 'string' && data.file_id) return { type, data };
318
+ const media = isMediaRef(data.media) ? data.media : mediaRefFromLegacyData(data);
319
+ if (!media) return { type, data };
320
+ return { type, data: { ...oneBot12MediaExtra(data), ...mediaRefToOneBot12Fields(media) } };
321
+ }
322
+
323
+ /**
324
+ * canonical Segment → OneBot 12 数组段:
325
+ * - mention → mention(`user_id: target`,`target: 'all'` → mention_all);
326
+ * - reply(`message_id`)→ reply(`message_id`);
327
+ * - image / voice / audio / video / file 的 MediaRef → url/data/path 扩展字段;
328
+ * - 其余(已是 wire 形状的段、平台扩展段)原样透传。
329
+ */
330
+ function canonicalToOneBotSegment(segment: OneBot12WireSegment): OneBot12Segment {
331
+ const data = segment.data ?? {};
332
+ switch (segment.type) {
333
+ case 'mention': {
334
+ const target = data.target ?? data.user_id ?? data.id;
335
+ if (target == null) return { type: segment.type, data };
336
+ if (String(target) === 'all') return { type: 'mention_all', data: {} };
337
+ return { type: 'mention', data: { user_id: String(target) } };
338
+ }
339
+ case 'reply': {
340
+ const messageId = data.message_id ?? data.id;
341
+ if (messageId == null) return { type: segment.type, data };
342
+ return { type: 'reply', data: { message_id: String(messageId) } };
343
+ }
344
+ default:
345
+ if (ONEBOT12_MEDIA_TYPES.has(segment.type)) {
346
+ return oneBot12MediaSegment(segment.type, data);
347
+ }
348
+ return { type: segment.type, data };
349
+ }
350
+ }
351
+
276
352
  /**
277
353
  * Wire-encode an already-rendered outbound payload into OneBot 12 message segments.
278
- * Segment canonicalization is intentionally not done here.
354
+ * 入参假定已经 core `normalizeOutboundPayload` 归一为 canonical Segment[];
355
+ * 旧 wire 形状(mention / 裸 `{url,file,base64}`)保持兼容透传。
279
356
  */
280
357
  export function formatOutboundSegments(payload: unknown): OneBot12Segment[] {
281
358
  if (typeof payload === 'string') {
@@ -303,11 +380,110 @@ export function formatOutboundSegments(payload: unknown): OneBot12Segment[] {
303
380
  segs.push({ type: 'text', data: { text: item } });
304
381
  continue;
305
382
  }
306
- segs.push({ type: item.type, data: item.data ?? {} });
383
+ segs.push(canonicalToOneBotSegment(item));
307
384
  }
308
385
  return segs.length ? segs : [{ type: 'text', data: { text: '' } }];
309
386
  }
310
387
 
388
+ /** 端点动作调用签名(WS echo 请求 / webhook api_url HTTP)。 */
389
+ export type OneBot12CallAction = (
390
+ action: string,
391
+ params: Record<string, unknown>,
392
+ ) => Promise<unknown>;
393
+
394
+ /** upload_file 文件名:优先段 data.name/filename,URL/路径取 basename,再按 mime 给默认名。 */
395
+ function oneBot12UploadName(
396
+ segmentType: string,
397
+ data: Record<string, unknown>,
398
+ media: MediaRef,
399
+ ): string {
400
+ const named = data.name ?? data.filename;
401
+ if (typeof named === 'string' && named) return named;
402
+ if (media.kind === 'url') {
403
+ try {
404
+ const base = new URL(media.value).pathname.split('/').filter(Boolean).pop();
405
+ if (base) return base;
406
+ } catch {
407
+ /* ignore */
408
+ }
409
+ }
410
+ if (media.kind === 'path') {
411
+ const base = media.value.split(/[\\/]/).filter(Boolean).pop();
412
+ if (base) return base;
413
+ }
414
+ const ext = media.mime_type?.split('/')[1]?.split(';')[0];
415
+ return `${segmentType}.${ext || 'bin'}`;
416
+ }
417
+
418
+ /** canonical MediaRef → OB12 `upload_file` 动作参数(spec: type url/path/data)。 */
419
+ export function mediaRefToOneBot12UploadParams(
420
+ segmentType: string,
421
+ data: Record<string, unknown>,
422
+ media: MediaRef,
423
+ ): Record<string, unknown> | undefined {
424
+ const name = oneBot12UploadName(segmentType, data, media);
425
+ if (media.kind === 'url') return { type: 'url', name, url: media.value };
426
+ if (media.kind === 'path') {
427
+ const path = media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value;
428
+ return { type: 'path', name, path };
429
+ }
430
+ if (media.kind === 'base64') {
431
+ const value = media.value.startsWith('base64://')
432
+ ? media.value.slice('base64://'.length)
433
+ : media.value;
434
+ return { type: 'data', name, data: value };
435
+ }
436
+ return undefined;
437
+ }
438
+
439
+ async function uploadOneMediaSegment(
440
+ item: unknown,
441
+ callAction: OneBot12CallAction,
442
+ onUploadFailed?: (error: unknown) => void,
443
+ ): Promise<unknown> {
444
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
445
+ const segment = item as OneBot12WireSegment;
446
+ if (typeof segment.type !== 'string' || !ONEBOT12_MEDIA_TYPES.has(segment.type)) return item;
447
+ const data = segment.data ?? {};
448
+ if (typeof data.file_id === 'string' && data.file_id) return item;
449
+ const media = isMediaRef(data.media) ? data.media : mediaRefFromLegacyData(data);
450
+ if (!media) return item;
451
+ // kind=file:平台不透明引用(OB12 file_id 复投),直接物化为 file_id。
452
+ if (media.kind === 'file') {
453
+ return { type: segment.type, data: { ...oneBot12MediaExtra(data), file_id: media.value } };
454
+ }
455
+ const params = mediaRefToOneBot12UploadParams(segment.type, data, media);
456
+ if (!params) return item;
457
+ try {
458
+ const result = await callAction('upload_file', params) as { file_id?: unknown } | undefined;
459
+ if (result && typeof result.file_id === 'string' && result.file_id) {
460
+ return { type: segment.type, data: { ...oneBot12MediaExtra(data), file_id: result.file_id } };
461
+ }
462
+ throw new Error('upload_file 响应缺少 file_id');
463
+ } catch (error) {
464
+ // 上传失败降级:保留原段,由 formatOutboundSegments 走扩展字段(url/data/path)
465
+ onUploadFailed?.(error);
466
+ return item;
467
+ }
468
+ }
469
+
470
+ /**
471
+ * 出站媒体段物化:image/voice/audio/video/file 段的 MediaRef(url/base64/path)
472
+ * 先经 `upload_file` 换 file_id(spec 正式投递形状),再交给
473
+ * {@link formatOutboundSegments} 编码;kind=file 的 MediaRef 直接按 file_id 复投。
474
+ * 上传失败保留原段(降级扩展字段透传)并回调 onUploadFailed。
475
+ */
476
+ export async function uploadOneBot12MediaSegments(
477
+ payload: unknown,
478
+ callAction: OneBot12CallAction,
479
+ onUploadFailed?: (error: unknown) => void,
480
+ ): Promise<unknown> {
481
+ if (Array.isArray(payload)) {
482
+ return Promise.all(payload.map((item) => uploadOneMediaSegment(item, callAction, onUploadFailed)));
483
+ }
484
+ return uploadOneMediaSegment(payload, callAction, onUploadFailed);
485
+ }
486
+
311
487
  export function buildSendMessageParams(
312
488
  target: string,
313
489
  message: OneBot12Segment[],
@@ -349,6 +525,7 @@ export async function callOneBot12Action(
349
525
  method: 'POST',
350
526
  headers,
351
527
  body: JSON.stringify(body),
528
+ signal: AbortSignal.timeout(30_000),
352
529
  });
353
530
 
354
531
  const text = await res.text();
package/src/webhook.ts CHANGED
@@ -2,11 +2,12 @@
2
2
  * OneBot12 HTTP webhook endpoint — POST inbound + api_url outbound.
3
3
  */
4
4
  import type { IncomingMessage, ServerResponse } from 'node:http';
5
- import type { EndpointInstance } from '@zhin.js/adapter';
5
+ import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
6
6
  import type { MessageGateway } from '@zhin.js/core/runtime';
7
7
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
8
8
  import { formatCompact, getLogger } from '@zhin.js/logger';
9
9
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
10
+ import { createOneBot12EndpointManagement } from './endpoint-management.js';
10
11
  import {
11
12
  buildSendMessageParams,
12
13
  callOneBot12Action,
@@ -17,6 +18,7 @@ import {
17
18
  isMessageEvent,
18
19
  senderNickname,
19
20
  senderUserId,
21
+ uploadOneBot12MediaSegments,
20
22
  type OneBot12Event,
21
23
  type OneBot12WebhookConfig,
22
24
  } from './protocol.js';
@@ -34,6 +36,7 @@ export interface OneBot12WebhookEndpointOptions {
34
36
 
35
37
  export class OneBot12WebhookEndpoint implements EndpointInstance {
36
38
  readonly #options: OneBot12WebhookEndpointOptions;
39
+ readonly management: EndpointManagement = createOneBot12EndpointManagement(this);
37
40
  readonly #callAction: typeof callOneBot12Action;
38
41
  #routeReleases: HttpRouteRegistration[] = [];
39
42
  #open = false;
@@ -47,6 +50,15 @@ export class OneBot12WebhookEndpoint implements EndpointInstance {
47
50
  async start(): Promise<void> {
48
51
  if (this.#started) return;
49
52
  this.#started = true;
53
+ if (!this.#options.config.access_token) {
54
+ // webhook 模式未配 access_token 时任何 POST 都会被放行(verifyOneBotAccessToken 直接 return true)
55
+ logger.warn(formatCompact({
56
+ endpoint: this.#options.config.name,
57
+ mode: 'webhook',
58
+ ok: false,
59
+ error: 'missing access_token',
60
+ }));
61
+ }
50
62
  this.#setupRoutes();
51
63
  logger.info(formatCompact({
52
64
  op: 'listen',
@@ -72,18 +84,20 @@ export class OneBot12WebhookEndpoint implements EndpointInstance {
72
84
  }
73
85
 
74
86
  async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
75
- const apiUrl = this.#options.config.api_url;
76
- if (!apiUrl) {
77
- throw new Error('OneBot12 connection:webhook requires api_url for outbound send');
78
- }
79
- const message = formatOutboundSegments(payload);
80
- const params = buildSendMessageParams(target, message);
81
- const resp = await this.#callAction(
82
- { url: apiUrl, access_token: this.#options.config.access_token },
83
- 'send_message',
84
- params,
87
+ const materialized = await uploadOneBot12MediaSegments(
88
+ payload,
89
+ (action, params) => this.callApi(action, params),
90
+ (error) => {
91
+ logger.warn(formatCompact({
92
+ op: 'onebot12_upload_failed',
93
+ endpoint: this.#options.config.name,
94
+ error: error instanceof Error ? error.message : String(error),
95
+ }));
96
+ },
85
97
  );
86
- const data = resp.data as { message_id?: string } | undefined;
98
+ const message = formatOutboundSegments(materialized);
99
+ const params = buildSendMessageParams(target, message);
100
+ const data = await this.callApi('send_message', params) as { message_id?: string } | undefined;
87
101
  const messageId = data?.message_id ?? '';
88
102
  logger.debug(formatCompact({
89
103
  op: 'onebot12_send',
@@ -94,6 +108,20 @@ export class OneBot12WebhookEndpoint implements EndpointInstance {
94
108
  return messageId;
95
109
  }
96
110
 
111
+ /** Public API for management surface / callers;webhook 模式走 api_url。 */
112
+ async callApi(action: string, params: Record<string, unknown> = {}): Promise<unknown> {
113
+ const apiUrl = this.#options.config.api_url;
114
+ if (!apiUrl) {
115
+ throw new Error('OneBot12 connection:webhook requires api_url for outbound api');
116
+ }
117
+ const resp = await this.#callAction(
118
+ { url: apiUrl, access_token: this.#options.config.access_token },
119
+ action,
120
+ params,
121
+ );
122
+ return resp.data;
123
+ }
124
+
97
125
  admit(ev: OneBot12Event): void {
98
126
  if (!this.#open || !isMessageEvent(ev)) return;
99
127
  const target = formatInboundTarget(ev);
@@ -2,11 +2,18 @@
2
2
  * OneBot12 WS client endpoint — outbound connect to OneBot implementation.
3
3
  */
4
4
  import WebSocket from 'ws';
5
- import { clearInterval, clearTimeout } from 'node:timers';
6
- import type { EndpointInstance } from '@zhin.js/adapter';
5
+ import { clearTimeout } from 'node:timers';
6
+ import {
7
+ createEndpointLifecycle,
8
+ type EndpointConnectHandle,
9
+ type EndpointInstance,
10
+ type EndpointLifecycle,
11
+ type EndpointManagement,
12
+ } from '@zhin.js/adapter';
7
13
  import type { MessageGateway } from '@zhin.js/core/runtime';
8
14
  import { formatCompact, getLogger } from '@zhin.js/logger';
9
15
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
16
+ import { createOneBot12EndpointManagement } from './endpoint-management.js';
10
17
  import {
11
18
  buildSendMessageParams,
12
19
  buildWsConnectOptions,
@@ -17,6 +24,7 @@ import {
17
24
  isMessageEvent,
18
25
  senderNickname,
19
26
  senderUserId,
27
+ uploadOneBot12MediaSegments,
20
28
  type OneBot12ActionRequest,
21
29
  type OneBot12ActionResponse,
22
30
  type OneBot12Event,
@@ -42,9 +50,9 @@ export interface OneBot12WsEndpointOptions {
42
50
 
43
51
  export class OneBot12WsEndpoint implements EndpointInstance {
44
52
  readonly #options: OneBot12WsEndpointOptions;
53
+ readonly management: EndpointManagement = createOneBot12EndpointManagement(this);
54
+ readonly #lifecycle: EndpointLifecycle;
45
55
  #ws?: OneBot12WsSocket;
46
- #reconnectTimer?: NodeJS.Timeout;
47
- #heartbeatTimer?: NodeJS.Timeout;
48
56
  #requestId = 0;
49
57
  #pending = new Map<string, {
50
58
  resolve: (value: unknown) => void;
@@ -52,18 +60,39 @@ export class OneBot12WsEndpoint implements EndpointInstance {
52
60
  timeout: NodeJS.Timeout;
53
61
  }>();
54
62
  #open = false;
55
- #started = false;
56
- #stopping = false;
57
63
 
58
64
  constructor(options: OneBot12WsEndpointOptions) {
59
65
  this.#options = options;
66
+ const { config } = options;
67
+ this.#lifecycle = createEndpointLifecycle({
68
+ name: config.name,
69
+ reconnect: {
70
+ initialIntervalMs: config.reconnect_interval,
71
+ // 固定间隔(multiplier 1、无抖动),对齐旧 reconnect_interval 语义
72
+ multiplier: 1,
73
+ maxIntervalMs: config.reconnect_interval,
74
+ jitterMs: 0,
75
+ },
76
+ heartbeat: { intervalMs: config.heartbeat_interval },
77
+ });
60
78
  }
61
79
 
62
80
  async start(): Promise<void> {
63
- if (this.#started) return;
64
- this.#started = true;
65
- this.#stopping = false;
66
- await this.#connect();
81
+ if (this.#lifecycle.started) return;
82
+ try {
83
+ await this.#lifecycle.start((handle) => this.#connect(handle));
84
+ } catch (err) {
85
+ // start 失败必须清理现场(状态复位由基座保证)
86
+ if (this.#ws) {
87
+ try {
88
+ this.#ws.close();
89
+ } catch {
90
+ /* ignore */
91
+ }
92
+ this.#ws = undefined;
93
+ }
94
+ throw err;
95
+ }
67
96
  }
68
97
 
69
98
  open(): void {
@@ -76,34 +105,29 @@ export class OneBot12WsEndpoint implements EndpointInstance {
76
105
 
77
106
  async stop(): Promise<void> {
78
107
  this.#open = false;
79
- this.#stopping = true;
80
- this.#started = false;
81
- if (this.#reconnectTimer) {
82
- clearTimeout(this.#reconnectTimer);
83
- this.#reconnectTimer = undefined;
84
- }
85
- if (this.#heartbeatTimer) {
86
- clearInterval(this.#heartbeatTimer);
87
- this.#heartbeatTimer = undefined;
88
- }
108
+ // 基座负责:清重连/心跳定时器、强关 ws、唤醒 stop-during-connect 竞态
109
+ await this.#lifecycle.stop();
89
110
  for (const [, pending] of this.#pending) {
90
111
  clearTimeout(pending.timeout);
91
112
  pending.reject(new Error('连接已关闭'));
92
113
  }
93
114
  this.#pending.clear();
94
- if (this.#ws) {
95
- try {
96
- this.#ws.close();
97
- } catch {
98
- /* ignore */
99
- }
100
- this.#ws = undefined;
101
- }
102
- logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
115
+ this.#ws = undefined;
103
116
  }
104
117
 
105
118
  async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
106
- const message = formatOutboundSegments(payload);
119
+ const materialized = await uploadOneBot12MediaSegments(
120
+ payload,
121
+ (action, params) => this.callApi(action, params),
122
+ (error) => {
123
+ logger.warn(formatCompact({
124
+ op: 'onebot12_upload_failed',
125
+ endpoint: this.#options.config.name,
126
+ error: error instanceof Error ? error.message : String(error),
127
+ }));
128
+ },
129
+ );
130
+ const message = formatOutboundSegments(materialized);
107
131
  const params = buildSendMessageParams(target, message);
108
132
  const data = await this.#callAction('send_message', params) as { message_id?: string } | undefined;
109
133
  const messageId = data?.message_id ?? '';
@@ -116,6 +140,11 @@ export class OneBot12WsEndpoint implements EndpointInstance {
116
140
  return messageId;
117
141
  }
118
142
 
143
+ /** Public API for management surface / callers. */
144
+ callApi(action: string, params: Record<string, unknown> = {}): Promise<unknown> {
145
+ return this.#callAction(action, params);
146
+ }
147
+
119
148
  /** Test / internal: admit a parsed event when the endpoint is open. */
120
149
  admit(ev: OneBot12Event): void {
121
150
  if (!this.#open || !isMessageEvent(ev)) return;
@@ -149,7 +178,7 @@ export class OneBot12WsEndpoint implements EndpointInstance {
149
178
  });
150
179
  }
151
180
 
152
- async #connect(): Promise<void> {
181
+ async #connect(handle: EndpointConnectHandle): Promise<void> {
153
182
  const { url, headers, safeUrl } = buildWsConnectOptions(this.#options.config);
154
183
  const create = this.#options.createWebSocket
155
184
  ?? ((connectUrl: string, options: OneBot12WsCreateOptions) =>
@@ -159,6 +188,13 @@ export class OneBot12WsEndpoint implements EndpointInstance {
159
188
  let settled = false;
160
189
  const ws = create(url, { headers });
161
190
  this.#ws = ws;
191
+ handle.onForceClose(() => {
192
+ try {
193
+ ws.close();
194
+ } catch {
195
+ /* ignore */
196
+ }
197
+ });
162
198
 
163
199
  ws.on('open', () => {
164
200
  if (settled) return;
@@ -168,7 +204,9 @@ export class OneBot12WsEndpoint implements EndpointInstance {
168
204
  mode: 'ws',
169
205
  url: safeUrl,
170
206
  }));
171
- this.#startHeartbeat();
207
+ this.#lifecycle.startHeartbeat(() => {
208
+ this.#callAction('get_status', {}).catch(() => {});
209
+ });
172
210
  resolve();
173
211
  });
174
212
 
@@ -183,18 +221,13 @@ export class OneBot12WsEndpoint implements EndpointInstance {
183
221
  ? reason.toString()
184
222
  : String(reason ?? '');
185
223
  const codeNum = typeof code === 'number' ? code : Number(code ?? 0);
186
- logger.warn(formatCompact({
187
- op: 'disconnect',
188
- endpoint: this.#options.config.name,
189
- code: codeNum,
190
- error: reasonStr || 'closed',
191
- reconnect_ms: this.#options.config.reconnect_interval,
192
- }));
193
224
  if (!settled) {
194
225
  settled = true;
195
226
  reject(new Error(`OneBot12 WS 关闭: ${codeNum} ${reasonStr}`));
196
227
  }
197
- this.#scheduleReconnect();
228
+ // 断开日志与重连武装均由基座负责;仅曾 open 的连接才会武装重连,
229
+ // 初始连接失败由 start() 的拒绝路径复位,不产生僵尸重连。
230
+ handle.notifyClosed(`OneBot12 WS 关闭: ${codeNum} ${reasonStr || 'closed'}`);
198
231
  });
199
232
 
200
233
  ws.on('error', (err) => {
@@ -259,29 +292,4 @@ export class OneBot12WsEndpoint implements EndpointInstance {
259
292
  this.#ws!.send(JSON.stringify(req));
260
293
  });
261
294
  }
262
-
263
- #startHeartbeat(): void {
264
- if (this.#heartbeatTimer) {
265
- clearInterval(this.#heartbeatTimer);
266
- }
267
- this.#heartbeatTimer = setInterval(() => {
268
- this.#callAction('get_status', {}).catch(() => {});
269
- }, this.#options.config.heartbeat_interval);
270
- }
271
-
272
- #scheduleReconnect(): void {
273
- if (this.#stopping || !this.#started || this.#reconnectTimer) return;
274
- const delay = this.#options.config.reconnect_interval;
275
- this.#reconnectTimer = setTimeout(() => {
276
- this.#reconnectTimer = undefined;
277
- void this.#connect().catch((err) => {
278
- logger.warn(formatCompact({
279
- op: 'reconnect',
280
- endpoint: this.#options.config.name,
281
- ok: false,
282
- error: err instanceof Error ? err.message : String(err),
283
- }));
284
- });
285
- }, delay);
286
- }
287
295
  }
@@ -2,11 +2,12 @@
2
2
  * OneBot12 reverse WSS endpoint — accepts inbound WebSocket from OneBot implementation.
3
3
  */
4
4
  import { clearInterval } from 'node:timers';
5
- import type { EndpointInstance } from '@zhin.js/adapter';
5
+ import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
6
6
  import type { MessageGateway } from '@zhin.js/core/runtime';
7
7
  import type { HttpHost, WsConnection } from '@zhin.js/host-http';
8
8
  import { formatCompact, getLogger } from '@zhin.js/logger';
9
9
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
10
+ import { createOneBot12EndpointManagement } from './endpoint-management.js';
10
11
  import {
11
12
  buildSendMessageParams,
12
13
  formatInboundContent,
@@ -16,6 +17,7 @@ import {
16
17
  isMessageEvent,
17
18
  senderNickname,
18
19
  senderUserId,
20
+ uploadOneBot12MediaSegments,
19
21
  type OneBot12ActionRequest,
20
22
  type OneBot12ActionResponse,
21
23
  type OneBot12Event,
@@ -35,6 +37,7 @@ export interface OneBot12WssEndpointOptions {
35
37
 
36
38
  export class OneBot12WssEndpoint implements EndpointInstance {
37
39
  readonly #options: OneBot12WssEndpointOptions;
40
+ readonly management: EndpointManagement = createOneBot12EndpointManagement(this);
38
41
  #ws?: OneBot12WsSocket;
39
42
  #wsRelease?: () => void;
40
43
  #heartbeatTimer?: NodeJS.Timeout;
@@ -54,6 +57,15 @@ export class OneBot12WssEndpoint implements EndpointInstance {
54
57
  async start(): Promise<void> {
55
58
  if (this.#started) return;
56
59
  this.#started = true;
60
+ if (!this.#options.config.access_token) {
61
+ // wss 模式未配 access_token 时任何连接都会被放行(verifyOneBotAccessToken 直接 return true)
62
+ logger.warn(formatCompact({
63
+ endpoint: this.#options.config.name,
64
+ mode: 'wss',
65
+ ok: false,
66
+ error: 'missing access_token',
67
+ }));
68
+ }
57
69
  const handle = this.#options.http.ws(this.#options.config.path);
58
70
  this.#wsRelease = handle.onConnection((connection) => {
59
71
  this.#acceptConnection(connection);
@@ -99,12 +111,28 @@ export class OneBot12WssEndpoint implements EndpointInstance {
99
111
  }
100
112
 
101
113
  async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
102
- const message = formatOutboundSegments(payload);
114
+ const materialized = await uploadOneBot12MediaSegments(
115
+ payload,
116
+ (action, params) => this.callApi(action, params),
117
+ (error) => {
118
+ logger.warn(formatCompact({
119
+ op: 'onebot12_upload_failed',
120
+ endpoint: this.#options.config.name,
121
+ error: error instanceof Error ? error.message : String(error),
122
+ }));
123
+ },
124
+ );
125
+ const message = formatOutboundSegments(materialized);
103
126
  const params = buildSendMessageParams(target, message);
104
127
  const data = await this.#callAction('send_message', params) as { message_id?: string } | undefined;
105
128
  return data?.message_id ?? '';
106
129
  }
107
130
 
131
+ /** Public API for management surface / callers. */
132
+ callApi(action: string, params: Record<string, unknown> = {}): Promise<unknown> {
133
+ return this.#callAction(action, params);
134
+ }
135
+
108
136
  admit(ev: OneBot12Event): void {
109
137
  if (!this.#open || !isMessageEvent(ev)) return;
110
138
  const target = formatInboundTarget(ev);
package/plugin.ts DELETED
@@ -1,8 +0,0 @@
1
- import { definePlugin } from '@zhin.js/plugin-runtime';
2
-
3
- export default definePlugin({
4
- name: 'onebot12',
5
- metadata: {
6
- displayName: 'OneBot 12 Adapter',
7
- },
8
- });