@zhin.js/adapter-onebot12 1.1.18 → 1.1.20

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +24 -5
  3. package/adapters/onebot12/index.js +3 -32
  4. package/adapters/onebot12/index.ts +2 -38
  5. package/commands/onebot12/endpoint/definition.js +2 -2
  6. package/commands/onebot12/endpoint/definition.ts +2 -2
  7. package/lib/adapter.d.ts +21 -0
  8. package/lib/adapter.js +34 -0
  9. package/lib/client.d.ts +9 -11
  10. package/lib/client.js +23 -13
  11. package/lib/console-spec.js +2 -0
  12. package/lib/console.d.ts +1 -1
  13. package/lib/console.js +2 -5
  14. package/lib/content-port.d.ts +2 -2
  15. package/lib/content-port.js +1 -1
  16. package/lib/endpoint-management.d.ts +5 -5
  17. package/lib/endpoint-management.js +17 -17
  18. package/lib/identity.d.ts +4 -0
  19. package/lib/identity.js +53 -0
  20. package/lib/index.d.ts +3 -2
  21. package/lib/index.js +2 -1
  22. package/lib/protocol.d.ts +32 -4
  23. package/lib/protocol.js +54 -53
  24. package/lib/response.d.ts +5 -0
  25. package/lib/response.js +17 -0
  26. package/lib/side-event-dispatch.d.ts +8 -1
  27. package/lib/side-event-dispatch.js +2 -1
  28. package/lib/webhook.d.ts +5 -11
  29. package/lib/webhook.js +72 -37
  30. package/lib/ws-endpoint.d.ts +5 -11
  31. package/lib/ws-endpoint.js +79 -45
  32. package/lib/ws-transport.d.ts +18 -10
  33. package/lib/ws-transport.js +26 -45
  34. package/lib/ws-types.d.ts +0 -6
  35. package/lib/wss-endpoint.d.ts +5 -11
  36. package/lib/wss-endpoint.js +82 -48
  37. package/package.json +15 -15
  38. package/plugin.js +0 -4
  39. package/schema.json +15 -3
  40. package/src/adapter.ts +36 -0
  41. package/src/client.ts +26 -29
  42. package/src/console-spec.ts +2 -0
  43. package/src/console.ts +2 -5
  44. package/src/content-port.ts +3 -3
  45. package/src/endpoint-management.ts +25 -24
  46. package/src/identity.ts +52 -0
  47. package/src/index.ts +4 -1
  48. package/src/protocol.ts +71 -62
  49. package/src/response.ts +19 -0
  50. package/src/side-event-dispatch.ts +9 -7
  51. package/src/webhook.ts +70 -55
  52. package/src/ws-endpoint.ts +75 -63
  53. package/src/ws-transport.ts +37 -73
  54. package/src/ws-types.ts +0 -7
  55. package/src/wss-endpoint.ts +80 -66
  56. package/lib/onebot12-runtime-state.d.ts +0 -1
  57. package/lib/onebot12-runtime-state.js +0 -6
  58. package/src/onebot12-runtime-state.ts +0 -7
package/schema.json CHANGED
@@ -52,7 +52,7 @@
52
52
  "endpoints": {
53
53
  "type": "array",
54
54
  "minItems": 1,
55
- "description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(id 必填,其余覆盖顶层)",
55
+ "description": "多账号:每项明确选择 platform/userId,id 为本地账号名称,其余连接字段覆盖实例默认值",
56
56
  "items": {
57
57
  "type": "object",
58
58
  "additionalProperties": false,
@@ -121,15 +121,27 @@
121
121
  "id": {
122
122
  "type": "string",
123
123
  "description": "OneBot12 bot name"
124
+ },
125
+ "platform": {
126
+ "type": "string",
127
+ "minLength": 1,
128
+ "description": "Selected OneBot12 bot platform"
129
+ },
130
+ "userId": {
131
+ "type": "string",
132
+ "minLength": 1,
133
+ "description": "Selected OneBot12 bot user ID"
124
134
  }
125
135
  },
126
136
  "required": [
127
- "id"
137
+ "id",
138
+ "platform",
139
+ "userId"
128
140
  ]
129
141
  },
130
142
  "x-i18n": {
131
143
  "en": {
132
- "description": "Multiple accounts: mount multiple endpoints in one plugin instance. Each entry uses the top-level fields; id is required and other fields override defaults."
144
+ "description": "Each endpoint explicitly selects platform and userId. id is the local account name; other connection fields override instance defaults."
133
145
  }
134
146
  }
135
147
  },
package/src/adapter.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { Adapter, type AdapterContext, type EndpointCreationContext } from 'zhin.js/adapter';
2
+ import { httpHostToken } from '@zhin.js/host-http';
3
+ import { resolveOneBot12Config, type OneBot12EndpointConfig } from './protocol.js';
4
+ import { OneBot12WsEndpoint } from './ws-endpoint.js';
5
+ import { OneBot12WssEndpoint } from './wss-endpoint.js';
6
+ import { OneBot12WebhookEndpoint } from './webhook.js';
7
+ import { probeOnebot12Identity, probeOnebot12HttpIdentity } from './identity.js';
8
+ import type { Onebot12ClientEventMap } from './client.js';
9
+
10
+ export class Onebot12Adapter extends Adapter<OneBot12WsEndpoint | OneBot12WssEndpoint | OneBot12WebhookEndpoint, OneBot12EndpointConfig, Onebot12ClientEventMap> {
11
+ readonly #context: AdapterContext<OneBot12EndpointConfig>;
12
+ constructor(context: AdapterContext<OneBot12EndpointConfig>) { super(); this.#context = context; }
13
+ override get configurationIdentityFields(): readonly string[] { return ['id', 'platform', 'userId']; }
14
+ override get configurationIdentityProbeFields(): readonly string[] { return ['connection', 'access_token', 'url', 'api_url']; }
15
+ override get configurationUpdateFields(): readonly string[] {
16
+ return ['connection', 'url', 'path', 'api_url', 'access_token', 'reconnect_interval', 'heartbeat_interval'];
17
+ }
18
+ protected override validateConfiguration(config: OneBot12EndpointConfig): void { resolveOneBot12Config(config); }
19
+ protected override async identifyConfiguration(input: OneBot12EndpointConfig, signal: AbortSignal): Promise<string | undefined> {
20
+ const config = resolveOneBot12Config(input);
21
+ if (config.connection === 'ws') return probeOnebot12Identity(config, signal);
22
+ if (config.connection === 'webhook') return probeOnebot12HttpIdentity(config, signal);
23
+ return undefined;
24
+ }
25
+ protected createEndpoint(input: OneBot12EndpointConfig, execution: EndpointCreationContext): OneBot12WsEndpoint | OneBot12WssEndpoint | OneBot12WebhookEndpoint {
26
+ const config = resolveOneBot12Config(input);
27
+ const id = execution.canonicalId;
28
+ if (config.connection === 'webhook') return new OneBot12WebhookEndpoint({ id, signal: execution.signal, http: this.#context.use(httpHostToken), config });
29
+ if (config.connection === 'wss') return new OneBot12WssEndpoint({ id, signal: execution.signal, http: this.#context.use(httpHostToken), config });
30
+ return new OneBot12WsEndpoint({ id, signal: execution.signal, config });
31
+ }
32
+ }
33
+
34
+ declare module '@zhin.js/feature-kit' {
35
+ interface AdapterRegistry { readonly onebot12: Onebot12Adapter; }
36
+ }
package/src/client.ts CHANGED
@@ -1,11 +1,13 @@
1
+ import { readOnebot12Response } from './response.js';
2
+ export { readOnebot12Response } from './response.js';
3
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
1
4
  import {
2
5
  OneBotV12Client,
3
6
  type OneBotV12Event as ImHelperOneBot12Event,
4
7
  type OneBotV12Response,
5
8
  } from '@imhelper/onebot-v12';
6
- import { EventFactory, type EventMap, type ImHelperEventMap } from 'imhelper';
9
+ import { ProtocolError, EventFactory, type EventMap, type ImHelperEventMap } from 'imhelper';
7
10
  import {
8
- defineEndpointClient,
9
11
  forwardEndpointClientEvents,
10
12
  type ClientEventPayloads,
11
13
  } from 'zhin.js/adapter';
@@ -25,38 +27,44 @@ type Onebot12ClientEvents = ImHelperEventMap<
25
27
  >;
26
28
  export type Onebot12ClientEventMap = ClientEventPayloads<Onebot12ClientEvents>;
27
29
 
28
- export function createOnebot12EndpointClient(
30
+ export function onebot12ClientOptions(
29
31
  config: ResolvedOneBot12Config,
30
32
  request: Onebot12ApiCall,
31
- ): OneBotV12Client {
33
+ ): ConstructorParameters<typeof OneBotV12Client>[0] {
32
34
  const baseUrl = config.connection === 'ws'
33
35
  ? config.url.replace(/^ws(s?):/, 'http$1:')
34
36
  : config.connection === 'webhook'
35
37
  ? config.api_url
36
38
  : 'http://localhost';
37
- return new OneBotV12Client({
39
+ return {
38
40
  baseUrl,
39
- selfId: config.id,
41
+ platform: config.platform,
42
+ selfId: config.userId,
40
43
  accessToken: config.access_token,
41
44
  receiveMode: 'manual',
42
45
  call: request,
43
- });
46
+ };
44
47
  }
45
48
 
46
- export async function callOnebot12Client<T = unknown>(
47
- client: OneBotV12Client,
48
- action: string,
49
- params?: Record<string, unknown>,
50
- ): Promise<T | undefined> {
51
- const response: OneBotV12Response<T> = await client.call<T>(action, params);
52
- if (response.status !== 'ok') {
53
- throw new Error(
54
- `OneBot 12 API ${action}: retcode=${response.retcode}${response.message ? ` ${response.message}` : ''}`,
55
- );
49
+ export interface Onebot12ClientRequest {
50
+ readonly client: OneBotV12Client;
51
+ readonly action: string;
52
+ readonly params?: Record<string, unknown>;
53
+ }
54
+ /** Native methods retain ProtocolError; framework ports preserve the underlying delivery evidence. */
55
+ export async function callOnebot12Client<T = unknown>(request: Onebot12ClientRequest): Promise<T | undefined> {
56
+ try {
57
+ return readOnebot12Response<T>(await request.client.call<T>(request.action, request.params));
58
+ } catch (error) {
59
+ if (error instanceof ProtocolError) {
60
+ if (error.cause instanceof EndpointDeliveryError) throw error.cause;
61
+ if (error.response !== undefined) readOnebot12Response(error.response);
62
+ }
63
+ throw error;
56
64
  }
57
- return response.data;
58
65
  }
59
66
 
67
+
60
68
  const onebot12ClientEventNames = Object.freeze([
61
69
  ...EventFactory.getSupportedEventTypes<string>(),
62
70
  'event',
@@ -68,14 +76,3 @@ export function forwardOnebot12ClientEvents(
68
76
  ): () => void {
69
77
  return forwardEndpointClientEvents(client, onebot12ClientEventNames, receive);
70
78
  }
71
-
72
- declare module '@zhin.js/feature-kit' {
73
- interface AdapterClientRegistry {
74
- readonly onebot12: {
75
- readonly client: OneBotV12Client;
76
- readonly events: Onebot12ClientEventMap;
77
- };
78
- }
79
- }
80
-
81
- export const onebot12Client = defineEndpointClient<OneBotV12Client, Onebot12ClientEventMap>('onebot12');
@@ -7,6 +7,8 @@ export const onebot12ConsoleSpec = {
7
7
  adapterKey: 'onebot12',
8
8
  adapterDisplayName: 'OneBot 12',
9
9
  fields: [
10
+ { key: 'platform', required: true, description: 'Selected OneBot12 bot platform' },
11
+ { key: 'userId', required: true, description: 'Selected OneBot12 bot user ID' },
10
12
  { key: 'url', description: 'OneBot 实现 WebSocket URL(connection: ws 必填)', descriptionI18n: { en: 'OneBot WebSocket URL (required for connection: ws)' } },
11
13
  { key: 'path', description: 'HTTP/WS 路径(webhook / reverse-wss)', descriptionI18n: { en: 'HTTP/WS path (webhook / reverse-wss)' } },
12
14
  { key: 'api_url', description: 'HTTP action 端点(connection: webhook 出站必填)', descriptionI18n: { en: 'HTTP action endpoint (required for outbound connection: webhook)' } },
package/src/console.ts CHANGED
@@ -5,17 +5,14 @@
5
5
  */
6
6
  import { createAdapterConsoleRpc, type AdapterConsoleResources } from 'zhin.js/adapter';
7
7
  import type { PluginConsoleRpc } from 'zhin.js';
8
- import { onebot12RuntimeStateToken } from './onebot12-runtime-state.js';
9
8
  import { onebot12ConsoleSpec } from './console-spec.js';
10
9
 
11
10
  export function createOnebot12ConsoleRpc(
12
- resources: AdapterConsoleResources,
11
+ _resources: AdapterConsoleResources,
13
12
  getConfig: () => unknown,
14
13
  ): PluginConsoleRpc {
15
- const state = resources.use(onebot12RuntimeStateToken);
16
14
  return createAdapterConsoleRpc({
17
15
  ...onebot12ConsoleSpec,
18
- running: () => state.endpoints.values(),
19
16
  getConfig,
20
- }, resources);
17
+ });
21
18
  }
@@ -1,7 +1,7 @@
1
- import type { EndpointContentPort, EndpointContentResolveContext } from 'zhin.js/adapter';
1
+ import type { EndpointContentPort, EndpointContentResolveContext, PendingRpcRequest } from 'zhin.js/adapter';
2
2
  import type { ConversationMessage, ConversationReference } from '@zhin.js/im-contract';
3
3
 
4
- type CallApi = (action: string, params?: Record<string, unknown>) => Promise<unknown>;
4
+ type CallApi = (request: PendingRpcRequest) => Promise<unknown>;
5
5
 
6
6
  export function createOneBot12ContentPort(callApi: CallApi): EndpointContentPort {
7
7
  return Object.freeze({
@@ -16,7 +16,7 @@ export function createOneBot12ContentPort(callApi: CallApi): EndpointContentPort
16
16
  ? Object.freeze({ status: 'unsupported' as const, code: 'onebot12_opaque_media' })
17
17
  : Object.freeze({ status: 'resolved' as const, reference, value: reference.media });
18
18
  }
19
- const row = await callApi('get_message', { message_id: reference.message.id }) as Record<string, unknown>;
19
+ const row = await callApi({ action: 'get_message', params: { message_id: reference.message.id }, signal: context.signal }) as Record<string, unknown>;
20
20
  context.signal.throwIfAborted();
21
21
  const message = (row.message && typeof row.message === 'object' ? row.message : row) as Record<string, unknown>;
22
22
  const segments = normalizeSegments(message.message ?? message.content);
@@ -3,51 +3,54 @@
3
3
  * packages/im/adapter/src/endpoint-management.ts)。
4
4
  *
5
5
  * 动作名对齐 OB12 规范:get_friend_list / get_group_list / get_group_member_list。
6
- * 归一化取舍(OB12 id 为字符串,收敛为数字):
7
- * - friend → {user_id:number, nickname: user_name, remark: user_displayname ?? ''}
8
- * - group → {group_id:number, name: group_name ?? name}
6
+ * 投影仅归一化展示字段;OB12 账号与会话 ID 始终保留为不透明字符串:
7
+ * - friend → {user_id:string, nickname: user_name, remark: user_displayname ?? ''}
8
+ * - group → {group_id:string, name: group_name ?? name}
9
9
  * - 群成员列表保持 OB12 原生形状,仅保证数组
10
10
  */
11
- import type {
12
- EndpointFriend,
13
- EndpointGroup,
14
- EndpointManagement,
11
+ import {
12
+ type EndpointFriend,
13
+ type EndpointGroup,
14
+ type EndpointManagement,
15
+ type EndpointManagementRequest,
16
+ type EndpointGroupRequest,
17
+ type PendingRpcRequest,
15
18
  } from 'zhin.js/adapter';
16
19
 
17
20
  /** 管理面只依赖 endpoint 的 callApi(ws/wss/webhook 三种传输各自实现)。 */
18
21
  export interface OneBot12ManagementCaller {
19
- callApi(action: string, params?: Record<string, unknown>): Promise<unknown>;
22
+ callApi(request: PendingRpcRequest): Promise<unknown>;
20
23
  }
21
24
 
22
25
  export function createOneBot12EndpointManagement(
23
26
  endpoint: OneBot12ManagementCaller,
24
27
  ): EndpointManagement {
25
28
  return Object.freeze<EndpointManagement>({
26
- async listFriends(): Promise<readonly EndpointFriend[]> {
27
- const data = await endpoint.callApi('get_friend_list');
29
+ async listFriends(request: EndpointManagementRequest = {}): Promise<readonly EndpointFriend[]> {
30
+ const data = await endpoint.callApi({ action: 'get_friend_list', signal: request.signal });
28
31
  return toArray(data).map((value) => {
29
32
  const friend = asRecord(value);
30
33
  return {
31
- user_id: toNumberId(friend.user_id, 'user_id'),
34
+ user_id: readOpaqueId(friend.user_id, 'user_id'),
32
35
  nickname: String(friend.user_name ?? friend.nickname ?? ''),
33
36
  remark: String(friend.user_displayname ?? friend.remark ?? ''),
34
37
  };
35
38
  });
36
39
  },
37
- async listGroups(): Promise<readonly EndpointGroup[]> {
38
- const data = await endpoint.callApi('get_group_list');
40
+ async listGroups(request: EndpointManagementRequest = {}): Promise<readonly EndpointGroup[]> {
41
+ const data = await endpoint.callApi({ action: 'get_group_list', signal: request.signal });
39
42
  return toArray(data).map((value) => {
40
43
  const group = asRecord(value);
41
44
  return {
42
- group_id: toNumberId(group.group_id, 'group_id'),
45
+ group_id: readOpaqueId(group.group_id, 'group_id'),
43
46
  name: String(group.group_name ?? group.name ?? ''),
44
47
  };
45
48
  });
46
49
  },
47
- async listGroupMembers(groupId: string): Promise<readonly unknown[]> {
48
- const data = await endpoint.callApi('get_group_member_list', {
50
+ async listGroupMembers({ groupId, signal }: EndpointGroupRequest): Promise<readonly unknown[]> {
51
+ const data = await endpoint.callApi({ action: 'get_group_member_list', signal, params: {
49
52
  group_id: groupId,
50
- });
53
+ } });
51
54
  return toArray(data);
52
55
  },
53
56
  });
@@ -63,11 +66,9 @@ function toArray(value: unknown): readonly unknown[] {
63
66
  return Array.isArray(value) ? value : [];
64
67
  }
65
68
 
66
- /** OB12 id 是字符串;QQ 系实现均为数字串,统一收敛为数字。 */
67
- function toNumberId(value: unknown, label: string): number {
68
- const n = Number(value);
69
- if (!Number.isFinite(n) || String(value ?? '').trim() === '') {
70
- throw new TypeError(`onebot12 ${label} 必须是数字: ${String(value)}`);
71
- }
72
- return n;
69
+ /** Platform IDs are addresses, including nonnumeric IDs and integers beyond Number precision. */
70
+ function readOpaqueId(value: unknown, label: string): string {
71
+ if (typeof value === 'string' && value.trim()) return value;
72
+ if (typeof value === 'number' && Number.isSafeInteger(value)) return String(value);
73
+ throw new TypeError(`onebot12 ${label} must be an opaque string or safe integer platform ID`);
73
74
  }
@@ -0,0 +1,52 @@
1
+ import WebSocket from 'ws';
2
+ import { buildWsConnectOptions, callOneBot12Action, type OneBot12ActionResponse, type OneBot12WebhookConfig, type OneBot12WsConfig } from './protocol.js';
3
+ import { createOneBot12Rpc } from './ws-transport.js';
4
+
5
+ /** Separate read-only connection: never changes credentials or state of the running Endpoint. */
6
+ export async function probeOnebot12Identity(config: OneBot12WsConfig, signal: AbortSignal): Promise<string | undefined> {
7
+ signal.throwIfAborted();
8
+ const { url, headers } = buildWsConnectOptions(config);
9
+ const socket = new WebSocket(url, { headers });
10
+ const rpc = createOneBot12Rpc({ endpointId: config.id, signal, socket: () => socket, self: { platform: config.platform, user_id: config.userId }, });
11
+ let rejectOpen: (error: unknown) => void = () => undefined;
12
+ const abort = () => { rejectOpen(signal.reason); rpc.disconnect(); socket.terminate(); };
13
+ signal.addEventListener('abort', abort, { once: true });
14
+ socket.on('message', data => rpc.receive(data, () => undefined));
15
+ socket.on('close', () => { rejectOpen(new Error('Identity connection closed')); rpc.disconnect(); });
16
+ try {
17
+ await new Promise<void>((resolve, reject) => { rejectOpen = reject; socket.once('open', resolve); socket.on('error', reject); });
18
+ signal.throwIfAborted();
19
+ const self = { platform: config.platform, user_id: config.userId };
20
+ return await identifySelectedBot({ selected: self, query: action => rpc.call({ action }), signal });
21
+ } finally {
22
+ signal.removeEventListener('abort', abort); rpc.dispose(); socket.terminate();
23
+ }
24
+ }
25
+
26
+ export async function probeOnebot12HttpIdentity(config: OneBot12WebhookConfig, signal: AbortSignal): Promise<string | undefined> {
27
+ const self = { platform: config.platform, user_id: config.userId };
28
+ return identifySelectedBot({ selected: self, query: action => callOneBot12Action({ url: config.api_url, access_token: config.access_token, signal, self, action }), signal });
29
+ }
30
+
31
+ interface OneBot12IdentityProbe {
32
+ readonly selected: Readonly<{ platform: string; user_id: string }>;
33
+ readonly query: (action: string) => Promise<OneBot12ActionResponse>;
34
+ readonly signal: AbortSignal;
35
+ }
36
+ async function identifySelectedBot({ selected, query, signal }: OneBot12IdentityProbe): Promise<string | undefined> {
37
+ const status = await query('get_status');
38
+ signal.throwIfAborted();
39
+ if (status.status !== 'ok' || status.retcode !== 0) throw new Error('OneBot12 identity status lookup failed');
40
+ const data = status.data;
41
+ if (!data || typeof data !== 'object' || !('bots' in data) || !Array.isArray(data.bots)) return undefined;
42
+ const match = data.bots.some((bot: unknown) => {
43
+ if (!bot || typeof bot !== 'object' || !('self' in bot) || !bot.self || typeof bot.self !== 'object') return false;
44
+ return 'platform' in bot.self && 'user_id' in bot.self && bot.self.platform === selected.platform && bot.self.user_id === selected.user_id;
45
+ });
46
+ if (!match) return undefined;
47
+ const account = await query('get_self_info');
48
+ signal.throwIfAborted();
49
+ if (account.status !== 'ok' || account.retcode !== 0) throw new Error('OneBot12 account identity lookup failed');
50
+ if (!account.data || typeof account.data !== 'object' || !('user_id' in account.data) || account.data.user_id !== selected.user_id) return undefined;
51
+ return `onebot12:${JSON.stringify([selected.platform, selected.user_id])}`;
52
+ }
package/src/index.ts CHANGED
@@ -21,6 +21,9 @@ export {
21
21
  type OneBot12EndpointConfig,
22
22
  type OneBot12Event,
23
23
  type OneBot12HttpOptions,
24
+ type OneBot12HttpRequest,
25
+ type OneBot12MediaUpload,
26
+ type OneBot12MediaSource,
24
27
  type OneBot12Segment,
25
28
  type OneBot12Self,
26
29
  type OneBot12WebhookConfig,
@@ -50,7 +53,6 @@ export type { OneBot12WsSocket, OneBot12WsCreateOptions } from './ws-types.js';
50
53
  export { verifyOneBotAccessToken } from './wss-auth.js';
51
54
  export {
52
55
  Onebot12Client,
53
- onebot12Client,
54
56
  type Onebot12ClientEventMap,
55
57
  } from './client.js';
56
58
 
@@ -64,3 +66,4 @@ export type {
64
66
  } from '@imhelper/onebot-v12';
65
67
  export { ProtocolError } from '@imhelper/onebot-v12';
66
68
  export type { ProtocolErrorKind, ProtocolErrorOptions } from 'imhelper';
69
+ export { Onebot12Adapter } from './adapter.js';
package/src/protocol.ts CHANGED
@@ -1,15 +1,18 @@
1
+ import { readOnebot12Response } from './response.js';
1
2
  /**
2
3
  * OneBot 12 protocol helpers (no legacy Adapter/Endpoint / segment-mapper).
3
4
  * Canonicalization is owned by gateway/core before endpoint.send.
4
5
  * Spec: https://12.onebot.dev/
5
6
  */
6
- import { isMediaRef, type MediaRef, type ConversationRef } from '@zhin.js/im-contract';
7
+ import { EndpointDeliveryError, isMediaRef, type MediaRef, type ConversationRef } from '@zhin.js/im-contract';
7
8
  import { formatCompact, getLogger } from '@zhin.js/logger';
8
9
 
9
10
  const logger = getLogger('onebot12');
10
11
 
11
12
  /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
12
13
  export interface OneBot12EndpointConfig {
14
+ readonly platform: string;
15
+ readonly userId: string;
13
16
  readonly connection?: 'ws' | 'webhook' | 'wss';
14
17
  readonly id: string;
15
18
  readonly access_token?: string;
@@ -21,6 +24,8 @@ export interface OneBot12EndpointConfig {
21
24
  }
22
25
 
23
26
  export interface OneBot12ConfigBase {
27
+ readonly platform: string;
28
+ readonly userId: string;
24
29
  readonly context: 'onebot12';
25
30
  readonly id: string;
26
31
  readonly access_token?: string;
@@ -78,10 +83,10 @@ export interface OneBot12Segment {
78
83
  }
79
84
 
80
85
  export interface OneBot12ActionRequest {
86
+ readonly self: OneBot12Self;
81
87
  action: string;
82
88
  params: Record<string, unknown>;
83
89
  echo?: string;
84
- self?: OneBot12Self;
85
90
  }
86
91
 
87
92
  export interface OneBot12ActionResponse {
@@ -93,6 +98,8 @@ export interface OneBot12ActionResponse {
93
98
  }
94
99
 
95
100
  export interface OneBot12HttpOptions {
101
+ readonly self: OneBot12Self;
102
+ readonly signal?: AbortSignal;
96
103
  readonly url: string;
97
104
  readonly access_token?: string;
98
105
  }
@@ -105,12 +112,14 @@ export interface OneBot12WireSegment {
105
112
  export function resolveOneBot12Config(config: OneBot12EndpointConfig): ResolvedOneBot12Config {
106
113
  const connection = config.connection ?? 'ws';
107
114
  const id = requiredEndpointField(config.id, 'id');
115
+ const platform = requiredEndpointField(config.platform, 'platform');
116
+ const userId = requiredEndpointField(config.userId, 'userId');
108
117
  const access_token = optionalEndpointField(config.access_token);
109
118
 
110
119
  if (connection === 'ws') {
111
120
  const url = requiredEndpointField(config.url, 'url');
112
121
  return {
113
- context: 'onebot12',
122
+ context: 'onebot12', platform, userId,
114
123
  connection: 'ws',
115
124
  id,
116
125
  access_token,
@@ -124,7 +133,7 @@ export function resolveOneBot12Config(config: OneBot12EndpointConfig): ResolvedO
124
133
  const path = requiredEndpointField(config.path, 'path');
125
134
  const api_url = requiredEndpointField(config.api_url, 'api_url');
126
135
  return {
127
- context: 'onebot12',
136
+ context: 'onebot12', platform, userId,
128
137
  connection: 'webhook',
129
138
  id,
130
139
  access_token,
@@ -136,7 +145,7 @@ export function resolveOneBot12Config(config: OneBot12EndpointConfig): ResolvedO
136
145
  if (connection === 'wss') {
137
146
  const path = requiredEndpointField(config.path, 'path');
138
147
  return {
139
- context: 'onebot12',
148
+ context: 'onebot12', platform, userId,
140
149
  connection: 'wss',
141
150
  id,
142
151
  access_token,
@@ -150,7 +159,7 @@ export function resolveOneBot12Config(config: OneBot12EndpointConfig): ResolvedO
150
159
 
151
160
  function requiredEndpointField(
152
161
  value: unknown,
153
- field: 'id' | 'url' | 'path' | 'api_url',
162
+ field: 'id' | 'url' | 'path' | 'api_url' | 'platform' | 'userId',
154
163
  ): string {
155
164
  const resolved = optionalEndpointField(value);
156
165
  if (!resolved) {
@@ -398,11 +407,12 @@ export type OneBot12CallAction = (
398
407
  ) => Promise<unknown>;
399
408
 
400
409
  /** upload_file 文件名:优先段 data.name/filename,URL/路径取 basename,再按 mime 给默认名。 */
401
- function oneBot12UploadName(
402
- segmentType: string,
403
- data: Record<string, unknown>,
404
- media: MediaRef,
405
- ): string {
410
+ export interface OneBot12MediaSource {
411
+ readonly type: string;
412
+ readonly data: Readonly<Record<string, unknown>>;
413
+ readonly media: MediaRef;
414
+ }
415
+ function oneBot12UploadName({ type, data, media }: OneBot12MediaSource): string {
406
416
  const named = data.name ?? data.filename;
407
417
  if (typeof named === 'string' && named) return named;
408
418
  if (media.kind === 'url') {
@@ -418,16 +428,13 @@ function oneBot12UploadName(
418
428
  if (base) return base;
419
429
  }
420
430
  const ext = media.mime_type?.split('/')[1]?.split(';')[0];
421
- return `${segmentType}.${ext || 'bin'}`;
431
+ return `${type}.${ext || 'bin'}`;
422
432
  }
423
433
 
424
434
  /** canonical MediaRef → OB12 `upload_file` 动作参数(spec: type url/path/data)。 */
425
- export function mediaRefToOneBot12UploadParams(
426
- segmentType: string,
427
- data: Record<string, unknown>,
428
- media: MediaRef,
429
- ): Record<string, unknown> | undefined {
430
- const name = oneBot12UploadName(segmentType, data, media);
435
+ export function mediaRefToOneBot12UploadParams(source: OneBot12MediaSource): Record<string, unknown> | undefined {
436
+ const { media } = source;
437
+ const name = oneBot12UploadName(source);
431
438
  if (media.kind === 'url') return { type: 'url', name, url: media.value };
432
439
  if (media.kind === 'path') {
433
440
  const path = media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value;
@@ -444,8 +451,7 @@ export function mediaRefToOneBot12UploadParams(
444
451
 
445
452
  async function uploadOneMediaSegment(
446
453
  item: unknown,
447
- callAction: OneBot12CallAction,
448
- onUploadFailed?: (error: unknown) => void,
454
+ request: OneBot12MediaUpload,
449
455
  ): Promise<unknown> {
450
456
  if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
451
457
  const segment = item as OneBot12WireSegment;
@@ -460,17 +466,18 @@ async function uploadOneMediaSegment(
460
466
  if (media.kind === 'file') {
461
467
  return { type: segment.type, data: { ...oneBot12MediaExtra(data), file_id: media.value } };
462
468
  }
463
- const params = mediaRefToOneBot12UploadParams(segment.type, data, media);
469
+ const params = mediaRefToOneBot12UploadParams({ type: segment.type, data, media });
464
470
  if (!params) return item;
465
471
  try {
466
- const result = await callAction('upload_file', params) as { file_id?: unknown } | undefined;
472
+ const result = await request.callAction('upload_file', params) as { file_id?: unknown } | undefined;
467
473
  if (result && typeof result.file_id === 'string' && result.file_id) {
468
474
  return { type: segment.type, data: { ...oneBot12MediaExtra(data), file_id: result.file_id } };
469
475
  }
470
476
  throw new Error('upload_file 响应缺少 file_id');
471
477
  } catch (error) {
478
+ if (request.signal?.aborted) throw error;
472
479
  // 上传失败降级:保留原段,由 formatOutboundSegments 走扩展字段(url/data/path)
473
- onUploadFailed?.(error);
480
+ request.onUploadFailed?.(error);
474
481
  return item;
475
482
  }
476
483
  }
@@ -481,15 +488,17 @@ async function uploadOneMediaSegment(
481
488
  * {@link formatOutboundSegments} 编码;kind=file 的 MediaRef 直接按 file_id 复投。
482
489
  * 上传失败保留原段(降级扩展字段透传)并回调 onUploadFailed。
483
490
  */
484
- export async function uploadOneBot12MediaSegments(
485
- payload: unknown,
486
- callAction: OneBot12CallAction,
487
- onUploadFailed?: (error: unknown) => void,
488
- ): Promise<unknown> {
489
- if (Array.isArray(payload)) {
490
- return Promise.all(payload.map((item) => uploadOneMediaSegment(item, callAction, onUploadFailed)));
491
+ export interface OneBot12MediaUpload {
492
+ readonly payload: unknown;
493
+ readonly callAction: OneBot12CallAction;
494
+ readonly signal?: AbortSignal;
495
+ readonly onUploadFailed?: (error: unknown) => void;
496
+ }
497
+ export async function uploadOneBot12MediaSegments(request: OneBot12MediaUpload): Promise<unknown> {
498
+ if (Array.isArray(request.payload)) {
499
+ return Promise.all(request.payload.map(item => uploadOneMediaSegment(item, request)));
491
500
  }
492
- return uploadOneMediaSegment(payload, callAction, onUploadFailed);
501
+ return uploadOneMediaSegment(request.payload, request);
493
502
  }
494
503
 
495
504
  /**
@@ -519,40 +528,34 @@ export function buildSendMessageParams(
519
528
  * 向 OneBot 实现发送动作请求(HTTP POST),返回动作响应。
520
529
  * 供 webhook 出站与纯协议测试使用;WS 路径走 WebSocket echo 请求。
521
530
  */
522
- export async function callOneBot12Action(
523
- options: OneBot12HttpOptions,
524
- action: string,
525
- params: Record<string, unknown> = {},
526
- echo?: string,
527
- ): Promise<OneBot12ActionResponse> {
531
+ export interface OneBot12HttpRequest extends OneBot12HttpOptions {
532
+ readonly action: string;
533
+ readonly params?: Record<string, unknown>;
534
+ readonly echo?: string;
535
+ }
536
+ /** HTTP owns submission evidence and cancellation for both SDK and framework callers. */
537
+ export async function callOneBot12Action(request: OneBot12HttpRequest): Promise<OneBot12ActionResponse> {
538
+ if (request.signal?.aborted) throw new EndpointDeliveryError('endpoint_inactive', 'Endpoint operation cancelled', 'not_sent');
528
539
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
529
- if (options.access_token) {
530
- headers.Authorization = `Bearer ${options.access_token}`;
531
- }
532
- const body: OneBot12ActionRequest = { action, params };
533
- if (echo) body.echo = echo;
534
-
535
- const res = await fetch(options.url, {
536
- method: 'POST',
537
- headers,
538
- body: JSON.stringify(body),
539
- signal: AbortSignal.timeout(30_000),
540
- });
541
-
542
- const text = await res.text();
543
- if (res.status === 401) throw new Error(`OneBot12 鉴权失败: ${text}`);
544
- if (res.status !== 200) throw new Error(`OneBot12 HTTP ${res.status}: ${text}`);
545
-
546
- let data: OneBot12ActionResponse;
540
+ if (request.access_token) headers.Authorization = `Bearer ${request.access_token}`;
541
+ const body: OneBot12ActionRequest = { action: request.action, params: request.params ?? {}, self: request.self,
542
+ ...(request.echo !== undefined ? { echo: request.echo } : {}) };
547
543
  try {
548
- data = JSON.parse(text) as OneBot12ActionResponse;
549
- } catch {
550
- throw new Error(`OneBot12 无效响应: ${text.slice(0, 200)}`);
551
- }
552
- if (data.status === 'failed' && data.retcode !== 0) {
553
- throw new Error(`OneBot12 动作失败 retcode=${data.retcode}: ${data.message}`);
544
+ const response = await fetch(request.url, { method: 'POST', headers, body: JSON.stringify(body),
545
+ signal: request.signal ? AbortSignal.any([request.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000) });
546
+ const text = await response.text();
547
+ if (response.status === 401) throw new EndpointDeliveryError('platform_rejected', `OneBot12 鉴权失败: ${text}`, 'rejected');
548
+ if (response.status !== 200) throw new EndpointDeliveryError('endpoint_http_failed', `OneBot12 HTTP ${response.status}: ${text}`, 'unknown');
549
+ let data: OneBot12ActionResponse;
550
+ try { data = JSON.parse(text) as OneBot12ActionResponse; }
551
+ catch { throw new EndpointDeliveryError('delivery_unconfirmed', `OneBot12 无效响应: ${text.slice(0, 200)}`, 'unknown'); }
552
+ readOnebot12Response(data);
553
+ return data;
554
+ } catch (cause) {
555
+ if (cause instanceof EndpointDeliveryError) throw cause;
556
+ throw new EndpointDeliveryError(request.signal?.aborted ? 'endpoint_inactive' : 'endpoint_transport_failed',
557
+ cause instanceof Error ? cause.message : String(cause), 'unknown', { cause });
554
558
  }
555
- return data;
556
559
  }
557
560
 
558
561
  /** Build WS connect URL + headers (access_token via Bearer + query). */
@@ -573,3 +576,9 @@ export function buildWsConnectOptions(config: OneBot12WsConfig): {
573
576
  safeUrl.searchParams.delete('access_token');
574
577
  return { url: connectUrl, headers, safeUrl: safeUrl.toString() };
575
578
  }
579
+
580
+ /** Route only the explicitly selected bot; service-wide frames carry no account authority. */
581
+ export function matchesOneBot12Self(config: Pick<OneBot12ConfigBase, 'platform' | 'userId'>,
582
+ event: OneBot12Event): event is OneBot12Event & { readonly self: OneBot12Self } {
583
+ return event.self?.platform === config.platform && event.self?.user_id === config.userId;
584
+ }