@dsbot-channel/onebot 0.0.0-stage → 0.7.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.
@@ -0,0 +1,318 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import WebSocket from 'ws';
3
+ /** Kept always on: a half-open socket is the failure that leaves no line in a log. */
4
+ export const ONEBOT_PING_INTERVAL_MS = 30_000;
5
+ export const ONEBOT_PONG_TIMEOUT_MS = 10_000;
6
+ const REAL_CLOCK = {
7
+ setTimer: (callback, ms) => setTimeout(callback, ms),
8
+ clearTimer: (handle) => clearTimeout(handle),
9
+ };
10
+ /**
11
+ * The forward half of OneBot over WebSocket: this side dials out, sends actions with an
12
+ * `echo`, and reads events off the same socket or a second one.
13
+ */
14
+ export class ForwardLink {
15
+ #options;
16
+ #pending = new Map();
17
+ #api;
18
+ #events;
19
+ #apiOpen = false;
20
+ #eventsOpen = false;
21
+ #closed = false;
22
+ #retry;
23
+ #eventRetry;
24
+ #beat;
25
+ #attempt = 0;
26
+ #reconnects = 0;
27
+ #state = 'offline';
28
+ #lastFailure = null;
29
+ #lastFailureAt = null;
30
+ #lastConnectedAt = null;
31
+ #lastFrameAt = 0;
32
+ #warnedSize = false;
33
+ constructor(options) {
34
+ this.#options = options;
35
+ }
36
+ start() {
37
+ this.#closed = false;
38
+ this.#dial();
39
+ if (this.#options.heartbeat !== undefined && this.#options.heartbeat.intervalMs > 0) {
40
+ this.#startBeat();
41
+ }
42
+ }
43
+ close() {
44
+ this.#closed = true;
45
+ if (this.#retry !== undefined)
46
+ clearTimeout(this.#retry);
47
+ if (this.#eventRetry !== undefined)
48
+ clearTimeout(this.#eventRetry);
49
+ if (this.#beat !== undefined)
50
+ clearInterval(this.#beat);
51
+ this.#retry = undefined;
52
+ this.#eventRetry = undefined;
53
+ this.#beat = undefined;
54
+ for (const [, pending] of this.#pending) {
55
+ clearTimeout(pending.timer);
56
+ pending.resolve({ ok: false, code: 'unreachable', reason: 'the link closed while waiting for an answer' });
57
+ }
58
+ this.#pending.clear();
59
+ this.#api?.close();
60
+ this.#events?.close();
61
+ this.#api = undefined;
62
+ this.#events = undefined;
63
+ this.#apiOpen = false;
64
+ this.#eventsOpen = false;
65
+ this.#state = 'offline';
66
+ }
67
+ health() {
68
+ return {
69
+ kind: 'connection',
70
+ state: this.#state,
71
+ lastFailure: this.#lastFailure,
72
+ lastFailureAt: this.#lastFailureAt,
73
+ reconnectCount: this.#reconnects,
74
+ lastConnectedAt: this.#lastConnectedAt,
75
+ queueDepth: this.#pending.size,
76
+ };
77
+ }
78
+ /** Sends one action and waits for the frame that carries its echo. */
79
+ request(action, params, timeoutMs) {
80
+ const socket = this.#api;
81
+ if (socket === undefined || socket.readyState !== WebSocket.OPEN) {
82
+ return Promise.resolve({ ok: false, code: 'unreachable', reason: 'the forward socket is not open' });
83
+ }
84
+ const echo = randomUUID();
85
+ return new Promise((resolve) => {
86
+ const timer = setTimeout(() => {
87
+ this.#pending.delete(echo);
88
+ resolve({ ok: false, code: 'timeout', reason: 'the api did not answer in time' });
89
+ }, timeoutMs ?? this.#options.timeoutMs);
90
+ this.#pending.set(echo, { resolve, timer });
91
+ socket.send(JSON.stringify({ action, params, echo }), (error) => {
92
+ if (error === null || error === undefined)
93
+ return;
94
+ const pending = this.#pending.get(echo);
95
+ if (pending === undefined)
96
+ return;
97
+ clearTimeout(pending.timer);
98
+ this.#pending.delete(echo);
99
+ pending.resolve({ ok: false, code: 'unreachable', reason: `the frame could not be sent: ${String(error)}` });
100
+ });
101
+ });
102
+ }
103
+ #dial() {
104
+ const socket = new WebSocket(this.#options.apiUrl, {
105
+ ...(this.#options.token === undefined ? {} : { headers: { authorization: `Bearer ${this.#options.token}` } }),
106
+ });
107
+ this.#api = socket;
108
+ socket.on('open', () => {
109
+ this.#attempt = 0;
110
+ if (this.#lastConnectedAt !== null)
111
+ this.#reconnects += 1;
112
+ this.#lastConnectedAt = this.#now();
113
+ this.#lastFrameAt = this.#now();
114
+ this.#apiOpen = true;
115
+ if (this.#options.eventUrl !== undefined)
116
+ this.#openEvents();
117
+ this.#settle();
118
+ });
119
+ socket.on('message', (raw) => this.#onFrame(socket, raw));
120
+ this.#watch(socket);
121
+ socket.on('close', (code) => this.#dropped(`the forward socket closed with ${code}`));
122
+ socket.on('error', (error) => {
123
+ this.#lastFailure = error.message;
124
+ this.#lastFailureAt = this.#now();
125
+ });
126
+ }
127
+ /** Opens the event socket only while the api socket is up; it has no life of its own. */
128
+ #openEvents() {
129
+ const url = this.#options.eventUrl;
130
+ if (url === undefined || this.#events !== undefined || this.#eventRetry !== undefined)
131
+ return;
132
+ if (this.#api === undefined || this.#api.readyState !== WebSocket.OPEN)
133
+ return;
134
+ const socket = new WebSocket(url, {
135
+ ...(this.#options.token === undefined ? {} : { headers: { authorization: `Bearer ${this.#options.token}` } }),
136
+ });
137
+ this.#events = socket;
138
+ socket.on('open', () => {
139
+ this.#eventsOpen = true;
140
+ this.#settle();
141
+ });
142
+ this.#watch(socket);
143
+ socket.on('message', (raw) => this.#onFrame(socket, raw));
144
+ socket.on('close', () => {
145
+ this.#events = undefined;
146
+ this.#eventsOpen = false;
147
+ if (!this.#closed && this.#api !== undefined) {
148
+ this.#eventRetry = setTimeout(() => {
149
+ this.#eventRetry = undefined;
150
+ this.#openEvents();
151
+ }, 1000);
152
+ }
153
+ this.#settle();
154
+ });
155
+ socket.on('error', (error) => {
156
+ this.#lastFailure = error.message;
157
+ this.#lastFailureAt = this.#now();
158
+ });
159
+ }
160
+ #clock() {
161
+ return this.#options.clock ?? REAL_CLOCK;
162
+ }
163
+ /** Pings this socket on a fixed beat and closes it when a pong does not come back. */
164
+ #watch(socket) {
165
+ const liveness = this.#options.liveness ?? {
166
+ intervalMs: ONEBOT_PING_INTERVAL_MS,
167
+ timeoutMs: ONEBOT_PONG_TIMEOUT_MS,
168
+ };
169
+ const clock = this.#clock();
170
+ let waiting;
171
+ let tick;
172
+ const schedule = () => {
173
+ tick = clock.setTimer(() => {
174
+ tick = undefined;
175
+ if (socket.readyState !== WebSocket.OPEN)
176
+ return;
177
+ if (waiting === undefined) {
178
+ socket.ping();
179
+ waiting = clock.setTimer(() => {
180
+ waiting = undefined;
181
+ this.#options.warn('dsbot-channel: closed the onebot socket: its ping went unanswered');
182
+ socket.close(1001, 'ping timeout');
183
+ }, liveness.timeoutMs);
184
+ }
185
+ schedule();
186
+ }, liveness.intervalMs);
187
+ };
188
+ schedule();
189
+ socket.on('pong', () => {
190
+ if (waiting !== undefined)
191
+ clock.clearTimer(waiting);
192
+ waiting = undefined;
193
+ });
194
+ socket.on('close', () => {
195
+ if (tick !== undefined)
196
+ clock.clearTimer(tick);
197
+ tick = undefined;
198
+ if (waiting !== undefined)
199
+ clock.clearTimer(waiting);
200
+ waiting = undefined;
201
+ });
202
+ }
203
+ /** Online means every socket this profile needs is open; anything else says why it is not. */
204
+ #settle() {
205
+ if (this.#closed) {
206
+ this.#state = 'offline';
207
+ return;
208
+ }
209
+ const needsEvents = this.#options.eventUrl !== undefined;
210
+ if (this.#apiOpen && (!needsEvents || this.#eventsOpen)) {
211
+ this.#state = 'online';
212
+ return;
213
+ }
214
+ this.#state = this.#retry !== undefined || this.#eventRetry !== undefined ? 'reconnecting' : 'offline';
215
+ }
216
+ #onFrame(socket, raw) {
217
+ // A replaced or closed socket can still deliver a straggler; it is not ours any more.
218
+ if (socket !== this.#api && socket !== this.#events)
219
+ return;
220
+ const bytes = Array.isArray(raw)
221
+ ? Buffer.concat(raw)
222
+ : Buffer.isBuffer(raw)
223
+ ? raw
224
+ : Buffer.from(new Uint8Array(raw));
225
+ if (bytes.length > this.#options.maxFrameBytes) {
226
+ socket.close(1009, 'frame too large');
227
+ if (!this.#warnedSize) {
228
+ this.#warnedSize = true;
229
+ this.#options.warn('dsbot-channel: closed the onebot socket: a frame exceeded the size limit');
230
+ }
231
+ return;
232
+ }
233
+ let frame;
234
+ try {
235
+ frame = JSON.parse(bytes.toString());
236
+ }
237
+ catch {
238
+ this.#options.onMismatch();
239
+ return;
240
+ }
241
+ if (frame === null || typeof frame !== 'object' || Array.isArray(frame)) {
242
+ this.#options.onMismatch();
243
+ return;
244
+ }
245
+ const record = frame;
246
+ if (typeof record.echo === 'string') {
247
+ const pending = this.#pending.get(record.echo);
248
+ if (pending === undefined) {
249
+ this.#options.onMismatch();
250
+ return;
251
+ }
252
+ clearTimeout(pending.timer);
253
+ this.#pending.delete(record.echo);
254
+ pending.resolve(this.#answer(record));
255
+ return;
256
+ }
257
+ if (record.post_type !== undefined) {
258
+ this.#lastFrameAt = this.#now();
259
+ this.#options.onEvent(record);
260
+ return;
261
+ }
262
+ this.#options.onMismatch();
263
+ }
264
+ #answer(frame) {
265
+ if (frame.status !== 'ok') {
266
+ const message = typeof frame.message === 'string' && frame.message.length > 0 ? frame.message : 'the api answered with a failed status';
267
+ return { ok: false, code: 'rejected', reason: message };
268
+ }
269
+ return { ok: true, frame };
270
+ }
271
+ #dropped(reason) {
272
+ this.#lastFailure = reason;
273
+ this.#lastFailureAt = this.#now();
274
+ this.#apiOpen = false;
275
+ this.#eventsOpen = false;
276
+ for (const [, pending] of this.#pending) {
277
+ clearTimeout(pending.timer);
278
+ pending.resolve({ ok: false, code: 'unreachable', reason });
279
+ }
280
+ this.#pending.clear();
281
+ this.#api = undefined;
282
+ this.#events?.close();
283
+ this.#events = undefined;
284
+ if (this.#eventRetry !== undefined)
285
+ clearTimeout(this.#eventRetry);
286
+ this.#eventRetry = undefined;
287
+ const retry = this.#options.reconnect;
288
+ if (this.#closed || retry === false) {
289
+ this.#state = 'offline';
290
+ return;
291
+ }
292
+ const base = Math.min(retry.initialMs * retry.factor ** this.#attempt, retry.maxMs);
293
+ const jittered = Math.round(base * (0.8 + Math.random() * 0.4));
294
+ this.#attempt += 1;
295
+ this.#state = 'reconnecting';
296
+ this.#retry = setTimeout(() => {
297
+ this.#retry = undefined;
298
+ if (!this.#closed)
299
+ this.#dial();
300
+ }, jittered);
301
+ }
302
+ #startBeat() {
303
+ const beat = this.#options.heartbeat;
304
+ if (beat === undefined)
305
+ return;
306
+ this.#beat = setInterval(() => {
307
+ if (this.#state !== 'online')
308
+ return;
309
+ if (this.#now() - this.#lastFrameAt > beat.timeoutMs) {
310
+ this.#options.warn('dsbot-channel: closed the onebot socket: its heartbeat went silent');
311
+ this.#api?.close(1001, 'heartbeat timeout');
312
+ }
313
+ }, beat.intervalMs);
314
+ }
315
+ #now() {
316
+ return this.#options.now?.() ?? Date.now();
317
+ }
318
+ }
@@ -0,0 +1,39 @@
1
+ import type { Server } from 'node:http';
2
+ import WebSocket from 'ws';
3
+ import type { ActionAnswer, LinkClock, LivenessOptions } from './ws-forward.js';
4
+ export interface ReverseOptions {
5
+ path: string;
6
+ token?: string | undefined;
7
+ selfId?: string | undefined;
8
+ maxFrameBytes: number;
9
+ /** Protocol-level ping/pong; always on in production, shortened in tests. */
10
+ liveness?: LivenessOptions | undefined;
11
+ /** Timer source for liveness; real timers unless a test hands in its own. */
12
+ clock?: LinkClock | undefined;
13
+ /** How long an action waits for its answer; the profile's `sendTimeoutMs`. */
14
+ timeoutMs: number;
15
+ heartbeat?: {
16
+ intervalMs: number;
17
+ timeoutMs: number;
18
+ } | undefined;
19
+ onEvent: (frame: Record<string, unknown>) => void;
20
+ onMismatch: () => void;
21
+ onAuthFailure: () => void;
22
+ onUnknownPath: () => void;
23
+ warn: (message: string) => void;
24
+ now?: (() => number) | undefined;
25
+ }
26
+ /**
27
+ * The reverse half of OneBot over WebSocket: an implementation dials us, we keep one
28
+ * connection per adapter, and actions travel back down it with an `echo`.
29
+ */
30
+ export declare class ReverseLink {
31
+ #private;
32
+ constructor(options: ReverseOptions);
33
+ /** Takes over the server's upgrade events; anything not on our path is left to the caller. */
34
+ attach(server: Server): void;
35
+ close(): void;
36
+ connection(): WebSocket | undefined;
37
+ lastAuthenticatedAt(): number | null;
38
+ request(action: string, params: Record<string, unknown>, timeoutMs: number | undefined): Promise<ActionAnswer>;
39
+ }
@@ -0,0 +1,265 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import WebSocket, { WebSocketServer } from 'ws';
3
+ import { ONEBOT_PING_INTERVAL_MS, ONEBOT_PONG_TIMEOUT_MS } from './ws-forward.js';
4
+ /**
5
+ * The reverse half of OneBot over WebSocket: an implementation dials us, we keep one
6
+ * connection per adapter, and actions travel back down it with an `echo`.
7
+ */
8
+ export class ReverseLink {
9
+ #options;
10
+ #pending = new Map();
11
+ #server;
12
+ #socket;
13
+ #selfId;
14
+ #lastAuthenticatedAt = null;
15
+ #beat;
16
+ #lastFrameAt = 0;
17
+ #warnedSize = false;
18
+ #warnedNoId = false;
19
+ constructor(options) {
20
+ this.#options = options;
21
+ this.#server = new WebSocketServer({ noServer: true });
22
+ }
23
+ /** Takes over the server's upgrade events; anything not on our path is left to the caller. */
24
+ attach(server) {
25
+ server.on('upgrade', (request, socket, head) => {
26
+ void this.#upgrade(request, socket, head);
27
+ });
28
+ if (this.#options.heartbeat !== undefined && this.#options.heartbeat.intervalMs > 0) {
29
+ this.#startBeat();
30
+ }
31
+ }
32
+ close() {
33
+ if (this.#beat !== undefined)
34
+ clearInterval(this.#beat);
35
+ this.#beat = undefined;
36
+ for (const [, pending] of this.#pending) {
37
+ clearTimeout(pending.timer);
38
+ pending.resolve({ ok: false, code: 'unreachable', reason: 'the link closed while waiting for an answer' });
39
+ }
40
+ this.#pending.clear();
41
+ this.#socket?.close();
42
+ this.#socket = undefined;
43
+ this.#server.close();
44
+ }
45
+ connection() {
46
+ return this.#socket;
47
+ }
48
+ lastAuthenticatedAt() {
49
+ return this.#lastAuthenticatedAt;
50
+ }
51
+ request(action, params, timeoutMs) {
52
+ const socket = this.#socket;
53
+ if (socket === undefined || socket.readyState !== WebSocket.OPEN) {
54
+ return Promise.resolve({ ok: false, code: 'unreachable', reason: 'no reverse socket is connected' });
55
+ }
56
+ const echo = randomUUID();
57
+ return new Promise((resolve) => {
58
+ const timer = setTimeout(() => {
59
+ this.#pending.delete(echo);
60
+ resolve({ ok: false, code: 'timeout', reason: 'the api did not answer in time' });
61
+ }, timeoutMs ?? this.#options.timeoutMs);
62
+ this.#pending.set(echo, { resolve, timer });
63
+ socket.send(JSON.stringify({ action, params, echo }), (error) => {
64
+ if (error === null || error === undefined)
65
+ return;
66
+ const pending = this.#pending.get(echo);
67
+ if (pending === undefined)
68
+ return;
69
+ clearTimeout(pending.timer);
70
+ this.#pending.delete(echo);
71
+ pending.resolve({ ok: false, code: 'unreachable', reason: `the frame could not be sent: ${String(error)}` });
72
+ });
73
+ });
74
+ }
75
+ async #upgrade(request, socket, head) {
76
+ const url = request.url ?? '/';
77
+ if (url !== this.#options.path) {
78
+ this.#options.onUnknownPath();
79
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
80
+ socket.destroy();
81
+ return;
82
+ }
83
+ if (!this.#authorized(request)) {
84
+ this.#options.onAuthFailure();
85
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
86
+ socket.destroy();
87
+ return;
88
+ }
89
+ const claimed = headerOf(request, 'x-self-id');
90
+ if (this.#options.selfId !== undefined && claimed !== undefined && claimed !== this.#options.selfId) {
91
+ this.#options.onAuthFailure();
92
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
93
+ socket.destroy();
94
+ return;
95
+ }
96
+ if (this.#options.selfId === undefined && claimed === undefined && !this.#warnedNoId) {
97
+ this.#warnedNoId = true;
98
+ this.#options.warn('dsbot-channel: accepted an onebot reverse connection that names no X-Self-ID');
99
+ }
100
+ const previous = this.#socket;
101
+ if (previous !== undefined) {
102
+ const previousId = this.#selfId;
103
+ if (claimed !== undefined && previousId !== undefined && claimed !== previousId) {
104
+ this.#options.onAuthFailure();
105
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
106
+ socket.destroy();
107
+ return;
108
+ }
109
+ this.#options.warn('dsbot-channel: replaced the onebot reverse connection this adapter already held');
110
+ previous.close(1000, 'replaced by a new connection');
111
+ }
112
+ this.#server.handleUpgrade(request, socket, head, (upgraded) => {
113
+ this.#adopt(upgraded, claimed);
114
+ });
115
+ }
116
+ #adopt(socket, claimed) {
117
+ this.#socket = socket;
118
+ this.#lastAuthenticatedAt = this.#now();
119
+ this.#lastFrameAt = this.#now();
120
+ this.#selfId = claimed ?? this.#options.selfId;
121
+ this.#watch(socket);
122
+ socket.on('message', (raw) => this.#onFrame(socket, raw));
123
+ socket.on('close', () => {
124
+ if (this.#socket !== socket)
125
+ return;
126
+ this.#socket = undefined;
127
+ for (const [, pending] of this.#pending) {
128
+ clearTimeout(pending.timer);
129
+ pending.resolve({ ok: false, code: 'unreachable', reason: 'the reverse socket closed while waiting for an answer' });
130
+ }
131
+ this.#pending.clear();
132
+ });
133
+ socket.on('error', (error) => this.#options.warn(`dsbot-channel: onebot reverse socket: ${error.message}`));
134
+ }
135
+ #onFrame(socket, raw) {
136
+ // A replaced or closed socket can still deliver a straggler; it is not ours any more.
137
+ if (socket !== this.#socket)
138
+ return;
139
+ const bytes = Array.isArray(raw)
140
+ ? Buffer.concat(raw)
141
+ : Buffer.isBuffer(raw)
142
+ ? raw
143
+ : Buffer.from(new Uint8Array(raw));
144
+ if (bytes.length > this.#options.maxFrameBytes) {
145
+ this.#socket?.close(1009, 'frame too large');
146
+ if (!this.#warnedSize) {
147
+ this.#warnedSize = true;
148
+ this.#options.warn('dsbot-channel: closed the onebot socket: a frame exceeded the size limit');
149
+ }
150
+ return;
151
+ }
152
+ let frame;
153
+ try {
154
+ frame = JSON.parse(bytes.toString());
155
+ }
156
+ catch {
157
+ this.#options.onMismatch();
158
+ return;
159
+ }
160
+ if (frame === null || typeof frame !== 'object' || Array.isArray(frame)) {
161
+ this.#options.onMismatch();
162
+ return;
163
+ }
164
+ const record = frame;
165
+ if (typeof record.echo === 'string') {
166
+ const pending = this.#pending.get(record.echo);
167
+ if (pending === undefined) {
168
+ this.#options.onMismatch();
169
+ return;
170
+ }
171
+ clearTimeout(pending.timer);
172
+ this.#pending.delete(record.echo);
173
+ pending.resolve(answerOf(record));
174
+ return;
175
+ }
176
+ if (record.post_type !== undefined) {
177
+ this.#lastFrameAt = this.#now();
178
+ this.#options.onEvent(record);
179
+ return;
180
+ }
181
+ this.#options.onMismatch();
182
+ }
183
+ #authorized(request) {
184
+ if (this.#options.token === undefined)
185
+ return true;
186
+ const header = request.headers.authorization;
187
+ if (typeof header !== 'string')
188
+ return false;
189
+ const match = /^(?:Bearer|Token)\s+(.+)$/i.exec(header.trim());
190
+ return match !== null && match[1] === this.#options.token;
191
+ }
192
+ #clock() {
193
+ return (this.#options.clock ?? {
194
+ setTimer: (callback, ms) => setTimeout(callback, ms),
195
+ clearTimer: (handle) => clearTimeout(handle),
196
+ });
197
+ }
198
+ /** Pings the reverse socket on the same fixed beat as the forward link. */
199
+ #watch(socket) {
200
+ const liveness = this.#options.liveness ?? {
201
+ intervalMs: ONEBOT_PING_INTERVAL_MS,
202
+ timeoutMs: ONEBOT_PONG_TIMEOUT_MS,
203
+ };
204
+ const clock = this.#clock();
205
+ let waiting;
206
+ let tick;
207
+ const schedule = () => {
208
+ tick = clock.setTimer(() => {
209
+ tick = undefined;
210
+ if (socket.readyState !== WebSocket.OPEN)
211
+ return;
212
+ if (waiting === undefined) {
213
+ socket.ping();
214
+ waiting = clock.setTimer(() => {
215
+ waiting = undefined;
216
+ this.#options.warn('dsbot-channel: closed the onebot socket: its ping went unanswered');
217
+ socket.close(1001, 'ping timeout');
218
+ }, liveness.timeoutMs);
219
+ }
220
+ schedule();
221
+ }, liveness.intervalMs);
222
+ };
223
+ schedule();
224
+ socket.on('pong', () => {
225
+ if (waiting !== undefined)
226
+ clock.clearTimer(waiting);
227
+ waiting = undefined;
228
+ });
229
+ socket.on('close', () => {
230
+ if (tick !== undefined)
231
+ clock.clearTimer(tick);
232
+ tick = undefined;
233
+ if (waiting !== undefined)
234
+ clock.clearTimer(waiting);
235
+ waiting = undefined;
236
+ });
237
+ }
238
+ #startBeat() {
239
+ const beat = this.#options.heartbeat;
240
+ if (beat === undefined)
241
+ return;
242
+ this.#beat = setInterval(() => {
243
+ if (this.#socket === undefined)
244
+ return;
245
+ if (this.#now() - this.#lastFrameAt > beat.timeoutMs) {
246
+ this.#options.warn('dsbot-channel: closed the onebot socket: its heartbeat went silent');
247
+ this.#socket.close(1001, 'heartbeat timeout');
248
+ }
249
+ }, beat.intervalMs);
250
+ }
251
+ #now() {
252
+ return this.#options.now?.() ?? Date.now();
253
+ }
254
+ }
255
+ function headerOf(request, name) {
256
+ const value = request.headers[name];
257
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
258
+ }
259
+ function answerOf(frame) {
260
+ if (frame.status !== 'ok') {
261
+ const message = typeof frame.message === 'string' && frame.message.length > 0 ? frame.message : 'the api answered with a failed status';
262
+ return { ok: false, code: 'rejected', reason: message };
263
+ }
264
+ return { ok: true, frame };
265
+ }
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@dsbot-channel/onebot",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.7.0",
4
+ "description": "OneBot transport for dsbot-channel.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./*.js": {
15
+ "types": "./dist/*.d.ts",
16
+ "default": "./dist/*.js"
17
+ },
18
+ "./package.json": "./package.json",
19
+ "./cordis.patch.yml": "./cordis.patch.yml"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "assets",
24
+ "cordis.patch.yml",
25
+ "LICENSE"
26
+ ],
27
+ "license": "MIT",
28
+ "publishConfig": {
29
+ "registry": "https://registry.npmjs.org/",
30
+ "access": "public"
31
+ },
32
+ "engines": {
33
+ "node": "^20.19.0 || >=22.12.0"
34
+ },
35
+ "dependencies": {
36
+ "ws": "^8.18.0",
37
+ "@dsbot-channel/core": "^0.7.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "~22.20.5",
41
+ "typescript": "~5.9.3"
42
+ },
43
+ "dsh": {
44
+ "bundle": {
45
+ "patch": "./cordis.patch.yml"
46
+ }
47
+ },
48
+ "scripts": {
49
+ "build": "pnpm clean && tsc -p tsconfig.build.json",
50
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
51
+ "typecheck": "tsc -p tsconfig.build.json --noEmit",
52
+ "verify:dist": "node ../../scripts/verify-dist.mjs packages/onebot"
53
+ }
6
54
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.