@zhin.js/adapter-onebot12 1.0.1 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +940 -0
- package/README.md +61 -69
- package/adapters/onebot12/index.js +50 -0
- package/adapters/onebot12/index.ts +64 -0
- package/commands/onebot12/endpoint/add/[id]/index.js +3 -0
- package/commands/onebot12/endpoint/add/[id]/index.ts +3 -0
- package/commands/onebot12/endpoint/definition.js +26 -0
- package/commands/onebot12/endpoint/definition.ts +24 -0
- package/commands/onebot12/endpoint/list/index.js +3 -0
- package/commands/onebot12/endpoint/list/index.ts +3 -0
- package/commands/onebot12/endpoint/remove/[id]/index.js +3 -0
- package/commands/onebot12/endpoint/remove/[id]/index.ts +3 -0
- package/lib/client.d.ts +20 -0
- package/lib/client.js +33 -0
- package/lib/content-port.d.ts +4 -0
- package/lib/content-port.js +45 -0
- package/lib/endpoint-management.d.ts +16 -0
- package/lib/endpoint-management.js +47 -0
- package/lib/index.d.ts +10 -19
- package/lib/index.js +7 -27
- package/lib/onebot12-runtime-state.d.ts +1 -0
- package/lib/onebot12-runtime-state.js +6 -0
- package/lib/protocol.d.ts +157 -0
- package/lib/protocol.js +431 -0
- package/lib/side-event-dispatch.d.ts +7 -0
- package/lib/side-event-dispatch.js +36 -0
- package/lib/webhook.d.ts +23 -0
- package/lib/webhook.js +157 -0
- package/lib/ws-endpoint.d.ts +22 -0
- package/lib/ws-endpoint.js +219 -0
- package/lib/ws-transport.d.ts +12 -0
- package/lib/ws-transport.js +58 -0
- package/lib/ws-types.d.ts +17 -0
- package/lib/ws-types.js +1 -0
- package/lib/wss-auth.d.ts +2 -0
- package/lib/wss-auth.js +16 -0
- package/lib/wss-endpoint.d.ts +25 -0
- package/lib/wss-endpoint.js +193 -0
- package/package.json +67 -16
- package/plugin.js +14 -0
- package/schema.json +120 -0
- package/src/client.ts +81 -0
- package/src/content-port.ts +49 -0
- package/src/endpoint-management.ts +73 -0
- package/src/index.ts +63 -37
- package/src/onebot12-runtime-state.ts +7 -0
- package/src/protocol.ts +575 -0
- package/src/side-event-dispatch.ts +48 -0
- package/src/webhook.ts +219 -0
- package/src/ws-endpoint.ts +285 -0
- package/src/ws-transport.ts +88 -0
- package/src/ws-types.ts +21 -0
- package/src/wss-auth.ts +17 -0
- package/src/wss-endpoint.ts +248 -0
- package/lib/adapter.d.ts +0 -18
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -41
- package/lib/adapter.js.map +0 -1
- package/lib/api.d.ts +0 -14
- package/lib/api.d.ts.map +0 -1
- package/lib/api.js +0 -34
- package/lib/api.js.map +0 -1
- package/lib/endpoint-webhook.d.ts +0 -25
- package/lib/endpoint-webhook.d.ts.map +0 -1
- package/lib/endpoint-webhook.js +0 -118
- package/lib/endpoint-webhook.js.map +0 -1
- package/lib/endpoint-ws.d.ts +0 -26
- package/lib/endpoint-ws.d.ts.map +0 -1
- package/lib/endpoint-ws.js +0 -210
- package/lib/endpoint-ws.js.map +0 -1
- package/lib/endpoint-wss.d.ts +0 -26
- package/lib/endpoint-wss.d.ts.map +0 -1
- package/lib/endpoint-wss.js +0 -187
- package/lib/endpoint-wss.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types.d.ts +0 -77
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -5
- package/lib/types.js.map +0 -1
- package/lib/utils.d.ts +0 -45
- package/lib/utils.d.ts.map +0 -1
- package/lib/utils.js +0 -60
- package/lib/utils.js.map +0 -1
- package/skills/onebot12/SKILL.md +0 -36
- package/src/adapter.ts +0 -54
- package/src/api.ts +0 -48
- package/src/endpoint-webhook.ts +0 -130
- package/src/endpoint-ws.ts +0 -222
- package/src/endpoint-wss.ts +0 -202
- package/src/types.ts +0 -86
- package/src/utils.ts +0 -87
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { receiveOneBotLikeSideEvent } from '@zhin.js/core';
|
|
2
|
+
import type { EndpointEventEmitter } from 'zhin.js/adapter';
|
|
3
|
+
import { formatCompact, type getAdapterLogger } from '@zhin.js/logger';
|
|
4
|
+
import type { OneBot12Event } from './protocol.js';
|
|
5
|
+
|
|
6
|
+
export interface OneBot12SideEventCaller {
|
|
7
|
+
callApi(action: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function receiveOneBot12SideEvent(
|
|
11
|
+
emit: EndpointEventEmitter,
|
|
12
|
+
endpointKey: string,
|
|
13
|
+
caller: OneBot12SideEventCaller,
|
|
14
|
+
raw: OneBot12Event,
|
|
15
|
+
logger: ReturnType<typeof getAdapterLogger>,
|
|
16
|
+
): void {
|
|
17
|
+
if (!emit) return;
|
|
18
|
+
const record = raw as Record<string, unknown>;
|
|
19
|
+
const eventType = String(record.type ?? record.post_type ?? '');
|
|
20
|
+
const detailType = String(record.detail_type ?? '');
|
|
21
|
+
const isRequest = eventType === 'request' || eventType.startsWith('request.');
|
|
22
|
+
const isFriend = detailType.includes('friend') || String(record.request_type ?? '') === 'friend';
|
|
23
|
+
void receiveOneBotLikeSideEvent(emit, {
|
|
24
|
+
adapter: 'onebot12',
|
|
25
|
+
endpointKey,
|
|
26
|
+
platform: 'onebot',
|
|
27
|
+
raw: record,
|
|
28
|
+
...(isRequest ? {
|
|
29
|
+
approve: async (flag, remark) => {
|
|
30
|
+
await (isFriend
|
|
31
|
+
? caller.callApi('set_friend_add_request', { flag, approve: true, remark })
|
|
32
|
+
: caller.callApi('set_group_add_request', { flag, approve: true, reason: remark }));
|
|
33
|
+
},
|
|
34
|
+
reject: async (flag, reason) => {
|
|
35
|
+
await (isFriend
|
|
36
|
+
? caller.callApi('set_friend_add_request', { flag, approve: false })
|
|
37
|
+
: caller.callApi('set_group_add_request', { flag, approve: false, reason }));
|
|
38
|
+
},
|
|
39
|
+
} : {}),
|
|
40
|
+
}).catch((err) => {
|
|
41
|
+
logger.warn(formatCompact({
|
|
42
|
+
op: 'onebot12_side_event_failed',
|
|
43
|
+
endpoint: endpointKey,
|
|
44
|
+
type: eventType || undefined,
|
|
45
|
+
error: err instanceof Error ? err.message : String(err),
|
|
46
|
+
}));
|
|
47
|
+
});
|
|
48
|
+
}
|
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OneBot12 HTTP webhook endpoint — POST inbound + api_url outbound.
|
|
3
|
+
*/
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
|
+
import {
|
|
6
|
+
ClientEndpoint,
|
|
7
|
+
createRecallEndpointControl,
|
|
8
|
+
type EndpointControl,
|
|
9
|
+
type EndpointManagement,
|
|
10
|
+
type EndpointSendRequest,
|
|
11
|
+
} from 'zhin.js/adapter';
|
|
12
|
+
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
13
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
14
|
+
import type { CapabilityId } from 'zhin.js';
|
|
15
|
+
import { createOneBot12EndpointManagement } from './endpoint-management.js';
|
|
16
|
+
import {
|
|
17
|
+
buildSendMessageParams,
|
|
18
|
+
callOneBot12Action,
|
|
19
|
+
formatInboundContent,
|
|
20
|
+
formatOutboundSegments,
|
|
21
|
+
isBotMentioned,
|
|
22
|
+
isMessageEvent,
|
|
23
|
+
onebot12InboundConversation,
|
|
24
|
+
senderNickname,
|
|
25
|
+
senderUserId,
|
|
26
|
+
uploadOneBot12MediaSegments,
|
|
27
|
+
type OneBot12Event,
|
|
28
|
+
type OneBot12WebhookConfig,
|
|
29
|
+
} from './protocol.js';
|
|
30
|
+
import { receiveOneBot12SideEvent } from './side-event-dispatch.js';
|
|
31
|
+
import { createOneBot12ContentPort } from './content-port.js';
|
|
32
|
+
import { callOnebot12Client, createOnebot12EndpointClient, forwardOnebot12ClientEvents, type Onebot12Client } from './client.js';
|
|
33
|
+
import { verifyOneBotAccessToken } from './wss-auth.js';
|
|
34
|
+
|
|
35
|
+
const logger = getLogger('onebot12');
|
|
36
|
+
|
|
37
|
+
export interface OneBot12WebhookEndpointOptions {
|
|
38
|
+
readonly id: CapabilityId;
|
|
39
|
+
readonly http: HttpHost;
|
|
40
|
+
readonly config: OneBot12WebhookConfig;
|
|
41
|
+
readonly callAction?: typeof callOneBot12Action;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class OneBot12WebhookEndpoint extends ClientEndpoint<Onebot12Client> {
|
|
45
|
+
readonly client: Onebot12Client;
|
|
46
|
+
readonly #options: OneBot12WebhookEndpointOptions;
|
|
47
|
+
readonly management: EndpointManagement;
|
|
48
|
+
readonly control: EndpointControl = createRecallEndpointControl((id) => this.recallMessage(id));
|
|
49
|
+
readonly content;
|
|
50
|
+
readonly #callAction: typeof callOneBot12Action;
|
|
51
|
+
#routeReleases: HttpRouteRegistration[] = [];
|
|
52
|
+
#started = false;
|
|
53
|
+
|
|
54
|
+
constructor(options: OneBot12WebhookEndpointOptions) {
|
|
55
|
+
super();
|
|
56
|
+
this.#options = options;
|
|
57
|
+
this.#callAction = options.callAction ?? callOneBot12Action;
|
|
58
|
+
this.client = createOnebot12EndpointClient(options.config, (action, params) => this.#callApi(action, params));
|
|
59
|
+
const callApi = (action: string, params?: Record<string, unknown>) => callOnebot12Client(this.client, action, params);
|
|
60
|
+
this.management = createOneBot12EndpointManagement({ callApi });
|
|
61
|
+
this.content = createOneBot12ContentPort(callApi);
|
|
62
|
+
this.bindClientEvents(
|
|
63
|
+
(receive) => forwardOnebot12ClientEvents(this.client, receive),
|
|
64
|
+
(name, payload) => {
|
|
65
|
+
if (name === 'event') this.#admitRaw(payload as OneBot12Event);
|
|
66
|
+
},
|
|
67
|
+
(_name, error) => this.#warnPlatformEvent(error),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async start(): Promise<void> {
|
|
72
|
+
if (this.#started) return;
|
|
73
|
+
this.#started = true;
|
|
74
|
+
if (!this.#options.config.access_token) {
|
|
75
|
+
// webhook 模式未配 access_token 时任何 POST 都会被放行(verifyOneBotAccessToken 直接 return true)
|
|
76
|
+
logger.warn(formatCompact({
|
|
77
|
+
endpoint: this.#options.config.id,
|
|
78
|
+
mode: 'webhook',
|
|
79
|
+
ok: false,
|
|
80
|
+
error: 'missing access_token',
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
this.#setupRoutes();
|
|
84
|
+
logger.info(formatCompact({
|
|
85
|
+
op: 'listen',
|
|
86
|
+
endpoint: this.#options.config.id,
|
|
87
|
+
mode: 'webhook',
|
|
88
|
+
path: this.#options.config.path,
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async stop(): Promise<void> {
|
|
93
|
+
this.close();
|
|
94
|
+
for (const release of this.#routeReleases.splice(0)) release();
|
|
95
|
+
this.#started = false;
|
|
96
|
+
logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.id }));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
|
|
100
|
+
const materialized = await uploadOneBot12MediaSegments(
|
|
101
|
+
payload,
|
|
102
|
+
(action, params) => callOnebot12Client(this.client, action, params),
|
|
103
|
+
(error) => {
|
|
104
|
+
logger.warn(formatCompact({
|
|
105
|
+
op: 'onebot12_upload_failed',
|
|
106
|
+
endpoint: this.#options.config.id,
|
|
107
|
+
error: error instanceof Error ? error.message : String(error),
|
|
108
|
+
}));
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
const message = formatOutboundSegments(materialized);
|
|
112
|
+
const params = buildSendMessageParams(conversation, message);
|
|
113
|
+
const data = await callOnebot12Client<{ message_id?: string }>(this.client, 'send_message', params);
|
|
114
|
+
const messageId = data?.message_id ?? '';
|
|
115
|
+
logger.debug(formatCompact({
|
|
116
|
+
op: 'onebot12_send',
|
|
117
|
+
endpoint: this.#options.config.id,
|
|
118
|
+
kind: conversation.kind,
|
|
119
|
+
conversationId: conversation.id,
|
|
120
|
+
messageId,
|
|
121
|
+
}));
|
|
122
|
+
return messageId;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async recallMessage(messageId: string): Promise<void> {
|
|
126
|
+
if (!messageId) return;
|
|
127
|
+
await callOnebot12Client(this.client, 'delete_message', { message_id: messageId });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async #callApi(
|
|
131
|
+
action: string,
|
|
132
|
+
params: Record<string, unknown> = {},
|
|
133
|
+
): Promise<import('@imhelper/onebot-v12').OneBotV12Response> {
|
|
134
|
+
return this.#callAction(
|
|
135
|
+
{ url: this.#options.config.api_url, access_token: this.#options.config.access_token },
|
|
136
|
+
action,
|
|
137
|
+
params,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#admitRaw(ev: OneBot12Event): void {
|
|
142
|
+
if (!isMessageEvent(ev)) {
|
|
143
|
+
receiveOneBot12SideEvent(
|
|
144
|
+
(name, payload) => this.emit(name, payload),
|
|
145
|
+
this.#options.config.id,
|
|
146
|
+
{ callApi: (action, params) => callOnebot12Client(this.client, action, params) },
|
|
147
|
+
ev,
|
|
148
|
+
logger,
|
|
149
|
+
);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const conversation = onebot12InboundConversation(String(this.#options.id), ev);
|
|
153
|
+
const content = formatInboundContent(ev);
|
|
154
|
+
const nickname = senderNickname(ev);
|
|
155
|
+
const mentioned = isBotMentioned(ev);
|
|
156
|
+
void this.emit('message.receive', {
|
|
157
|
+
conversation,
|
|
158
|
+
message: { conversation, id: ev.message_id },
|
|
159
|
+
content,
|
|
160
|
+
sender: { id: senderUserId(ev), ...(nickname ? { name: nickname } : {}) },
|
|
161
|
+
endpointId: this.#options.config.id,
|
|
162
|
+
...(mentioned ? { mentioned: true } : {}),
|
|
163
|
+
metadata: Object.freeze({
|
|
164
|
+
detail_type: ev.detail_type,
|
|
165
|
+
user_id: ev.user_id,
|
|
166
|
+
group_id: ev.group_id,
|
|
167
|
+
channel_id: ev.channel_id,
|
|
168
|
+
guild_id: ev.guild_id,
|
|
169
|
+
time: ev.time,
|
|
170
|
+
...(nickname ? { nickname } : {}),
|
|
171
|
+
}),
|
|
172
|
+
}).catch((err) => {
|
|
173
|
+
logger.warn(formatCompact({
|
|
174
|
+
op: 'onebot12_gateway_receive_failed',
|
|
175
|
+
kind: conversation.kind,
|
|
176
|
+
conversationId: conversation.id,
|
|
177
|
+
error: err instanceof Error ? err.message : String(err),
|
|
178
|
+
}));
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#warnPlatformEvent(error: unknown): void {
|
|
183
|
+
logger.warn(formatCompact({
|
|
184
|
+
op: 'onebot12_platform_event_failed',
|
|
185
|
+
error: error instanceof Error ? error.message : String(error),
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#setupRoutes(): void {
|
|
190
|
+
const path = this.#options.config.path;
|
|
191
|
+
this.#routeReleases.push(
|
|
192
|
+
this.#options.http.route('POST', path, async (request, response) => {
|
|
193
|
+
await this.#handleWebhook(request, response);
|
|
194
|
+
}, { summary: 'OneBot12 webhook callback', tags: ['onebot12'] }),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async #handleWebhook(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
199
|
+
try {
|
|
200
|
+
if (!verifyOneBotAccessToken(this.#options.config.access_token, request)) {
|
|
201
|
+
response.writeHead(403, { 'Content-Type': 'application/json' });
|
|
202
|
+
response.end(JSON.stringify({ message: 'Unauthorized' }));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (!this.clientEventsOpen) {
|
|
206
|
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
207
|
+
response.end(JSON.stringify({ status: 'ok' }));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
await this.client.acceptHttp(request, response);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
logger.error('OneBot12 webhook error:', error);
|
|
213
|
+
if (!response.headersSent) {
|
|
214
|
+
response.writeHead(500, { 'Content-Type': 'application/json' });
|
|
215
|
+
response.end(JSON.stringify({ message: 'Internal Server Error' }));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OneBot12 WS client endpoint — outbound connect to OneBot implementation.
|
|
3
|
+
*/
|
|
4
|
+
import WebSocket from 'ws';
|
|
5
|
+
import {
|
|
6
|
+
ClientEndpoint,
|
|
7
|
+
createRecallEndpointControl,
|
|
8
|
+
createEndpointLifecycle,
|
|
9
|
+
type EndpointConnectHandle,
|
|
10
|
+
type EndpointControl,
|
|
11
|
+
type EndpointLifecycle,
|
|
12
|
+
type EndpointManagement,
|
|
13
|
+
type EndpointSendRequest,
|
|
14
|
+
} from 'zhin.js/adapter';
|
|
15
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
16
|
+
import type { CapabilityId } from 'zhin.js';
|
|
17
|
+
import { createOneBot12EndpointManagement } from './endpoint-management.js';
|
|
18
|
+
import {
|
|
19
|
+
buildSendMessageParams,
|
|
20
|
+
buildWsConnectOptions,
|
|
21
|
+
formatInboundContent,
|
|
22
|
+
formatOutboundSegments,
|
|
23
|
+
isBotMentioned,
|
|
24
|
+
isMessageEvent,
|
|
25
|
+
onebot12InboundConversation,
|
|
26
|
+
senderNickname,
|
|
27
|
+
senderUserId,
|
|
28
|
+
uploadOneBot12MediaSegments,
|
|
29
|
+
type OneBot12Event,
|
|
30
|
+
type OneBot12WsConfig,
|
|
31
|
+
} from './protocol.js';
|
|
32
|
+
import { receiveOneBot12SideEvent } from './side-event-dispatch.js';
|
|
33
|
+
import { createOneBot12ContentPort } from './content-port.js';
|
|
34
|
+
import { callOnebot12Client, createOnebot12EndpointClient, forwardOnebot12ClientEvents, type Onebot12Client } from './client.js';
|
|
35
|
+
import {
|
|
36
|
+
type OneBot12PendingAction,
|
|
37
|
+
type OneBot12WsCreateOptions,
|
|
38
|
+
type OneBot12WsSocket,
|
|
39
|
+
} from './ws-types.js';
|
|
40
|
+
import {
|
|
41
|
+
callOneBot12WsAction,
|
|
42
|
+
handleOneBot12WsMessage,
|
|
43
|
+
rejectAllPending,
|
|
44
|
+
} from './ws-transport.js';
|
|
45
|
+
|
|
46
|
+
export interface OneBot12WsEndpointOptions {
|
|
47
|
+
readonly id: CapabilityId;
|
|
48
|
+
readonly config: OneBot12WsConfig;
|
|
49
|
+
readonly createWebSocket?: (
|
|
50
|
+
url: string,
|
|
51
|
+
options: OneBot12WsCreateOptions,
|
|
52
|
+
) => OneBot12WsSocket;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class OneBot12WsEndpoint extends ClientEndpoint<Onebot12Client> {
|
|
56
|
+
readonly client: Onebot12Client;
|
|
57
|
+
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
58
|
+
|
|
59
|
+
readonly #options: OneBot12WsEndpointOptions;
|
|
60
|
+
readonly management: EndpointManagement;
|
|
61
|
+
readonly control: EndpointControl = createRecallEndpointControl((id) => this.recallMessage(id));
|
|
62
|
+
readonly content;
|
|
63
|
+
readonly #lifecycle: EndpointLifecycle;
|
|
64
|
+
#ws?: OneBot12WsSocket;
|
|
65
|
+
#requestId = { value: 0 };
|
|
66
|
+
#pending = new Map<string, OneBot12PendingAction>();
|
|
67
|
+
|
|
68
|
+
constructor(options: OneBot12WsEndpointOptions) {
|
|
69
|
+
super();
|
|
70
|
+
this.#logger = getAdapterLogger('onebot12', options.config.id);
|
|
71
|
+
this.#options = options;
|
|
72
|
+
this.client = createOnebot12EndpointClient(options.config, (action, params) => this.#callAction(action, params ?? {}));
|
|
73
|
+
const callApi = (action: string, params?: Record<string, unknown>) => callOnebot12Client(this.client, action, params);
|
|
74
|
+
this.management = createOneBot12EndpointManagement({ callApi });
|
|
75
|
+
this.content = createOneBot12ContentPort(callApi);
|
|
76
|
+
this.bindClientEvents(
|
|
77
|
+
(receive) => forwardOnebot12ClientEvents(this.client, receive),
|
|
78
|
+
(name, payload) => {
|
|
79
|
+
if (name === 'event') this.#admitRaw(payload as OneBot12Event);
|
|
80
|
+
},
|
|
81
|
+
(_name, error) => this.#warnPlatformEvent(error),
|
|
82
|
+
);
|
|
83
|
+
const { config } = options;
|
|
84
|
+
this.#lifecycle = createEndpointLifecycle({
|
|
85
|
+
name: config.id,
|
|
86
|
+
reconnect: {
|
|
87
|
+
initialIntervalMs: config.reconnect_interval,
|
|
88
|
+
// 固定间隔(multiplier 1、无抖动),对齐旧 reconnect_interval 语义
|
|
89
|
+
multiplier: 1,
|
|
90
|
+
maxIntervalMs: config.reconnect_interval,
|
|
91
|
+
jitterMs: 0,
|
|
92
|
+
},
|
|
93
|
+
heartbeat: { intervalMs: config.heartbeat_interval },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async start(): Promise<void> {
|
|
98
|
+
if (this.#lifecycle.started) return;
|
|
99
|
+
try {
|
|
100
|
+
await this.#lifecycle.start((handle) => this.#connect(handle));
|
|
101
|
+
} catch (err) {
|
|
102
|
+
// start 失败必须清理现场(状态复位由基座保证)
|
|
103
|
+
if (this.#ws) {
|
|
104
|
+
try {
|
|
105
|
+
this.#ws.close();
|
|
106
|
+
} catch {
|
|
107
|
+
/* ignore */
|
|
108
|
+
}
|
|
109
|
+
this.#ws = undefined;
|
|
110
|
+
}
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async stop(): Promise<void> {
|
|
116
|
+
this.close();
|
|
117
|
+
// 基座负责:清重连/心跳定时器、强关 ws、唤醒 stop-during-connect 竞态
|
|
118
|
+
await this.#lifecycle.stop();
|
|
119
|
+
rejectAllPending(this.#pending);
|
|
120
|
+
this.#ws = undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
|
|
124
|
+
const materialized = await uploadOneBot12MediaSegments(
|
|
125
|
+
payload,
|
|
126
|
+
(action, params) => callOnebot12Client(this.client, action, params),
|
|
127
|
+
(error) => {
|
|
128
|
+
this.#logger.warn(formatCompact({
|
|
129
|
+
op: 'onebot12_upload_failed',
|
|
130
|
+
endpoint: this.#options.config.id,
|
|
131
|
+
error: error instanceof Error ? error.message : String(error),
|
|
132
|
+
}));
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
const message = formatOutboundSegments(materialized);
|
|
136
|
+
const params = buildSendMessageParams(conversation, message);
|
|
137
|
+
const data = await callOnebot12Client<{ message_id?: string }>(this.client, 'send_message', params);
|
|
138
|
+
const messageId = data?.message_id ?? '';
|
|
139
|
+
this.#logger.debug(formatCompact({
|
|
140
|
+
op: 'onebot12_send',
|
|
141
|
+
endpoint: this.#options.config.id,
|
|
142
|
+
kind: conversation.kind,
|
|
143
|
+
conversationId: conversation.id,
|
|
144
|
+
messageId,
|
|
145
|
+
}));
|
|
146
|
+
return messageId;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async recallMessage(messageId: string): Promise<void> {
|
|
150
|
+
if (!messageId) return;
|
|
151
|
+
await callOnebot12Client(this.client, 'delete_message', { message_id: messageId });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#admitRaw(ev: OneBot12Event): void {
|
|
155
|
+
if (!isMessageEvent(ev)) {
|
|
156
|
+
receiveOneBot12SideEvent(
|
|
157
|
+
(name, payload) => this.emit(name, payload),
|
|
158
|
+
this.#options.config.id,
|
|
159
|
+
{ callApi: (action, params) => callOnebot12Client(this.client, action, params) },
|
|
160
|
+
ev,
|
|
161
|
+
this.#logger,
|
|
162
|
+
);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const conversation = onebot12InboundConversation(String(this.#options.id), ev);
|
|
166
|
+
const content = formatInboundContent(ev);
|
|
167
|
+
const nickname = senderNickname(ev);
|
|
168
|
+
const mentioned = isBotMentioned(ev);
|
|
169
|
+
void this.emit('message.receive', {
|
|
170
|
+
conversation,
|
|
171
|
+
message: { conversation, id: ev.message_id },
|
|
172
|
+
content,
|
|
173
|
+
sender: { id: senderUserId(ev), name: senderNickname(ev) },
|
|
174
|
+
endpointId: this.#options.config.id,
|
|
175
|
+
...(mentioned ? { mentioned: true } : {}),
|
|
176
|
+
metadata: Object.freeze({
|
|
177
|
+
detail_type: ev.detail_type,
|
|
178
|
+
user_id: ev.user_id,
|
|
179
|
+
group_id: ev.group_id,
|
|
180
|
+
channel_id: ev.channel_id,
|
|
181
|
+
guild_id: ev.guild_id,
|
|
182
|
+
time: ev.time,
|
|
183
|
+
...(nickname ? { nickname } : {}),
|
|
184
|
+
}),
|
|
185
|
+
}).catch((err) => {
|
|
186
|
+
this.#logger.warn(formatCompact({
|
|
187
|
+
op: 'onebot12_gateway_receive_failed',
|
|
188
|
+
kind: conversation.kind,
|
|
189
|
+
conversationId: conversation.id,
|
|
190
|
+
error: err instanceof Error ? err.message : String(err),
|
|
191
|
+
}));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
#warnPlatformEvent(error: unknown): void {
|
|
196
|
+
this.#logger.warn(formatCompact({
|
|
197
|
+
op: 'onebot12_platform_event_failed',
|
|
198
|
+
error: error instanceof Error ? error.message : String(error),
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async #connect(handle: EndpointConnectHandle): Promise<void> {
|
|
203
|
+
const { url, headers, safeUrl } = buildWsConnectOptions(this.#options.config);
|
|
204
|
+
const create = this.#options.createWebSocket
|
|
205
|
+
?? ((connectUrl: string, options: OneBot12WsCreateOptions) =>
|
|
206
|
+
new WebSocket(connectUrl, { headers: options.headers }) as unknown as OneBot12WsSocket);
|
|
207
|
+
|
|
208
|
+
await new Promise<void>((resolve, reject) => {
|
|
209
|
+
let settled = false;
|
|
210
|
+
const ws = create(url, { headers });
|
|
211
|
+
this.#ws = ws;
|
|
212
|
+
handle.onForceClose(() => {
|
|
213
|
+
try {
|
|
214
|
+
ws.close();
|
|
215
|
+
} catch {
|
|
216
|
+
/* ignore */
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
ws.on('open', () => {
|
|
221
|
+
if (settled) return;
|
|
222
|
+
settled = true;
|
|
223
|
+
this.#logger.debug(formatCompact({
|
|
224
|
+
endpoint: this.#options.config.id,
|
|
225
|
+
mode: 'ws',
|
|
226
|
+
url: safeUrl,
|
|
227
|
+
}));
|
|
228
|
+
this.#lifecycle.startHeartbeat(() => {
|
|
229
|
+
this.#callAction('get_status', {}).catch(() => {});
|
|
230
|
+
});
|
|
231
|
+
resolve();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
ws.on('message', (data) => {
|
|
235
|
+
if (this.#ws !== ws) return;
|
|
236
|
+
this.#lifecycle.notifyHeartbeatAck();
|
|
237
|
+
handleOneBot12WsMessage(data, {
|
|
238
|
+
endpointId: this.#options.config.id,
|
|
239
|
+
pending: this.#pending,
|
|
240
|
+
ingest: (event) => this.client.ingest(
|
|
241
|
+
event as Parameters<Onebot12Client['ingest']>[0],
|
|
242
|
+
),
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
ws.on('close', (code, reason) => {
|
|
247
|
+
const reasonStr = typeof reason === 'string'
|
|
248
|
+
? reason
|
|
249
|
+
: Buffer.isBuffer(reason)
|
|
250
|
+
? reason.toString()
|
|
251
|
+
: String(reason ?? '');
|
|
252
|
+
const codeNum = typeof code === 'number' ? code : Number(code ?? 0);
|
|
253
|
+
if (!settled) {
|
|
254
|
+
settled = true;
|
|
255
|
+
reject(new Error(`OneBot12 WS 关闭: ${codeNum} ${reasonStr}`));
|
|
256
|
+
}
|
|
257
|
+
if (this.#ws === ws) {
|
|
258
|
+
this.#ws = undefined;
|
|
259
|
+
rejectAllPending(this.#pending);
|
|
260
|
+
}
|
|
261
|
+
// 断开日志与重连武装均由基座负责;仅曾 open 的连接才会武装重连,
|
|
262
|
+
// 初始连接失败由 start() 的拒绝路径复位,不产生僵尸重连。
|
|
263
|
+
handle.notifyClosed(`OneBot12 WS 关闭: ${codeNum} ${reasonStr || 'closed'}`);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
ws.on('error', (err) => {
|
|
267
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
268
|
+
this.#logger.warn(formatCompact({
|
|
269
|
+
op: 'ws_error',
|
|
270
|
+
endpoint: this.#options.config.id,
|
|
271
|
+
ok: false,
|
|
272
|
+
error: error.message,
|
|
273
|
+
}));
|
|
274
|
+
if (!settled) {
|
|
275
|
+
settled = true;
|
|
276
|
+
reject(error);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#callAction(action: string, params: Record<string, unknown>) {
|
|
283
|
+
return callOneBot12WsAction(this.#ws, this.#pending, this.#requestId, action, params);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { clearTimeout, setTimeout } from 'node:timers';
|
|
2
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
3
|
+
import type {
|
|
4
|
+
OneBot12ActionRequest,
|
|
5
|
+
OneBot12ActionResponse,
|
|
6
|
+
OneBot12Event,
|
|
7
|
+
} from './protocol.js';
|
|
8
|
+
import {
|
|
9
|
+
type OneBot12PendingAction,
|
|
10
|
+
type OneBot12WsSocket,
|
|
11
|
+
WS_OPEN,
|
|
12
|
+
} from './ws-types.js';
|
|
13
|
+
|
|
14
|
+
const logger = getLogger('onebot12');
|
|
15
|
+
|
|
16
|
+
export interface OneBot12WsMessageOptions {
|
|
17
|
+
readonly endpointId: string;
|
|
18
|
+
readonly pending: Map<string, OneBot12PendingAction>;
|
|
19
|
+
readonly ingest: (event: OneBot12Event) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function handleOneBot12WsMessage(
|
|
23
|
+
data: unknown,
|
|
24
|
+
options: OneBot12WsMessageOptions,
|
|
25
|
+
): void {
|
|
26
|
+
try {
|
|
27
|
+
const message = JSON.parse(decodeOneBot12WsPayload(data)) as
|
|
28
|
+
| OneBot12Event
|
|
29
|
+
| OneBot12ActionResponse;
|
|
30
|
+
if ('echo' in message && typeof message.echo === 'string') {
|
|
31
|
+
const response = message as OneBot12ActionResponse;
|
|
32
|
+
const pending = options.pending.get(response.echo!);
|
|
33
|
+
if (pending) {
|
|
34
|
+
options.pending.delete(response.echo!);
|
|
35
|
+
clearTimeout(pending.timeout);
|
|
36
|
+
pending.resolve(response);
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
options.ingest(message as OneBot12Event);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
logger.warn(formatCompact({
|
|
43
|
+
op: 'onebot12_parse_failed',
|
|
44
|
+
endpoint: options.endpointId,
|
|
45
|
+
error: error instanceof Error ? error.message : String(error),
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function callOneBot12WsAction(
|
|
51
|
+
ws: OneBot12WsSocket | undefined,
|
|
52
|
+
pending: Map<string, OneBot12PendingAction>,
|
|
53
|
+
requestId: { value: number },
|
|
54
|
+
action: string,
|
|
55
|
+
params: Record<string, unknown>,
|
|
56
|
+
): Promise<OneBot12ActionResponse> {
|
|
57
|
+
if (!ws || ws.readyState !== WS_OPEN) {
|
|
58
|
+
return Promise.reject(new Error('WebSocket 未连接'));
|
|
59
|
+
}
|
|
60
|
+
const echo = `ob12_${++requestId.value}`;
|
|
61
|
+
const request: OneBot12ActionRequest = { action, params, echo };
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const timeout = setTimeout(() => {
|
|
64
|
+
pending.delete(echo);
|
|
65
|
+
reject(new Error(`OneBot12 动作超时: ${action}`));
|
|
66
|
+
}, 30_000);
|
|
67
|
+
pending.set(echo, { resolve, reject, timeout });
|
|
68
|
+
ws.send(JSON.stringify(request));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function rejectAllPending(
|
|
73
|
+
pending: Map<string, OneBot12PendingAction>,
|
|
74
|
+
message = '连接已关闭',
|
|
75
|
+
): void {
|
|
76
|
+
for (const [, entry] of pending) {
|
|
77
|
+
clearTimeout(entry.timeout);
|
|
78
|
+
entry.reject(new Error(message));
|
|
79
|
+
}
|
|
80
|
+
pending.clear();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function decodeOneBot12WsPayload(data: unknown): string {
|
|
84
|
+
if (typeof data === 'string') return data;
|
|
85
|
+
if (Buffer.isBuffer(data)) return data.toString();
|
|
86
|
+
if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
|
|
87
|
+
return String(data ?? '');
|
|
88
|
+
}
|
package/src/ws-types.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { OneBot12ActionResponse } from './protocol.js';
|
|
2
|
+
|
|
3
|
+
/** Minimal WS surface used by the endpoint (real `ws` or test mock). */
|
|
4
|
+
export interface OneBot12WsSocket {
|
|
5
|
+
readonly readyState: number;
|
|
6
|
+
send(data: string): void;
|
|
7
|
+
close(code?: number, reason?: string): void;
|
|
8
|
+
on(event: 'open' | 'message' | 'close' | 'error', listener: (...args: unknown[]) => void): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface OneBot12WsCreateOptions {
|
|
12
|
+
readonly headers?: Record<string, string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const WS_OPEN = 1;
|
|
16
|
+
|
|
17
|
+
export interface OneBot12PendingAction {
|
|
18
|
+
resolve: (value: OneBot12ActionResponse) => void;
|
|
19
|
+
reject: (err: Error) => void;
|
|
20
|
+
timeout: NodeJS.Timeout;
|
|
21
|
+
}
|
package/src/wss-auth.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
|
|
3
|
+
export function verifyOneBotAccessToken(
|
|
4
|
+
accessToken: string | undefined,
|
|
5
|
+
request: IncomingMessage,
|
|
6
|
+
): boolean {
|
|
7
|
+
if (!accessToken) return true;
|
|
8
|
+
const auth = request.headers.authorization ?? '';
|
|
9
|
+
if (auth === `Bearer ${accessToken}`) return true;
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
12
|
+
if (url.searchParams.get('access_token') === accessToken) return true;
|
|
13
|
+
} catch {
|
|
14
|
+
/* ignore */
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|