@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +745 -3
  2. package/PERMITS.md +14 -0
  3. package/README.md +59 -367
  4. package/adapters/onebot11/index.js +40 -0
  5. package/adapters/onebot11/index.ts +53 -0
  6. package/agents/onebot11/agent.json +20 -0
  7. package/agents/onebot11/boundaries.md +3 -0
  8. package/agents/onebot11/conventions.md +3 -0
  9. package/agents/onebot11/skills/onebot11/SKILL.md +15 -0
  10. package/agents/onebot11/skills/onebot11/tools/set_title/index.js +25 -0
  11. package/agents/onebot11/skills/onebot11/tools/set_title/index.ts +24 -0
  12. package/agents/onebot11/system.md +3 -0
  13. package/commands/onebot11/endpoint/add/[id]/index.js +3 -0
  14. package/commands/onebot11/endpoint/add/[id]/index.ts +3 -0
  15. package/commands/onebot11/endpoint/definition.js +25 -0
  16. package/commands/onebot11/endpoint/definition.ts +23 -0
  17. package/commands/onebot11/endpoint/list/index.js +3 -0
  18. package/commands/onebot11/endpoint/list/index.ts +3 -0
  19. package/commands/onebot11/endpoint/remove/[id]/index.js +3 -0
  20. package/commands/onebot11/endpoint/remove/[id]/index.ts +3 -0
  21. package/lib/client.d.ts +20 -0
  22. package/lib/client.js +30 -0
  23. package/lib/content-port.d.ts +4 -0
  24. package/lib/content-port.js +63 -0
  25. package/lib/endpoint-management.d.ts +15 -0
  26. package/lib/endpoint-management.js +47 -0
  27. package/lib/index.d.ts +10 -16
  28. package/lib/index.js +7 -53
  29. package/lib/onebot11-runtime-state.d.ts +1 -0
  30. package/lib/onebot11-runtime-state.js +6 -0
  31. package/lib/protocol.d.ts +143 -0
  32. package/lib/protocol.js +327 -0
  33. package/lib/side-event-dispatch.d.ts +7 -0
  34. package/lib/side-event-dispatch.js +36 -0
  35. package/lib/ws-endpoint.d.ts +22 -0
  36. package/lib/ws-endpoint.js +209 -0
  37. package/lib/ws-transport.d.ts +12 -0
  38. package/lib/ws-transport.js +59 -0
  39. package/lib/ws-types.d.ts +18 -0
  40. package/lib/ws-types.js +1 -0
  41. package/lib/wss-auth.d.ts +2 -0
  42. package/lib/wss-auth.js +16 -0
  43. package/lib/wss-endpoint.d.ts +25 -0
  44. package/lib/wss-endpoint.js +186 -0
  45. package/package.json +82 -23
  46. package/plugin.js +18 -0
  47. package/schema.json +114 -0
  48. package/src/client.ts +78 -0
  49. package/src/content-port.ts +66 -0
  50. package/src/endpoint-management.ts +72 -0
  51. package/src/index.ts +60 -65
  52. package/src/onebot11-runtime-state.ts +7 -0
  53. package/src/protocol.ts +452 -0
  54. package/src/side-event-dispatch.ts +48 -0
  55. package/src/ws-endpoint.ts +272 -0
  56. package/src/ws-transport.ts +85 -0
  57. package/src/ws-types.ts +21 -0
  58. package/src/wss-auth.ts +17 -0
  59. package/src/wss-endpoint.ts +239 -0
  60. package/lib/adapter.d.ts +0 -32
  61. package/lib/adapter.d.ts.map +0 -1
  62. package/lib/adapter.js +0 -92
  63. package/lib/adapter.js.map +0 -1
  64. package/lib/bot-ws-client.d.ts +0 -40
  65. package/lib/bot-ws-client.d.ts.map +0 -1
  66. package/lib/bot-ws-client.js +0 -397
  67. package/lib/bot-ws-client.js.map +0 -1
  68. package/lib/bot-ws-server.d.ts +0 -42
  69. package/lib/bot-ws-server.d.ts.map +0 -1
  70. package/lib/bot-ws-server.js +0 -344
  71. package/lib/bot-ws-server.js.map +0 -1
  72. package/lib/index.d.ts.map +0 -1
  73. package/lib/index.js.map +0 -1
  74. package/lib/onebot-get-msg.d.ts +0 -3
  75. package/lib/onebot-get-msg.d.ts.map +0 -1
  76. package/lib/onebot-get-msg.js +0 -27
  77. package/lib/onebot-get-msg.js.map +0 -1
  78. package/lib/types.d.ts +0 -54
  79. package/lib/types.d.ts.map +0 -1
  80. package/lib/types.js +0 -5
  81. package/lib/types.js.map +0 -1
  82. package/plugin.yml +0 -3
  83. package/skills/onebot11/SKILL.md +0 -80
  84. package/src/adapter.ts +0 -104
  85. package/src/bot-ws-client.ts +0 -421
  86. package/src/bot-ws-server.ts +0 -382
  87. package/src/onebot-get-msg.ts +0 -33
  88. package/src/types.ts +0 -58
@@ -0,0 +1,72 @@
1
+ /**
2
+ * OneBot11 endpoint management 语义端口(Console 社交面 RPC 消费,见
3
+ * packages/im/adapter/src/endpoint-management.ts)。
4
+ *
5
+ * 归一化取舍:
6
+ * - friend → {user_id:number, nickname, remark: remark ?? ''}
7
+ * - group → {group_id:number, name: group_name ?? name}
8
+ * - 群成员列表保持 OneBot11 原生形状,仅保证数组
9
+ */
10
+ import type {
11
+ EndpointFriend,
12
+ EndpointGroup,
13
+ EndpointManagement,
14
+ } from 'zhin.js/adapter';
15
+
16
+ /** 管理面只依赖 endpoint 的 callApi(ws/wss 两种传输各自实现)。 */
17
+ export interface OneBot11ManagementCaller {
18
+ callApi(action: string, params?: Record<string, unknown>): Promise<unknown>;
19
+ }
20
+
21
+ export function createOneBot11EndpointManagement(
22
+ endpoint: OneBot11ManagementCaller,
23
+ ): EndpointManagement {
24
+ return Object.freeze<EndpointManagement>({
25
+ async listFriends(): Promise<readonly EndpointFriend[]> {
26
+ const data = await endpoint.callApi('get_friend_list');
27
+ return toArray(data).map((value) => {
28
+ const friend = asRecord(value);
29
+ return {
30
+ user_id: toNumberId(friend.user_id, 'user_id'),
31
+ nickname: String(friend.nickname ?? ''),
32
+ remark: String(friend.remark ?? ''),
33
+ };
34
+ });
35
+ },
36
+ async listGroups(): Promise<readonly EndpointGroup[]> {
37
+ const data = await endpoint.callApi('get_group_list');
38
+ return toArray(data).map((value) => {
39
+ const group = asRecord(value);
40
+ return {
41
+ group_id: toNumberId(group.group_id, 'group_id'),
42
+ name: String(group.group_name ?? group.name ?? ''),
43
+ };
44
+ });
45
+ },
46
+ async listGroupMembers(groupId: string): Promise<readonly unknown[]> {
47
+ const data = await endpoint.callApi('get_group_member_list', {
48
+ group_id: toNumberId(groupId, 'group_id'),
49
+ });
50
+ return toArray(data);
51
+ },
52
+ });
53
+ }
54
+
55
+ function asRecord(value: unknown): Record<string, unknown> {
56
+ return value !== null && typeof value === 'object'
57
+ ? value as Record<string, unknown>
58
+ : {};
59
+ }
60
+
61
+ function toArray(value: unknown): readonly unknown[] {
62
+ return Array.isArray(value) ? value : [];
63
+ }
64
+
65
+ /** console RPC 传入的 gid/uid 可能是字符串,统一收敛为数字。 */
66
+ function toNumberId(value: unknown, label: string): number {
67
+ const n = Number(value);
68
+ if (!Number.isFinite(n) || String(value ?? '').trim() === '') {
69
+ throw new TypeError(`onebot11 ${label} 必须是数字: ${String(value)}`);
70
+ }
71
+ return n;
72
+ }
package/src/index.ts CHANGED
@@ -1,71 +1,66 @@
1
- /**
2
- * OneBot11 适配器入口:单一适配器,支持正向 WS / 反向 WS(connection: ws | wss)
3
- */
4
- import { usePlugin, type Plugin, type Context, type IGroupManagement, createGroupManagementTools, type ToolFeature } from 'zhin.js';
5
- import type { Router } from '@zhin.js/host-router';
6
- import { OneBot11Adapter } from './adapter.js';
1
+ export {
2
+ buildSendAction,
3
+ buildWsConnectOptions,
4
+ extractQuoteId,
5
+ formatInboundContent,
6
+ formatInboundMetadata,
7
+ formatOutboundSegments,
8
+ getChannelId,
9
+ isMessageEvent,
10
+ isOneBot11BotMentioned,
11
+ mediaRefToOneBotFile,
12
+ onebot11InboundConversation,
13
+ resolveOneBot11Config,
14
+ senderDisplayName,
15
+ senderNickname,
16
+ senderUserId,
17
+ type OneBot11ActionRequest,
18
+ type OneBot11ActionResponse,
19
+ type OneBot11ConfigBase,
20
+ type OneBot11EndpointConfig,
21
+ type OneBot11Event,
22
+ type OneBot11Segment,
23
+ type OneBot11Sender,
24
+ type OneBot11WireSegment,
25
+ type OneBot11WsConfig,
26
+ type OneBot11WssConfig,
27
+ type ResolvedOneBot11Config,
28
+ } from './protocol.js';
7
29
 
8
- export * from './types.js';
9
- export { OneBot11WsClient } from './bot-ws-client.js';
10
- export { OneBot11WsServer } from './bot-ws-server.js';
11
- export { OneBot11Adapter, type OneBot11Bot } from './adapter.js';
30
+ export {
31
+ OneBot11WsEndpoint,
32
+ type OneBot11WsEndpointOptions,
33
+ } from './ws-endpoint.js';
12
34
 
13
- declare module 'zhin.js' {
14
- namespace Plugin {
15
- interface Contexts {
16
- router: import('@zhin.js/host-router').Router;
17
- }
18
- }
19
- interface Adapters {
20
- onebot11: OneBot11Adapter;
21
- }
22
- }
35
+ export {
36
+ OneBot11WssEndpoint,
37
+ type OneBot11WssEndpointOptions,
38
+ } from './wss-endpoint.js';
23
39
 
24
- const plugin = usePlugin();
25
- const { provide, useContext } = plugin;
26
- provide({
27
- name: 'onebot11',
28
- description: 'OneBot11 协议适配器(正向 WS / 反向 WS)',
29
- mounted: async (p: Plugin) => {
30
- const adapter = new OneBot11Adapter(p);
31
- await adapter.start();
32
- return adapter;
33
- },
34
- dispose: async (adapter: OneBot11Adapter) => {
35
- await adapter.stop();
36
- },
37
- } as unknown as Context<'onebot11'>);
40
+ export type { OneBot11WsSocket, OneBot11WsCreateOptions } from './ws-types.js';
38
41
 
39
- useContext('tool', 'onebot11', (toolService: ToolFeature, onebot11: OneBot11Adapter) => {
40
- const groupTools = createGroupManagementTools(
41
- onebot11 as unknown as IGroupManagement,
42
- 'onebot11',
43
- );
44
- const disposers: (() => void)[] = groupTools.map(t => toolService.addTool(t, plugin.name));
42
+ export {
43
+ callOneBot11WsAction,
44
+ decodeWsPayload,
45
+ handleOneBot11WsMessage,
46
+ rejectAllPending,
47
+ } from './ws-transport.js';
45
48
 
46
- // Platform-specific tool: set title
47
- disposers.push(toolService.addTool({
48
- name: 'onebot11_set_title',
49
- description: '设置 QQ 群成员的专属头衔。只有群主才能设置。',
50
- parameters: {
51
- type: 'object',
52
- properties: {
53
- bot: { type: 'string', description: 'Bot 名称' },
54
- group_id: { type: 'number', description: '目标群号' },
55
- user_id: { type: 'number', description: '目标成员 QQ号' },
56
- title: { type: 'string', description: '头衔文字' },
57
- },
58
- required: ['bot', 'group_id', 'user_id', 'title'],
59
- },
60
- platforms: ['onebot11'],
61
- tags: ['onebot11'],
62
- execute: async (args: Record<string, any>) => {
63
- const bot = onebot11.bots.get(args.bot);
64
- if (!bot) throw new Error(`Bot ${args.bot} 不存在`);
65
- const success = await bot.setTitle(args.group_id, args.user_id, args.title);
66
- return { success, message: success ? `已将 ${args.user_id} 的头衔设为 "${args.title}"` : '设置失败' };
67
- },
68
- }, plugin.name));
49
+ export { verifyOneBotAccessToken } from './wss-auth.js';
69
50
 
70
- return () => disposers.forEach(d => d());
71
- });
51
+ export {
52
+ Onebot11Client,
53
+ onebot11Client,
54
+ type Onebot11ClientEventMap,
55
+ } from './client.js';
56
+
57
+ export type {
58
+ OneBotV11ActionUrlResolver,
59
+ OneBotV11AdapterConfig as ImHelperOneBotV11AdapterConfig,
60
+ OneBotV11Call,
61
+ OneBotV11ClientConfig,
62
+ OneBotV11Event as ImHelperOneBotV11Event,
63
+ OneBotV11Response,
64
+ } from '@imhelper/onebot-v11';
65
+ export { ProtocolError } from '@imhelper/onebot-v11';
66
+ export type { ProtocolErrorKind, ProtocolErrorOptions } from 'imhelper';
@@ -0,0 +1,7 @@
1
+ /**
2
+ * OneBot11 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `onebot11 endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from 'zhin.js/adapter';
6
+
7
+ export const onebot11RuntimeStateToken = defineEndpointRuntimeStateToken('onebot11');
@@ -0,0 +1,452 @@
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, type MediaRef, type ConversationRef } from '@zhin.js/im-contract';
7
+ import { formatCompact, getLogger } from '@zhin.js/logger';
8
+
9
+ const logger = getLogger('onebot11');
10
+
11
+ /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
12
+ export interface OneBot11EndpointConfig {
13
+ readonly connection?: 'ws' | 'wss';
14
+ readonly id: string;
15
+ readonly access_token?: string;
16
+ readonly url?: string;
17
+ readonly path?: string;
18
+ readonly reconnect_interval?: number;
19
+ readonly heartbeat_interval?: number;
20
+ }
21
+
22
+ export interface OneBot11ConfigBase {
23
+ readonly context: 'onebot11';
24
+ readonly id: string;
25
+ readonly access_token?: string;
26
+ }
27
+
28
+ /** 正向 WebSocket:应用连 OneBot 实现的 WS 服务器 */
29
+ export interface OneBot11WsConfig extends OneBot11ConfigBase {
30
+ readonly connection: 'ws';
31
+ readonly url: string;
32
+ readonly reconnect_interval: number;
33
+ readonly heartbeat_interval: number;
34
+ }
35
+
36
+ /** 反向 WebSocket(slice 1 deferred — needs httpHostToken) */
37
+ export interface OneBot11WssConfig extends OneBot11ConfigBase {
38
+ readonly connection: 'wss';
39
+ readonly path: string;
40
+ readonly heartbeat_interval: number;
41
+ }
42
+
43
+ export type ResolvedOneBot11Config = OneBot11WsConfig | OneBot11WssConfig;
44
+
45
+ export interface OneBot11Sender {
46
+ readonly role?: string;
47
+ readonly nickname?: string;
48
+ readonly card?: string;
49
+ readonly title?: string;
50
+ }
51
+
52
+ export interface OneBot11Segment {
53
+ type: string;
54
+ data?: Record<string, unknown>;
55
+ }
56
+
57
+ export interface OneBot11Event {
58
+ post_type: string;
59
+ self_id?: number | string;
60
+ message_type?: string;
61
+ sub_type?: string;
62
+ message_id?: number | string;
63
+ user_id?: number | string;
64
+ group_id?: number | string;
65
+ sender?: OneBot11Sender;
66
+ message?: OneBot11Segment[];
67
+ raw_message?: string;
68
+ time?: number;
69
+ notice_type?: string;
70
+ request_type?: string;
71
+ [key: string]: unknown;
72
+ }
73
+
74
+ export interface OneBot11ActionRequest {
75
+ action: string;
76
+ params: Record<string, unknown>;
77
+ echo?: string;
78
+ }
79
+
80
+ export interface OneBot11ActionResponse {
81
+ status: 'ok' | 'failed';
82
+ retcode: number;
83
+ data?: unknown;
84
+ message?: string;
85
+ echo?: string;
86
+ }
87
+
88
+ export interface OneBot11WireSegment {
89
+ readonly type: string;
90
+ readonly data?: Record<string, unknown>;
91
+ }
92
+
93
+ function normalizeConnection(
94
+ connection: string | undefined,
95
+ ): 'ws' | 'wss' {
96
+ if (connection === 'ws' || connection === 'wss') return connection;
97
+ return 'ws';
98
+ }
99
+
100
+ export function resolveOneBot11Config(config: OneBot11EndpointConfig): ResolvedOneBot11Config {
101
+ const connection = normalizeConnection(config.connection);
102
+ const id = requiredEndpointField(config.id, 'id');
103
+ const access_token = optionalEndpointField(config.access_token);
104
+
105
+ if (connection === 'ws') {
106
+ const url = requiredEndpointField(config.url, 'url');
107
+ return {
108
+ context: 'onebot11',
109
+ connection: 'ws',
110
+ id,
111
+ access_token,
112
+ url,
113
+ reconnect_interval: config.reconnect_interval ?? 5000,
114
+ heartbeat_interval: config.heartbeat_interval ?? 30_000,
115
+ };
116
+ }
117
+
118
+ if (connection === 'wss') {
119
+ const path = requiredEndpointField(config.path, 'path');
120
+ return {
121
+ context: 'onebot11',
122
+ connection: 'wss',
123
+ id,
124
+ access_token,
125
+ path,
126
+ heartbeat_interval: config.heartbeat_interval ?? 30_000,
127
+ };
128
+ }
129
+
130
+ throw new TypeError(`Unknown OneBot11 connection: ${String(connection)}`);
131
+ }
132
+
133
+ function requiredEndpointField(
134
+ value: unknown,
135
+ field: 'id' | 'url' | 'path',
136
+ ): string {
137
+ const resolved = optionalEndpointField(value);
138
+ if (!resolved) {
139
+ throw new TypeError(`OneBot11 endpoint requires a non-empty ${field}`);
140
+ }
141
+ return resolved;
142
+ }
143
+
144
+ function optionalEndpointField(value: unknown): string | undefined {
145
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
146
+ }
147
+
148
+ /** 判断是否为消息事件(post_type=message) */
149
+ export function isMessageEvent(
150
+ ev: OneBot11Event,
151
+ ): ev is OneBot11Event & { message_id: number | string } {
152
+ return ev.post_type === 'message' && ev.message_id != null;
153
+ }
154
+
155
+ /** 从事件得到场景 id:私聊 user_id,群 group_id */
156
+ export function getChannelId(ev: OneBot11Event): string {
157
+ if (ev.message_type === 'group' && ev.group_id != null) return String(ev.group_id);
158
+ if (ev.group_id != null && ev.message_type !== 'private') return String(ev.group_id);
159
+ if (ev.user_id != null) return String(ev.user_id);
160
+ return '';
161
+ }
162
+
163
+ /**
164
+ * 入站归一化 → ConversationRef:OneBot 11 `group` 消息 → kind 'group',
165
+ * 其余(private 及带 group_id 的非 private 事件按群处理)→ kind 'private'。
166
+ * OneBot 11 无 guild/temp 容器概念,不填 parent。
167
+ */
168
+ export function onebot11InboundConversation(endpointKey: string, ev: OneBot11Event): ConversationRef {
169
+ const kind = ev.message_type === 'group' || (ev.group_id != null && ev.message_type !== 'private')
170
+ ? 'group' as const
171
+ : 'private' as const;
172
+ return {
173
+ endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
174
+ kind,
175
+ id: getChannelId(ev),
176
+ };
177
+ }
178
+
179
+ /** Build inbound text for OutboundMessageService.receive */
180
+ export function formatInboundContent(ev: OneBot11Event): string {
181
+ if (Array.isArray(ev.message)) {
182
+ return ev.message
183
+ .map((seg) => (seg.type === 'text' ? String(seg.data?.text ?? '') : ''))
184
+ .join('');
185
+ }
186
+ return typeof ev.raw_message === 'string'
187
+ ? stripCqCodes(ev.raw_message).trim()
188
+ : '';
189
+ }
190
+
191
+ function stripCqCodes(input: string): string {
192
+ let result = '';
193
+ let cursor = 0;
194
+ while (cursor < input.length) {
195
+ const start = input.indexOf('[CQ:', cursor);
196
+ if (start < 0) return result + input.slice(cursor);
197
+ result += input.slice(cursor, start);
198
+ const end = input.indexOf(']', start + 4);
199
+ if (end < 0) return result + input.slice(start);
200
+ cursor = end + 1;
201
+ }
202
+ return result;
203
+ }
204
+
205
+ export function senderDisplayName(ev: OneBot11Event): string {
206
+ const name = ev.sender?.card || ev.sender?.nickname;
207
+ if (typeof name === 'string' && name) return name;
208
+ return ev.user_id != null ? String(ev.user_id) : '';
209
+ }
210
+
211
+ /**
212
+ * Runtime Message.sender 必须是用户 ID(agent bridge 用它与 endpointMaster 比对)。
213
+ * user_id 缺失时兜底为空串,绝不回退到显示名。
214
+ */
215
+ export function senderUserId(ev: OneBot11Event): string {
216
+ return ev.user_id != null ? String(ev.user_id) : '';
217
+ }
218
+
219
+ /** 显示名(群名片优先)放 metadata.nickname;没有则返回 undefined,不写该字段。 */
220
+ export function senderNickname(ev: OneBot11Event): string | undefined {
221
+ const name = ev.sender?.card || ev.sender?.nickname;
222
+ return typeof name === 'string' && name ? name : undefined;
223
+ }
224
+
225
+ /**
226
+ * 回复引用 id:优先取 message 数组中的 {type:'reply', data:{id}} 段;
227
+ * 兼容部分实现把 reply 放在事件顶层(标量或带 message_id 的对象)。
228
+ */
229
+ export function extractQuoteId(ev: OneBot11Event): string | undefined {
230
+ if (Array.isArray(ev.message)) {
231
+ for (const seg of ev.message) {
232
+ if (seg?.type === 'reply' && seg.data?.id != null) return String(seg.data.id);
233
+ }
234
+ }
235
+ const reply = ev.reply;
236
+ if (typeof reply === 'number' || typeof reply === 'string') return String(reply);
237
+ if (reply && typeof reply === 'object' && 'message_id' in reply) {
238
+ const id = (reply as { message_id?: unknown }).message_id;
239
+ if (id != null) return String(id);
240
+ }
241
+ return undefined;
242
+ }
243
+
244
+ /** 扫描 message 段中的 at,qq 等于本机 uin(self_id)即视为被 @;`qq:'all'`(@全体)不算。 */
245
+ export function isOneBot11BotMentioned(input: {
246
+ readonly selfId: string | undefined;
247
+ readonly message?: readonly OneBot11Segment[];
248
+ }): boolean {
249
+ if (!input.selfId || !Array.isArray(input.message)) return false;
250
+ return input.message.some((seg) => {
251
+ if (seg?.type !== 'at' || seg.data?.qq == null) return false;
252
+ const qq = String(seg.data.qq);
253
+ return qq !== 'all' && qq === input.selfId;
254
+ });
255
+ }
256
+
257
+ /** Inbound fields extracted from an event for gateway.receive (ws / wss 两个 endpoint 共用). */
258
+ export interface OneBot11InboundFields {
259
+ readonly metadata: Readonly<Record<string, unknown>>;
260
+ readonly endpointId: string;
261
+ readonly mentioned: boolean;
262
+ readonly replyTo: { readonly id: string } | undefined;
263
+ }
264
+
265
+ /** 构造 gateway.receive 的 metadata 与提升字段(ws / wss 两个 endpoint 共用)。 */
266
+ export function formatInboundMetadata(
267
+ ev: OneBot11Event,
268
+ endpoint: string,
269
+ ): OneBot11InboundFields {
270
+ const selfId = ev.self_id != null ? String(ev.self_id) : undefined;
271
+ const nickname = senderNickname(ev);
272
+ const quoteId = extractQuoteId(ev);
273
+ const mentioned = isOneBot11BotMentioned({ selfId, message: ev.message });
274
+ return {
275
+ metadata: Object.freeze({
276
+ message_type: ev.message_type,
277
+ user_id: ev.user_id != null ? String(ev.user_id) : undefined,
278
+ group_id: ev.group_id != null ? String(ev.group_id) : undefined,
279
+ time: ev.time,
280
+ self_id: selfId,
281
+ role: ev.sender?.role,
282
+ ...(nickname ? { nickname } : {}),
283
+ }),
284
+ endpointId: endpoint,
285
+ mentioned,
286
+ replyTo: quoteId ? { id: quoteId } : undefined,
287
+ };
288
+ }
289
+
290
+ /**
291
+ * canonical MediaRef → OneBot `file` 参数:url 直传、base64 → `base64://`、
292
+ * 本地路径 → `file://`(路径在 OneBot 实现侧解析)、kind=file 不透明引用原样透传。
293
+ */
294
+ export function mediaRefToOneBotFile(media: MediaRef): string {
295
+ if (media.kind === 'base64') {
296
+ return media.value.startsWith('base64://') ? media.value : `base64://${media.value}`;
297
+ }
298
+ if (media.kind === 'path') {
299
+ return media.value.startsWith('file://') ? media.value : `file://${media.value}`;
300
+ }
301
+ return media.value;
302
+ }
303
+
304
+ /** 媒体段 data 里的 canonical-only 字段,不进 OneBot wire。 */
305
+ const MEDIA_DATA_SKIP_KEYS = new Set(['media', 'alt']);
306
+
307
+ /**
308
+ * image / record / video 段:只消费 canonical `data.media` MediaRef,
309
+ * 映射为 OneBot `file` 参数;无 MediaRef 时 warn + 丢弃(返回 null)。
310
+ */
311
+ function oneBotMediaSegment(
312
+ type: 'image' | 'record' | 'video',
313
+ data: Record<string, unknown>,
314
+ ): OneBot11Segment | null {
315
+ if (!isMediaRef(data.media)) {
316
+ logger.warn(formatCompact({
317
+ op: 'onebot11_outbound_media_dropped',
318
+ type,
319
+ reason: 'missing_media_ref',
320
+ }));
321
+ return null;
322
+ }
323
+ const extra: Record<string, unknown> = {};
324
+ for (const [key, value] of Object.entries(data)) {
325
+ if (!MEDIA_DATA_SKIP_KEYS.has(key)) extra[key] = value;
326
+ }
327
+ return { type, data: { ...extra, file: mediaRefToOneBotFile(data.media) } };
328
+ }
329
+
330
+ /**
331
+ * canonical Segment → OneBot 11 数组段:
332
+ * - mention → at(`qq: target`);
333
+ * - reply(`message_id`)→ reply(`id`);
334
+ * - image / audio→record / video 的 MediaRef → `file`(url / base64:// / file:// / 不透明引用);
335
+ * - file 段无 OneBot 11 消息段承载(文件走 upload_*_file API),warn + 丢弃;
336
+ * - face 取 `id`;
337
+ * - 其余(已是 wire 形状的段、平台扩展段)原样透传。
338
+ * 返回 null 表示该段被丢弃。
339
+ */
340
+ function canonicalToOneBotSegment(segment: OneBot11WireSegment): OneBot11Segment | null {
341
+ const data = segment.data ?? {};
342
+ switch (segment.type) {
343
+ case 'mention': {
344
+ const target = data.target ?? data.qq ?? data.id;
345
+ if (target == null) return { type: segment.type, data };
346
+ return { type: 'at', data: { qq: String(target) } };
347
+ }
348
+ case 'reply': {
349
+ const messageId = data.message_id ?? data.id;
350
+ if (messageId == null) return { type: segment.type, data };
351
+ return { type: 'reply', data: { id: String(messageId) } };
352
+ }
353
+ case 'face': {
354
+ if (data.id == null) return { type: segment.type, data };
355
+ return { type: 'face', data: { id: data.id } };
356
+ }
357
+ case 'image':
358
+ case 'record':
359
+ case 'video':
360
+ return oneBotMediaSegment(segment.type, data);
361
+ case 'audio':
362
+ return oneBotMediaSegment('record', data);
363
+ case 'file':
364
+ logger.warn(formatCompact({
365
+ op: 'onebot11_outbound_media_dropped',
366
+ type: 'file',
367
+ reason: 'file_segment_not_supported',
368
+ }));
369
+ return null;
370
+ default:
371
+ return { type: segment.type, data };
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Wire-encode an already-rendered outbound payload into OneBot 11 message segments.
377
+ * 入参假定已经 core `normalizeOutboundPayload` 归一为 canonical Segment[];
378
+ * 媒体段只消费 canonical `data.media`(无 MediaRef 的媒体段 warn + 丢弃),
379
+ * 其余 wire 形状段(at / 平台扩展段)原样透传。
380
+ */
381
+ export function formatOutboundSegments(payload: unknown): OneBot11Segment[] {
382
+ if (typeof payload === 'string') {
383
+ return [{ type: 'text', data: { text: payload } }];
384
+ }
385
+
386
+ const items: Array<string | OneBot11WireSegment> = Array.isArray(payload)
387
+ ? payload as Array<string | OneBot11WireSegment>
388
+ : payload && typeof payload === 'object' && 'type' in (payload as object)
389
+ ? [payload as OneBot11WireSegment]
390
+ : [];
391
+
392
+ if (items.length === 0) {
393
+ const text = payload == null
394
+ ? ''
395
+ : typeof payload === 'object'
396
+ ? JSON.stringify(payload)
397
+ : String(payload);
398
+ return [{ type: 'text', data: { text } }];
399
+ }
400
+
401
+ const segs: OneBot11Segment[] = [];
402
+ for (const item of items) {
403
+ if (typeof item === 'string') {
404
+ segs.push({ type: 'text', data: { text: item } });
405
+ continue;
406
+ }
407
+ const seg = canonicalToOneBotSegment(item);
408
+ if (seg) segs.push(seg);
409
+ }
410
+ return segs.length ? segs : [{ type: 'text', data: { text: '' } }];
411
+ }
412
+
413
+ export function buildSendAction(
414
+ conversation: ConversationRef,
415
+ message: OneBot11Segment[],
416
+ ): { action: string; params: Record<string, unknown> } {
417
+ if (conversation.kind === 'group') {
418
+ return {
419
+ action: 'send_group_msg',
420
+ params: {
421
+ group_id: Number(conversation.id) || conversation.id,
422
+ message,
423
+ },
424
+ };
425
+ }
426
+ return {
427
+ action: 'send_private_msg',
428
+ params: {
429
+ user_id: Number(conversation.id) || conversation.id,
430
+ message,
431
+ },
432
+ };
433
+ }
434
+
435
+ /** Build WS connect URL + headers (access_token via Bearer + query). */
436
+ export function buildWsConnectOptions(config: OneBot11WsConfig): {
437
+ readonly url: string;
438
+ readonly headers: Record<string, string>;
439
+ readonly safeUrl: string;
440
+ } {
441
+ const headers: Record<string, string> = {};
442
+ let connectUrl = config.url;
443
+ if (config.access_token) {
444
+ headers.Authorization = `Bearer ${config.access_token}`;
445
+ const url = new URL(config.url);
446
+ url.searchParams.set('access_token', config.access_token);
447
+ connectUrl = url.toString();
448
+ }
449
+ const safeUrl = new URL(connectUrl);
450
+ safeUrl.searchParams.delete('access_token');
451
+ return { url: connectUrl, headers, safeUrl: safeUrl.toString() };
452
+ }