@zhin.js/adapter-onebot12 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +855 -0
  2. package/README.md +59 -69
  3. package/adapters/onebot12.js +50 -0
  4. package/adapters/onebot12.ts +64 -0
  5. package/{skills/onebot12/SKILL.md → agent/skills/onebot12.md} +1 -1
  6. package/commands/endpoint/add/[id].js +3 -0
  7. package/commands/endpoint/add/[id].ts +3 -0
  8. package/commands/endpoint/list.js +3 -0
  9. package/commands/endpoint/list.ts +3 -0
  10. package/commands/endpoint/remove/[id].js +3 -0
  11. package/commands/endpoint/remove/[id].ts +3 -0
  12. package/lib/client.d.ts +20 -0
  13. package/lib/client.js +33 -0
  14. package/lib/content-port.d.ts +4 -0
  15. package/lib/content-port.js +45 -0
  16. package/lib/endpoint-management.d.ts +16 -0
  17. package/lib/endpoint-management.js +47 -0
  18. package/lib/index.d.ts +10 -19
  19. package/lib/index.js +7 -27
  20. package/lib/onebot12-endpoint-commands.d.ts +1 -0
  21. package/lib/onebot12-endpoint-commands.js +25 -0
  22. package/lib/onebot12-runtime-state.d.ts +1 -0
  23. package/lib/onebot12-runtime-state.js +6 -0
  24. package/lib/protocol.d.ts +173 -0
  25. package/lib/protocol.js +415 -0
  26. package/lib/side-event-dispatch.d.ts +7 -0
  27. package/lib/side-event-dispatch.js +36 -0
  28. package/lib/webhook.d.ts +23 -0
  29. package/lib/webhook.js +161 -0
  30. package/lib/ws-endpoint.d.ts +22 -0
  31. package/lib/ws-endpoint.js +255 -0
  32. package/lib/ws-types.d.ts +16 -0
  33. package/lib/ws-types.js +1 -0
  34. package/lib/wss-auth.d.ts +2 -0
  35. package/lib/wss-auth.js +16 -0
  36. package/lib/wss-endpoint.d.ts +25 -0
  37. package/lib/wss-endpoint.js +222 -0
  38. package/package.json +63 -16
  39. package/plugin.js +14 -0
  40. package/schema.json +102 -0
  41. package/src/client.ts +81 -0
  42. package/src/content-port.ts +49 -0
  43. package/src/endpoint-management.ts +73 -0
  44. package/src/index.ts +64 -37
  45. package/src/onebot12-endpoint-commands.ts +24 -0
  46. package/src/onebot12-runtime-state.ts +7 -0
  47. package/src/protocol.ts +574 -0
  48. package/src/side-event-dispatch.ts +48 -0
  49. package/src/webhook.ts +223 -0
  50. package/src/ws-endpoint.ts +321 -0
  51. package/src/ws-types.ts +19 -0
  52. package/src/wss-auth.ts +17 -0
  53. package/src/wss-endpoint.ts +278 -0
  54. package/lib/adapter.d.ts +0 -17
  55. package/lib/adapter.d.ts.map +0 -1
  56. package/lib/adapter.js +0 -40
  57. package/lib/adapter.js.map +0 -1
  58. package/lib/api.d.ts +0 -14
  59. package/lib/api.d.ts.map +0 -1
  60. package/lib/api.js +0 -34
  61. package/lib/api.js.map +0 -1
  62. package/lib/bot-webhook.d.ts +0 -25
  63. package/lib/bot-webhook.d.ts.map +0 -1
  64. package/lib/bot-webhook.js +0 -118
  65. package/lib/bot-webhook.js.map +0 -1
  66. package/lib/bot-ws.d.ts +0 -26
  67. package/lib/bot-ws.d.ts.map +0 -1
  68. package/lib/bot-ws.js +0 -210
  69. package/lib/bot-ws.js.map +0 -1
  70. package/lib/bot-wss.d.ts +0 -26
  71. package/lib/bot-wss.d.ts.map +0 -1
  72. package/lib/bot-wss.js +0 -187
  73. package/lib/bot-wss.js.map +0 -1
  74. package/lib/index.d.ts.map +0 -1
  75. package/lib/index.js.map +0 -1
  76. package/lib/types.d.ts +0 -77
  77. package/lib/types.d.ts.map +0 -1
  78. package/lib/types.js +0 -5
  79. package/lib/types.js.map +0 -1
  80. package/lib/utils.d.ts +0 -45
  81. package/lib/utils.d.ts.map +0 -1
  82. package/lib/utils.js +0 -60
  83. package/lib/utils.js.map +0 -1
  84. package/src/adapter.ts +0 -52
  85. package/src/api.ts +0 -48
  86. package/src/bot-webhook.ts +0 -130
  87. package/src/bot-ws.ts +0 -222
  88. package/src/bot-wss.ts +0 -202
  89. package/src/types.ts +0 -86
  90. package/src/utils.ts +0 -87
@@ -0,0 +1,173 @@
1
+ /**
2
+ * OneBot 12 protocol helpers (no legacy Adapter/Endpoint / segment-mapper).
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ * Spec: https://12.onebot.dev/
5
+ */
6
+ import { type MediaRef } from '@zhin.js/core';
7
+ import type { ConversationRef } from '@zhin.js/im-contract';
8
+ /** Transitional legacy endpoint row (`endpoints[]` with `context: onebot12`). */
9
+ export interface OneBot12LegacyEndpointRow {
10
+ readonly context?: string;
11
+ readonly connection?: 'ws' | 'webhook' | 'wss';
12
+ readonly id?: string;
13
+ readonly access_token?: string;
14
+ readonly url?: string;
15
+ readonly path?: string;
16
+ readonly api_url?: string;
17
+ readonly reconnect_interval?: number;
18
+ readonly heartbeat_interval?: number;
19
+ }
20
+ /** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
21
+ export interface OneBot12AdapterConfig {
22
+ readonly connection?: 'ws' | 'webhook' | 'wss';
23
+ readonly id?: string;
24
+ readonly access_token?: string;
25
+ readonly url?: string;
26
+ readonly path?: string;
27
+ readonly api_url?: string;
28
+ readonly reconnect_interval?: number;
29
+ readonly heartbeat_interval?: number;
30
+ /** Transitional: legacy root `endpoints[]` with `context: onebot12`. */
31
+ readonly endpoints?: ReadonlyArray<OneBot12LegacyEndpointRow>;
32
+ }
33
+ export interface OneBot12ConfigBase {
34
+ readonly context: 'onebot12';
35
+ readonly id: string;
36
+ readonly access_token?: string;
37
+ }
38
+ /** 正向 WebSocket:应用连 OneBot 实现的 WS 服务器 */
39
+ export interface OneBot12WsConfig extends OneBot12ConfigBase {
40
+ readonly connection: 'ws';
41
+ readonly url: string;
42
+ readonly reconnect_interval: number;
43
+ readonly heartbeat_interval: number;
44
+ }
45
+ /** HTTP Webhook:httpHostToken POST 入站 + api_url HTTP 出站 */
46
+ export interface OneBot12WebhookConfig extends OneBot12ConfigBase {
47
+ readonly connection: 'webhook';
48
+ readonly path: string;
49
+ readonly api_url?: string;
50
+ }
51
+ /** 反向 WebSocket:httpHostToken WS upgrade 入站/出站 */
52
+ export interface OneBot12WssConfig extends OneBot12ConfigBase {
53
+ readonly connection: 'wss';
54
+ readonly path: string;
55
+ readonly heartbeat_interval: number;
56
+ }
57
+ export type ResolvedOneBot12Config = OneBot12WsConfig | OneBot12WebhookConfig | OneBot12WssConfig;
58
+ export type OneBot12EndpointConfig = ResolvedOneBot12Config;
59
+ export interface OneBot12Self {
60
+ readonly platform: string;
61
+ readonly user_id: string;
62
+ }
63
+ export interface OneBot12Event {
64
+ id: string;
65
+ time: number;
66
+ type: 'meta' | 'message' | 'notice' | 'request';
67
+ detail_type: string;
68
+ sub_type: string;
69
+ self: OneBot12Self;
70
+ message_id?: string;
71
+ message?: OneBot12Segment[];
72
+ alt_message?: string;
73
+ user_id?: string;
74
+ group_id?: string;
75
+ channel_id?: string;
76
+ guild_id?: string;
77
+ [key: string]: unknown;
78
+ }
79
+ export interface OneBot12Segment {
80
+ type: string;
81
+ data?: Record<string, unknown>;
82
+ }
83
+ export interface OneBot12ActionRequest {
84
+ action: string;
85
+ params: Record<string, unknown>;
86
+ echo?: string;
87
+ self?: OneBot12Self;
88
+ }
89
+ export interface OneBot12ActionResponse {
90
+ status: 'ok' | 'failed';
91
+ retcode: number;
92
+ data?: unknown;
93
+ message: string;
94
+ echo?: string;
95
+ }
96
+ export interface OneBot12HttpOptions {
97
+ readonly url: string;
98
+ readonly access_token?: string;
99
+ }
100
+ export interface OneBot12WireSegment {
101
+ readonly type: string;
102
+ readonly data?: Record<string, unknown>;
103
+ }
104
+ export declare function resolveOneBot12Config(config?: OneBot12AdapterConfig): ResolvedOneBot12Config;
105
+ /** 判断是否为消息事件(type=message) */
106
+ export declare function isMessageEvent(ev: OneBot12Event): ev is OneBot12Event & {
107
+ message_id: string;
108
+ message?: OneBot12Segment[];
109
+ };
110
+ /** 从事件得到场景 id:私聊 user_id,群 group_id,频道 channel_id 或 guild_id:channel_id */
111
+ export declare function getChannelId(ev: OneBot12Event): string;
112
+ /**
113
+ * 入站归一化 → ConversationRef:`detail_type` 直映射 kind;channel 的 guild 容器
114
+ * 进 `parent`(kind 'channel');私聊临时会话(private 事件携带 group_id)映射为
115
+ * group 容器内的 private 会话。
116
+ */
117
+ export declare function onebot12InboundConversation(endpointKey: string, ev: OneBot12Event): ConversationRef;
118
+ /** Build inbound text for OutboundMessageService.receive */
119
+ export declare function formatInboundContent(ev: OneBot12Event): string;
120
+ /**
121
+ * Runtime Message sender 必须是用户 ID(agent bridge 以 sender 与 endpointMaster 比对)。
122
+ * 显示名经 {@link senderNickname} 放入 metadata.nickname。
123
+ */
124
+ export declare function senderUserId(ev: OneBot12Event): string;
125
+ /** 事件携带的发送者显示名(`user.name` / `qq.nickname`),没有则返回 undefined。 */
126
+ export declare function senderNickname(ev: OneBot12Event): string | undefined;
127
+ /**
128
+ * 判断入站消息是否 @ 了本机:OneBot12 提及段为 `{type:'mention', data:{user_id}}`,
129
+ * 目标 user_id 等于事件 self.user_id(self 为 {platform, user_id} 对象)时视为提及。
130
+ * 新 Plugin Runtime 的 Message.content 为纯文本,mention 信息只能经 metadata.mentioned 传递。
131
+ */
132
+ export declare function isBotMentioned(ev: OneBot12Event): boolean;
133
+ /**
134
+ * canonical MediaRef → OneBot 12 媒体字段(扩展字段降级形状)。
135
+ * spec 正式投递形状是 `file_id`(先 upload_file 物化,见
136
+ * {@link uploadOneBot12MediaSegments});上传失败时按常见扩展字段
137
+ * 降级输出:url → `url`、base64 → `data`、本地路径 → `path`;
138
+ * kind=file(平台不透明引用)直接按 `file_id` 复投。
139
+ */
140
+ export declare function mediaRefToOneBot12Fields(media: MediaRef): Record<string, unknown>;
141
+ /**
142
+ * Wire-encode an already-rendered outbound payload into OneBot 12 message segments.
143
+ * 入参假定已经 core `normalizeOutboundPayload` 归一为 canonical Segment[];
144
+ * 媒体段只认 `data.media`(canonical MediaRef),无 MediaRef 的媒体段 warn 后丢弃。
145
+ */
146
+ export declare function formatOutboundSegments(payload: unknown): OneBot12Segment[];
147
+ /** 端点动作调用签名(WS echo 请求 / webhook api_url HTTP)。 */
148
+ export type OneBot12CallAction = (action: string, params: Record<string, unknown>) => Promise<unknown>;
149
+ /** canonical MediaRef → OB12 `upload_file` 动作参数(spec: type url/path/data)。 */
150
+ export declare function mediaRefToOneBot12UploadParams(segmentType: string, data: Record<string, unknown>, media: MediaRef): Record<string, unknown> | undefined;
151
+ /**
152
+ * 出站媒体段物化:image/voice/audio/video/file 段的 MediaRef(url/base64/path)
153
+ * 先经 `upload_file` 换 file_id(spec 正式投递形状),再交给
154
+ * {@link formatOutboundSegments} 编码;kind=file 的 MediaRef 直接按 file_id 复投。
155
+ * 上传失败保留原段(降级扩展字段透传)并回调 onUploadFailed。
156
+ */
157
+ export declare function uploadOneBot12MediaSegments(payload: unknown, callAction: OneBot12CallAction, onUploadFailed?: (error: unknown) => void): Promise<unknown>;
158
+ /**
159
+ * 结构化会话 → OB12 `send_message` 动作参数:kind 直映射 detail_type;
160
+ * channel 的 guild 容器取自 `conversation.parent`(kind 'channel')。
161
+ */
162
+ export declare function buildSendMessageParams(conversation: ConversationRef, message: OneBot12Segment[]): Record<string, unknown>;
163
+ /**
164
+ * 向 OneBot 实现发送动作请求(HTTP POST),返回动作响应。
165
+ * 供 webhook 出站与纯协议测试使用;WS 路径走 WebSocket echo 请求。
166
+ */
167
+ export declare function callOneBot12Action(options: OneBot12HttpOptions, action: string, params?: Record<string, unknown>, echo?: string): Promise<OneBot12ActionResponse>;
168
+ /** Build WS connect URL + headers (access_token via Bearer + query). */
169
+ export declare function buildWsConnectOptions(config: OneBot12WsConfig): {
170
+ readonly url: string;
171
+ readonly headers: Record<string, string>;
172
+ readonly safeUrl: string;
173
+ };
@@ -0,0 +1,415 @@
1
+ /**
2
+ * OneBot 12 protocol helpers (no legacy Adapter/Endpoint / segment-mapper).
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ * Spec: https://12.onebot.dev/
5
+ */
6
+ import { isMediaRef } from '@zhin.js/core';
7
+ import { formatCompact, getLogger } from '@zhin.js/logger';
8
+ const logger = getLogger('onebot12');
9
+ export function resolveOneBot12Config(config = {}) {
10
+ const entry = config.endpoints?.find((item) => item.context === 'onebot12');
11
+ const connection = config.connection
12
+ ?? entry?.connection
13
+ ?? 'ws';
14
+ const id = (typeof config.id === 'string' && config.id)
15
+ || (typeof entry?.id === 'string' && entry.id)
16
+ || process.env.ONEBOT12_BOT_NAME
17
+ || 'onebot12-bot';
18
+ const access_token = config.access_token ?? entry?.access_token;
19
+ if (connection === 'ws') {
20
+ const url = config.url ?? entry?.url;
21
+ if (!url) {
22
+ throw new TypeError('OneBot12 connection:ws requires url (plugins.<key>.url or endpoints with context: onebot12)');
23
+ }
24
+ return {
25
+ context: 'onebot12',
26
+ connection: 'ws',
27
+ id,
28
+ access_token,
29
+ url,
30
+ reconnect_interval: config.reconnect_interval ?? entry?.reconnect_interval ?? 5000,
31
+ heartbeat_interval: config.heartbeat_interval ?? entry?.heartbeat_interval ?? 30_000,
32
+ };
33
+ }
34
+ if (connection === 'webhook') {
35
+ const path = config.path ?? entry?.path;
36
+ if (!path) {
37
+ throw new TypeError('OneBot12 connection:webhook requires path');
38
+ }
39
+ return {
40
+ context: 'onebot12',
41
+ connection: 'webhook',
42
+ id,
43
+ access_token,
44
+ path,
45
+ api_url: config.api_url ?? entry?.api_url,
46
+ };
47
+ }
48
+ if (connection === 'wss') {
49
+ const path = config.path ?? entry?.path;
50
+ if (!path) {
51
+ throw new TypeError('OneBot12 connection:wss requires path');
52
+ }
53
+ return {
54
+ context: 'onebot12',
55
+ connection: 'wss',
56
+ id,
57
+ access_token,
58
+ path,
59
+ heartbeat_interval: config.heartbeat_interval ?? entry?.heartbeat_interval ?? 30_000,
60
+ };
61
+ }
62
+ throw new TypeError(`Unknown OneBot12 connection: ${String(connection)}`);
63
+ }
64
+ /** 判断是否为消息事件(type=message) */
65
+ export function isMessageEvent(ev) {
66
+ return ev.type === 'message' && !!ev.message_id;
67
+ }
68
+ /** 从事件得到场景 id:私聊 user_id,群 group_id,频道 channel_id 或 guild_id:channel_id */
69
+ export function getChannelId(ev) {
70
+ if (ev.detail_type === 'private' && ev.user_id)
71
+ return ev.user_id;
72
+ if (ev.detail_type === 'group' && ev.group_id)
73
+ return ev.group_id;
74
+ if (ev.detail_type === 'channel' && ev.channel_id) {
75
+ return ev.guild_id ? `${ev.guild_id}:${ev.channel_id}` : ev.channel_id;
76
+ }
77
+ return ev.user_id ?? ev.group_id ?? '';
78
+ }
79
+ /**
80
+ * 入站归一化 → ConversationRef:`detail_type` 直映射 kind;channel 的 guild 容器
81
+ * 进 `parent`(kind 'channel');私聊临时会话(private 事件携带 group_id)映射为
82
+ * group 容器内的 private 会话。
83
+ */
84
+ export function onebot12InboundConversation(endpointKey, ev) {
85
+ const endpoint = { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey };
86
+ if (ev.detail_type === 'group' && ev.group_id) {
87
+ return { endpoint, kind: 'group', id: ev.group_id };
88
+ }
89
+ if (ev.detail_type === 'channel' && ev.channel_id) {
90
+ return {
91
+ endpoint,
92
+ kind: 'channel',
93
+ id: ev.channel_id,
94
+ ...(ev.guild_id ? { parent: { kind: 'channel', id: ev.guild_id } } : {}),
95
+ };
96
+ }
97
+ return {
98
+ endpoint,
99
+ kind: 'private',
100
+ id: ev.user_id ?? ev.group_id ?? '',
101
+ ...(ev.detail_type === 'private' && ev.group_id
102
+ ? { parent: { kind: 'group', id: ev.group_id } }
103
+ : {}),
104
+ };
105
+ }
106
+ /** Build inbound text for OutboundMessageService.receive */
107
+ export function formatInboundContent(ev) {
108
+ if (Array.isArray(ev.message)) {
109
+ return ev.message
110
+ .map((seg) => (seg.type === 'text' ? String(seg.data?.text ?? '') : ''))
111
+ .join('');
112
+ }
113
+ return typeof ev.alt_message === 'string'
114
+ ? ev.alt_message.replace(/\[[^\]]*(?:image|audio|video|file)[^\]]*\]/gi, '').trim()
115
+ : '';
116
+ }
117
+ /**
118
+ * Runtime Message sender 必须是用户 ID(agent bridge 以 sender 与 endpointMaster 比对)。
119
+ * 显示名经 {@link senderNickname} 放入 metadata.nickname。
120
+ */
121
+ export function senderUserId(ev) {
122
+ return ev.user_id ?? '';
123
+ }
124
+ /** 事件携带的发送者显示名(`user.name` / `qq.nickname`),没有则返回 undefined。 */
125
+ export function senderNickname(ev) {
126
+ const record = ev;
127
+ const name = record['user.name'] ?? record['qq.nickname'];
128
+ if (typeof name === 'string' && name)
129
+ return name;
130
+ return undefined;
131
+ }
132
+ /**
133
+ * 判断入站消息是否 @ 了本机:OneBot12 提及段为 `{type:'mention', data:{user_id}}`,
134
+ * 目标 user_id 等于事件 self.user_id(self 为 {platform, user_id} 对象)时视为提及。
135
+ * 新 Plugin Runtime 的 Message.content 为纯文本,mention 信息只能经 metadata.mentioned 传递。
136
+ */
137
+ export function isBotMentioned(ev) {
138
+ const selfId = ev.self?.user_id;
139
+ if (!selfId || !Array.isArray(ev.message))
140
+ return false;
141
+ return ev.message.some((seg) => seg.type === 'mention' && String(seg.data?.['user_id'] ?? '') === selfId);
142
+ }
143
+ /** OneBot 12 携带媒体的段类型。 */
144
+ const ONEBOT12_MEDIA_TYPES = new Set(['image', 'voice', 'audio', 'video', 'file']);
145
+ /** 媒体段 data 里的 canonical 字段,归一后不重复进 wire。 */
146
+ const MEDIA_DATA_SKIP_KEYS = new Set(['media', 'alt', 'mime_type']);
147
+ /**
148
+ * canonical MediaRef → OneBot 12 媒体字段(扩展字段降级形状)。
149
+ * spec 正式投递形状是 `file_id`(先 upload_file 物化,见
150
+ * {@link uploadOneBot12MediaSegments});上传失败时按常见扩展字段
151
+ * 降级输出:url → `url`、base64 → `data`、本地路径 → `path`;
152
+ * kind=file(平台不透明引用)直接按 `file_id` 复投。
153
+ */
154
+ export function mediaRefToOneBot12Fields(media) {
155
+ if (media.kind === 'file') {
156
+ return { file_id: media.value };
157
+ }
158
+ if (media.kind === 'base64') {
159
+ const value = media.value.startsWith('base64://')
160
+ ? media.value.slice('base64://'.length)
161
+ : media.value;
162
+ return { data: value };
163
+ }
164
+ if (media.kind === 'path') {
165
+ return { path: media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value };
166
+ }
167
+ return { url: media.value };
168
+ }
169
+ function oneBot12MediaExtra(data) {
170
+ const extra = {};
171
+ for (const [key, value] of Object.entries(data)) {
172
+ if (!MEDIA_DATA_SKIP_KEYS.has(key))
173
+ extra[key] = value;
174
+ }
175
+ return extra;
176
+ }
177
+ function oneBot12MediaSegment(type, data) {
178
+ // 已物化为 file_id 的段是 spec 正式形状,原样透传。
179
+ if (typeof data.file_id === 'string' && data.file_id)
180
+ return { type, data };
181
+ // canonical MediaRef 唯一来源(中央 normalizeOutboundPayload 已保证 canonical)
182
+ const media = isMediaRef(data.media) ? data.media : undefined;
183
+ if (!media) {
184
+ logger.warn(formatCompact({
185
+ op: 'onebot12_outbound_media_dropped',
186
+ type,
187
+ reason: 'missing_media_ref',
188
+ }));
189
+ return null;
190
+ }
191
+ return { type, data: { ...oneBot12MediaExtra(data), ...mediaRefToOneBot12Fields(media) } };
192
+ }
193
+ /**
194
+ * canonical Segment → OneBot 12 数组段:
195
+ * - mention → mention(`user_id: target`,`target: 'all'` → mention_all);
196
+ * - reply(`message_id`)→ reply(`message_id`);
197
+ * - image / voice / audio / video / file 的 MediaRef → url/data/path/file_id 字段,
198
+ * 无 canonical MediaRef 的媒体段 warn 后丢弃(返回 null);
199
+ * - 其余(已是 wire 形状的段、平台扩展段)原样透传。
200
+ */
201
+ function canonicalToOneBotSegment(segment) {
202
+ const data = segment.data ?? {};
203
+ switch (segment.type) {
204
+ case 'mention': {
205
+ const target = data.target ?? data.user_id ?? data.id;
206
+ if (target == null)
207
+ return { type: segment.type, data };
208
+ if (String(target) === 'all')
209
+ return { type: 'mention_all', data: {} };
210
+ return { type: 'mention', data: { user_id: String(target) } };
211
+ }
212
+ case 'reply': {
213
+ const messageId = data.message_id ?? data.id;
214
+ if (messageId == null)
215
+ return { type: segment.type, data };
216
+ return { type: 'reply', data: { message_id: String(messageId) } };
217
+ }
218
+ default:
219
+ if (ONEBOT12_MEDIA_TYPES.has(segment.type)) {
220
+ return oneBot12MediaSegment(segment.type, data);
221
+ }
222
+ return { type: segment.type, data };
223
+ }
224
+ }
225
+ /**
226
+ * Wire-encode an already-rendered outbound payload into OneBot 12 message segments.
227
+ * 入参假定已经 core `normalizeOutboundPayload` 归一为 canonical Segment[];
228
+ * 媒体段只认 `data.media`(canonical MediaRef),无 MediaRef 的媒体段 warn 后丢弃。
229
+ */
230
+ export function formatOutboundSegments(payload) {
231
+ if (typeof payload === 'string') {
232
+ return [{ type: 'text', data: { text: payload } }];
233
+ }
234
+ const items = Array.isArray(payload)
235
+ ? payload
236
+ : payload && typeof payload === 'object' && 'type' in payload
237
+ ? [payload]
238
+ : [];
239
+ if (items.length === 0) {
240
+ const text = payload == null
241
+ ? ''
242
+ : typeof payload === 'object'
243
+ ? JSON.stringify(payload)
244
+ : String(payload);
245
+ return [{ type: 'text', data: { text } }];
246
+ }
247
+ const segs = [];
248
+ for (const item of items) {
249
+ if (typeof item === 'string') {
250
+ segs.push({ type: 'text', data: { text: item } });
251
+ continue;
252
+ }
253
+ const seg = canonicalToOneBotSegment(item);
254
+ if (seg)
255
+ segs.push(seg);
256
+ }
257
+ return segs.length ? segs : [{ type: 'text', data: { text: '' } }];
258
+ }
259
+ /** upload_file 文件名:优先段 data.name/filename,URL/路径取 basename,再按 mime 给默认名。 */
260
+ function oneBot12UploadName(segmentType, data, media) {
261
+ const named = data.name ?? data.filename;
262
+ if (typeof named === 'string' && named)
263
+ return named;
264
+ if (media.kind === 'url') {
265
+ try {
266
+ const base = new URL(media.value).pathname.split('/').filter(Boolean).pop();
267
+ if (base)
268
+ return base;
269
+ }
270
+ catch {
271
+ /* ignore */
272
+ }
273
+ }
274
+ if (media.kind === 'path') {
275
+ const base = media.value.split(/[\\/]/).filter(Boolean).pop();
276
+ if (base)
277
+ return base;
278
+ }
279
+ const ext = media.mime_type?.split('/')[1]?.split(';')[0];
280
+ return `${segmentType}.${ext || 'bin'}`;
281
+ }
282
+ /** canonical MediaRef → OB12 `upload_file` 动作参数(spec: type url/path/data)。 */
283
+ export function mediaRefToOneBot12UploadParams(segmentType, data, media) {
284
+ const name = oneBot12UploadName(segmentType, data, media);
285
+ if (media.kind === 'url')
286
+ return { type: 'url', name, url: media.value };
287
+ if (media.kind === 'path') {
288
+ const path = media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value;
289
+ return { type: 'path', name, path };
290
+ }
291
+ if (media.kind === 'base64') {
292
+ const value = media.value.startsWith('base64://')
293
+ ? media.value.slice('base64://'.length)
294
+ : media.value;
295
+ return { type: 'data', name, data: value };
296
+ }
297
+ return undefined;
298
+ }
299
+ async function uploadOneMediaSegment(item, callAction, onUploadFailed) {
300
+ if (!item || typeof item !== 'object' || Array.isArray(item))
301
+ return item;
302
+ const segment = item;
303
+ if (typeof segment.type !== 'string' || !ONEBOT12_MEDIA_TYPES.has(segment.type))
304
+ return item;
305
+ const data = segment.data ?? {};
306
+ if (typeof data.file_id === 'string' && data.file_id)
307
+ return item;
308
+ // canonical MediaRef 唯一来源;无 MediaRef 的段原样保留,由
309
+ // formatOutboundSegments warn 后丢弃。
310
+ const media = isMediaRef(data.media) ? data.media : undefined;
311
+ if (!media)
312
+ return item;
313
+ // kind=file:平台不透明引用(OB12 file_id 复投),直接物化为 file_id。
314
+ if (media.kind === 'file') {
315
+ return { type: segment.type, data: { ...oneBot12MediaExtra(data), file_id: media.value } };
316
+ }
317
+ const params = mediaRefToOneBot12UploadParams(segment.type, data, media);
318
+ if (!params)
319
+ return item;
320
+ try {
321
+ const result = await callAction('upload_file', params);
322
+ if (result && typeof result.file_id === 'string' && result.file_id) {
323
+ return { type: segment.type, data: { ...oneBot12MediaExtra(data), file_id: result.file_id } };
324
+ }
325
+ throw new Error('upload_file 响应缺少 file_id');
326
+ }
327
+ catch (error) {
328
+ // 上传失败降级:保留原段,由 formatOutboundSegments 走扩展字段(url/data/path)
329
+ onUploadFailed?.(error);
330
+ return item;
331
+ }
332
+ }
333
+ /**
334
+ * 出站媒体段物化:image/voice/audio/video/file 段的 MediaRef(url/base64/path)
335
+ * 先经 `upload_file` 换 file_id(spec 正式投递形状),再交给
336
+ * {@link formatOutboundSegments} 编码;kind=file 的 MediaRef 直接按 file_id 复投。
337
+ * 上传失败保留原段(降级扩展字段透传)并回调 onUploadFailed。
338
+ */
339
+ export async function uploadOneBot12MediaSegments(payload, callAction, onUploadFailed) {
340
+ if (Array.isArray(payload)) {
341
+ return Promise.all(payload.map((item) => uploadOneMediaSegment(item, callAction, onUploadFailed)));
342
+ }
343
+ return uploadOneMediaSegment(payload, callAction, onUploadFailed);
344
+ }
345
+ /**
346
+ * 结构化会话 → OB12 `send_message` 动作参数:kind 直映射 detail_type;
347
+ * channel 的 guild 容器取自 `conversation.parent`(kind 'channel')。
348
+ */
349
+ export function buildSendMessageParams(conversation, message) {
350
+ const params = {
351
+ message,
352
+ detail_type: conversation.kind,
353
+ };
354
+ if (conversation.kind === 'private') {
355
+ params.user_id = conversation.id;
356
+ }
357
+ else if (conversation.kind === 'group') {
358
+ params.group_id = conversation.id;
359
+ }
360
+ else {
361
+ params.channel_id = conversation.id;
362
+ if (conversation.parent?.kind === 'channel')
363
+ params.guild_id = conversation.parent.id;
364
+ }
365
+ return params;
366
+ }
367
+ /**
368
+ * 向 OneBot 实现发送动作请求(HTTP POST),返回动作响应。
369
+ * 供 webhook 出站与纯协议测试使用;WS 路径走 WebSocket echo 请求。
370
+ */
371
+ export async function callOneBot12Action(options, action, params = {}, echo) {
372
+ const headers = { 'Content-Type': 'application/json' };
373
+ if (options.access_token) {
374
+ headers.Authorization = `Bearer ${options.access_token}`;
375
+ }
376
+ const body = { action, params };
377
+ if (echo)
378
+ body.echo = echo;
379
+ const res = await fetch(options.url, {
380
+ method: 'POST',
381
+ headers,
382
+ body: JSON.stringify(body),
383
+ signal: AbortSignal.timeout(30_000),
384
+ });
385
+ const text = await res.text();
386
+ if (res.status === 401)
387
+ throw new Error(`OneBot12 鉴权失败: ${text}`);
388
+ if (res.status !== 200)
389
+ throw new Error(`OneBot12 HTTP ${res.status}: ${text}`);
390
+ let data;
391
+ try {
392
+ data = JSON.parse(text);
393
+ }
394
+ catch {
395
+ throw new Error(`OneBot12 无效响应: ${text.slice(0, 200)}`);
396
+ }
397
+ if (data.status === 'failed' && data.retcode !== 0) {
398
+ throw new Error(`OneBot12 动作失败 retcode=${data.retcode}: ${data.message}`);
399
+ }
400
+ return data;
401
+ }
402
+ /** Build WS connect URL + headers (access_token via Bearer + query). */
403
+ export function buildWsConnectOptions(config) {
404
+ const headers = {};
405
+ let connectUrl = config.url;
406
+ if (config.access_token) {
407
+ headers.Authorization = `Bearer ${config.access_token}`;
408
+ const url = new URL(config.url);
409
+ url.searchParams.set('access_token', config.access_token);
410
+ connectUrl = url.toString();
411
+ }
412
+ const safeUrl = new URL(connectUrl);
413
+ safeUrl.searchParams.delete('access_token');
414
+ return { url: connectUrl, headers, safeUrl: safeUrl.toString() };
415
+ }
@@ -0,0 +1,7 @@
1
+ import type { EndpointEventEmitter } from 'zhin.js/adapter';
2
+ import { type getAdapterLogger } from '@zhin.js/logger';
3
+ import type { OneBot12Event } from './protocol.js';
4
+ export interface OneBot12SideEventCaller {
5
+ callApi(action: string, params?: Record<string, unknown>): Promise<unknown>;
6
+ }
7
+ export declare function receiveOneBot12SideEvent(emit: EndpointEventEmitter, endpointKey: string, caller: OneBot12SideEventCaller, raw: OneBot12Event, 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 receiveOneBot12SideEvent(emit, endpointKey, caller, raw, logger) {
4
+ if (!emit)
5
+ return;
6
+ const record = raw;
7
+ const eventType = String(record.type ?? record.post_type ?? '');
8
+ const detailType = String(record.detail_type ?? '');
9
+ const isRequest = eventType === 'request' || eventType.startsWith('request.');
10
+ const isFriend = detailType.includes('friend') || String(record.request_type ?? '') === 'friend';
11
+ void receiveOneBotLikeSideEvent(emit, {
12
+ adapter: 'onebot12',
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: 'onebot12_side_event_failed',
31
+ endpoint: endpointKey,
32
+ type: eventType || undefined,
33
+ error: err instanceof Error ? err.message : String(err),
34
+ }));
35
+ });
36
+ }
@@ -0,0 +1,23 @@
1
+ import { ClientEndpoint, type EndpointControl, type EndpointManagement, type EndpointSendRequest } from 'zhin.js/adapter';
2
+ import type { HttpHost } from '@zhin.js/host-http';
3
+ import type { CapabilityId } from 'zhin.js';
4
+ import { callOneBot12Action, type OneBot12WebhookConfig } from './protocol.js';
5
+ import { type Onebot12Client } from './client.js';
6
+ export interface OneBot12WebhookEndpointOptions {
7
+ readonly id: CapabilityId;
8
+ readonly http: HttpHost;
9
+ readonly config: OneBot12WebhookConfig;
10
+ readonly callAction?: typeof callOneBot12Action;
11
+ }
12
+ export declare class OneBot12WebhookEndpoint extends ClientEndpoint<Onebot12Client> {
13
+ #private;
14
+ readonly client: Onebot12Client;
15
+ readonly management: EndpointManagement;
16
+ readonly control: EndpointControl;
17
+ readonly content: import("@zhin.js/adapter").EndpointContentPort;
18
+ constructor(options: OneBot12WebhookEndpointOptions);
19
+ start(): Promise<void>;
20
+ stop(): Promise<void>;
21
+ send({ conversation, payload }: EndpointSendRequest): Promise<string>;
22
+ recallMessage(messageId: string): Promise<void>;
23
+ }