@dsbot-channel/onebot 0.0.0-stage → 0.7.1
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/LICENSE +21 -0
- package/assets/skill.md +26 -0
- package/cordis.patch.yml +3 -0
- package/dist/adapter.d.ts +118 -0
- package/dist/adapter.js +1203 -0
- package/dist/index.d.ts +126 -0
- package/dist/index.js +227 -0
- package/dist/ws-forward.d.ts +60 -0
- package/dist/ws-forward.js +318 -0
- package/dist/ws-reverse.d.ts +39 -0
- package/dist/ws-reverse.js +265 -0
- package/package.json +51 -3
- package/README.md +0 -3
package/dist/adapter.js
ADDED
|
@@ -0,0 +1,1203 @@
|
|
|
1
|
+
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { buildListenerHealth, buildStatus } from '@dsbot-channel/core/adapter-status.js';
|
|
4
|
+
import { answerStatus } from '@dsbot-channel/core/adapters/shared/status-route.js';
|
|
5
|
+
import { ForwardLink } from './ws-forward.js';
|
|
6
|
+
import { ReverseLink } from './ws-reverse.js';
|
|
7
|
+
import { ChannelStats, countersOf } from '@dsbot-channel/core/channel-stats.js';
|
|
8
|
+
import { ConfigError } from '@dsbot-channel/core/config.js';
|
|
9
|
+
import { buildEvent, handleProblem } from '@dsbot-channel/core/event-build.js';
|
|
10
|
+
import { ADAPTER_META_MAX_BYTES } from '@dsbot-channel/core/adapter-meta.js';
|
|
11
|
+
export const ONEBOT_PEER_GUIDANCE = 'This is a private OneBot message. Answer it with your reply as usual. If it needs no answer, call no_reply instead of staying silent. If you refuse it, call decline with a short reason.';
|
|
12
|
+
export const ONEBOT_GROUP_GUIDANCE = 'This is a group message from OneBot. Your reply is posted back to that group. If it needs no answer, call no_reply; if you refuse it, call decline with a short reason.';
|
|
13
|
+
export const ONEBOT_STATUS_ROUTE = '/onebot/status';
|
|
14
|
+
export const ONEBOT_DEDUPE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
15
|
+
export const ONEBOT_DEDUPE_LIMIT = 100_000;
|
|
16
|
+
export const ONEBOT_CHAIN_TTL_MS = 60 * 60 * 1000;
|
|
17
|
+
export const ONEBOT_CHAIN_LIMIT = 10_000;
|
|
18
|
+
export const ONEBOT_MAX_BODY_BYTES = 1024 * 1024;
|
|
19
|
+
const MEDIA_FOR_SEGMENT = {
|
|
20
|
+
image: 'image',
|
|
21
|
+
record: 'voice',
|
|
22
|
+
video: 'video',
|
|
23
|
+
file: 'file',
|
|
24
|
+
};
|
|
25
|
+
function idOf(value) {
|
|
26
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
27
|
+
return String(value);
|
|
28
|
+
if (typeof value === 'string' && value.length > 0)
|
|
29
|
+
return value;
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
function segmentText(segments) {
|
|
33
|
+
if (!Array.isArray(segments))
|
|
34
|
+
return '';
|
|
35
|
+
return segments
|
|
36
|
+
.filter((segment) => segment !== null && typeof segment === 'object' && segment.type === 'text')
|
|
37
|
+
.map((segment) => String(segment.data?.text ?? ''))
|
|
38
|
+
.join('');
|
|
39
|
+
}
|
|
40
|
+
function mentionsOf(segments, selfId) {
|
|
41
|
+
const mentioned = { self: false, others: false, all: false };
|
|
42
|
+
if (!Array.isArray(segments))
|
|
43
|
+
return mentioned;
|
|
44
|
+
for (const segment of segments) {
|
|
45
|
+
if (segment === null || typeof segment !== 'object')
|
|
46
|
+
continue;
|
|
47
|
+
const one = segment;
|
|
48
|
+
if (one.type !== 'at')
|
|
49
|
+
continue;
|
|
50
|
+
const qq = idOf(one.data?.qq);
|
|
51
|
+
if (qq === undefined)
|
|
52
|
+
continue;
|
|
53
|
+
if (qq === 'all')
|
|
54
|
+
mentioned.all = true;
|
|
55
|
+
else if (selfId !== undefined && qq === selfId)
|
|
56
|
+
mentioned.self = true;
|
|
57
|
+
else
|
|
58
|
+
mentioned.others = true;
|
|
59
|
+
}
|
|
60
|
+
return mentioned;
|
|
61
|
+
}
|
|
62
|
+
function mediaOf(segments) {
|
|
63
|
+
const media = [];
|
|
64
|
+
let dropped = 0;
|
|
65
|
+
if (!Array.isArray(segments))
|
|
66
|
+
return { media, dropped };
|
|
67
|
+
for (const segment of segments) {
|
|
68
|
+
if (segment === null || typeof segment !== 'object')
|
|
69
|
+
continue;
|
|
70
|
+
const one = segment;
|
|
71
|
+
const type = typeof one.type === 'string' ? MEDIA_FOR_SEGMENT[one.type] : undefined;
|
|
72
|
+
if (type === undefined)
|
|
73
|
+
continue;
|
|
74
|
+
const url = typeof one.data?.url === 'string' && one.data.url.length > 0 ? one.data.url : null;
|
|
75
|
+
const handle = typeof one.data?.file === 'string' && one.data.file.length > 0 ? one.data.file : null;
|
|
76
|
+
if (url === null && handle === null) {
|
|
77
|
+
dropped += 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const name = type === 'file' ? (typeof one.data?.name === 'string' ? one.data.name : typeof one.data?.file_name === 'string' ? one.data.file_name : undefined) : undefined;
|
|
81
|
+
media.push({ type, url, handle, ...(name === undefined || name.length === 0 ? {} : { name }) });
|
|
82
|
+
}
|
|
83
|
+
return { media, dropped };
|
|
84
|
+
}
|
|
85
|
+
function replyIdOf(segments) {
|
|
86
|
+
if (!Array.isArray(segments))
|
|
87
|
+
return undefined;
|
|
88
|
+
for (const segment of segments) {
|
|
89
|
+
if (segment === null || typeof segment !== 'object')
|
|
90
|
+
continue;
|
|
91
|
+
const one = segment;
|
|
92
|
+
if (one.type !== 'reply')
|
|
93
|
+
continue;
|
|
94
|
+
const id = idOf(one.data?.id);
|
|
95
|
+
if (id !== undefined)
|
|
96
|
+
return id;
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
function mapOneBotMessage(event, config, chainOf, warn, now = Date.now) {
|
|
101
|
+
if (event.post_type !== 'message')
|
|
102
|
+
return undefined;
|
|
103
|
+
if (event.message_type !== 'group' && event.message_type !== 'private')
|
|
104
|
+
return undefined;
|
|
105
|
+
const messageId = idOf(event.message_id);
|
|
106
|
+
const eventSelf = idOf(event.self_id);
|
|
107
|
+
if (config.selfId !== undefined && eventSelf !== undefined && eventSelf !== config.selfId) {
|
|
108
|
+
warn?.(`dsbot-channel: dropped an onebot event for account ${eventSelf}: this adapter answers for ${config.selfId}`);
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
const selfId = config.selfId ?? eventSelf;
|
|
112
|
+
if (messageId === undefined || selfId === undefined)
|
|
113
|
+
return undefined;
|
|
114
|
+
const scope = event.message_type === 'group' ? 'group' : 'c2c';
|
|
115
|
+
const target = scope === 'group' ? idOf(event.group_id) : idOf(event.user_id);
|
|
116
|
+
const senderId = idOf(event.user_id);
|
|
117
|
+
if (target === undefined || senderId === undefined)
|
|
118
|
+
return undefined;
|
|
119
|
+
const replyTo = replyIdOf(event.message);
|
|
120
|
+
const ownKey = `${scope}:${target}:${messageId}`;
|
|
121
|
+
const chainId = replyTo === undefined ? ownKey : chainOf(replyTo) ?? ownKey;
|
|
122
|
+
const rawText = segmentText(event.message);
|
|
123
|
+
const { media, dropped } = mediaOf(event.message);
|
|
124
|
+
if (dropped > 0) {
|
|
125
|
+
warn?.(`dsbot-channel: dropped ${dropped} onebot media reference(s) from ${messageId} that carried neither a url nor a file name`);
|
|
126
|
+
}
|
|
127
|
+
const nickname = typeof event.sender?.card === 'string' && event.sender.card.length > 0
|
|
128
|
+
? event.sender.card
|
|
129
|
+
: typeof event.sender?.nickname === 'string'
|
|
130
|
+
? event.sender.nickname
|
|
131
|
+
: '';
|
|
132
|
+
const kind = config.bots.includes(senderId) ? 'bot' : 'person';
|
|
133
|
+
const body = rawText;
|
|
134
|
+
const rawMessage = typeof event.raw_message === 'string' ? event.raw_message : '';
|
|
135
|
+
const payload = {
|
|
136
|
+
rawMessage,
|
|
137
|
+
messageType: event.message_type,
|
|
138
|
+
...(typeof event.sub_type === 'string' ? { subType: event.sub_type } : {}),
|
|
139
|
+
};
|
|
140
|
+
const oversized = Buffer.byteLength(JSON.stringify(payload), 'utf8') > ADAPTER_META_MAX_BYTES;
|
|
141
|
+
if (oversized) {
|
|
142
|
+
warn?.(`dsbot-channel: kept the raw payload of onebot message ${messageId} out of the event: it is larger than ${ADAPTER_META_MAX_BYTES} bytes`);
|
|
143
|
+
}
|
|
144
|
+
const built = buildEvent({
|
|
145
|
+
source: { kind: 'onebot', instance: config.channel, peer: target, scope },
|
|
146
|
+
author: { senderId, kind, personId: null },
|
|
147
|
+
body: { text: body, media, replyTo: replyTo ?? null },
|
|
148
|
+
transport: {
|
|
149
|
+
messageId,
|
|
150
|
+
timestamp: typeof event.time === 'number' && Number.isFinite(event.time) ? event.time * 1000 : Date.now(),
|
|
151
|
+
mentioned: mentionsOf(event.message, selfId),
|
|
152
|
+
isReply: replyTo !== undefined,
|
|
153
|
+
signature: null,
|
|
154
|
+
},
|
|
155
|
+
declared: { nickname, avatar: '' },
|
|
156
|
+
...(oversized ? {} : { adapter: payload }),
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
eventKey: ownKey,
|
|
160
|
+
chainId,
|
|
161
|
+
sessionKey: `${scope === 'group' ? 'group' : 'peer'}:${target}`,
|
|
162
|
+
replyTarget: `${scope === 'group' ? 'group' : 'peer'}:${target}`,
|
|
163
|
+
requestId: messageId,
|
|
164
|
+
source: built.source,
|
|
165
|
+
author: built.author,
|
|
166
|
+
rawText,
|
|
167
|
+
body: built.body,
|
|
168
|
+
transport: built.meta.transport,
|
|
169
|
+
declared: built.meta.declared,
|
|
170
|
+
adapter: built.meta.adapter,
|
|
171
|
+
receivedAt: now(),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
export const ONEBOT_NOTICE_TYPES = [
|
|
175
|
+
'group_recall',
|
|
176
|
+
'friend_recall',
|
|
177
|
+
'group_ban',
|
|
178
|
+
'group_lift_ban',
|
|
179
|
+
'group_increase',
|
|
180
|
+
'group_decrease',
|
|
181
|
+
'group_admin',
|
|
182
|
+
'group_upload',
|
|
183
|
+
'group_card',
|
|
184
|
+
'notify',
|
|
185
|
+
];
|
|
186
|
+
export const ONEBOT_REQUEST_TYPES = ['friend', 'group'];
|
|
187
|
+
export const ONEBOT_META_TYPES = ['lifecycle', 'heartbeat'];
|
|
188
|
+
/** Folds a wire event type onto the closed set a status and a profile may name; anything else is "other". */
|
|
189
|
+
function eventTypeOf(postType, value) {
|
|
190
|
+
const text = typeof value === 'string' ? value : '';
|
|
191
|
+
const known = postType === 'notice'
|
|
192
|
+
? ONEBOT_NOTICE_TYPES
|
|
193
|
+
: postType === 'request'
|
|
194
|
+
? ONEBOT_REQUEST_TYPES
|
|
195
|
+
: postType === 'meta_event'
|
|
196
|
+
? ONEBOT_META_TYPES
|
|
197
|
+
: [];
|
|
198
|
+
if (known.includes(text))
|
|
199
|
+
return text;
|
|
200
|
+
return text.length === 0 ? 'unknown' : 'other';
|
|
201
|
+
}
|
|
202
|
+
/** The class a status counts an event under; undefined for a message, 'unknown' for a post_type we do not read. */
|
|
203
|
+
export function oneBotEventClass(event) {
|
|
204
|
+
const postType = typeof event.post_type === 'string' ? event.post_type : '';
|
|
205
|
+
if (postType === 'message')
|
|
206
|
+
return undefined;
|
|
207
|
+
if (postType === 'message_sent')
|
|
208
|
+
return 'message_sent';
|
|
209
|
+
if (postType === 'notice')
|
|
210
|
+
return `notice.${eventTypeOf('notice', event.notice_type)}`;
|
|
211
|
+
if (postType === 'request')
|
|
212
|
+
return `request.${eventTypeOf('request', event.request_type)}`;
|
|
213
|
+
if (postType === 'meta_event')
|
|
214
|
+
return `meta.${eventTypeOf('meta_event', event.meta_event_type)}`;
|
|
215
|
+
return 'unknown';
|
|
216
|
+
}
|
|
217
|
+
/** One line of peer text, bounded: control characters flattened, whitespace collapsed. */
|
|
218
|
+
function wireText(value, limit = 120) {
|
|
219
|
+
if (typeof value !== 'string')
|
|
220
|
+
return '';
|
|
221
|
+
let out = '';
|
|
222
|
+
for (const char of value) {
|
|
223
|
+
const code = char.codePointAt(0) ?? 0;
|
|
224
|
+
out += code < 0x20 || (code >= 0x7f && code <= 0x9f) ? ' ' : char;
|
|
225
|
+
}
|
|
226
|
+
return Array.from(out.replace(/\s+/g, ' ').trim()).slice(0, limit).join('');
|
|
227
|
+
}
|
|
228
|
+
/** A field kept token-shaped, so an event key never carries whitespace or control characters. */
|
|
229
|
+
function tokenPart(value, limit = 48) {
|
|
230
|
+
const text = typeof value === 'string' ? value : typeof value === 'number' && Number.isFinite(value) ? String(value) : '';
|
|
231
|
+
return Array.from(text)
|
|
232
|
+
.map((char) => (/[A-Za-z0-9_.-]/.test(char) ? char : '_'))
|
|
233
|
+
.join('')
|
|
234
|
+
.slice(0, limit);
|
|
235
|
+
}
|
|
236
|
+
function summaryOf(event, postType, type, subType, groupId, userId) {
|
|
237
|
+
const id = (value) => idOf(value) ?? 'unknown';
|
|
238
|
+
if (postType === 'message_sent') {
|
|
239
|
+
const kind = typeof event.message_type === 'string' ? event.message_type : 'unknown';
|
|
240
|
+
return `sent: ${kind} message ${id(event.message_id)} in ${groupId ?? userId ?? 'unknown'}`;
|
|
241
|
+
}
|
|
242
|
+
if (postType === 'meta_event') {
|
|
243
|
+
return `meta: ${type} account ${id(event.self_id)}${subType === undefined ? '' : ` ${subType}`}`;
|
|
244
|
+
}
|
|
245
|
+
if (postType === 'request') {
|
|
246
|
+
const comment = wireText(event.comment);
|
|
247
|
+
const head = `request: ${type} user ${id(event.user_id)}`;
|
|
248
|
+
if (type === 'group')
|
|
249
|
+
return `${head} asked to join group ${groupId ?? 'unknown'}${comment.length === 0 ? '' : `: ${comment}`}`;
|
|
250
|
+
return `${head} asked to be a friend${comment.length === 0 ? '' : `: ${comment}`}`;
|
|
251
|
+
}
|
|
252
|
+
const head = `notice: ${type}`;
|
|
253
|
+
const who = id(event.user_id);
|
|
254
|
+
switch (type) {
|
|
255
|
+
case 'group_recall':
|
|
256
|
+
return `${head} user ${who} recalled message ${id(event.message_id)} in group ${groupId ?? 'unknown'}`;
|
|
257
|
+
case 'friend_recall':
|
|
258
|
+
return `${head} user ${who} recalled a message`;
|
|
259
|
+
case 'group_ban':
|
|
260
|
+
return subType === 'lift_ban'
|
|
261
|
+
? `${head} operator ${id(event.operator_id)} lifted the ban on user ${who} in group ${groupId ?? 'unknown'}`
|
|
262
|
+
: `${head} operator ${id(event.operator_id)} banned user ${who} in group ${groupId ?? 'unknown'}`;
|
|
263
|
+
case 'group_lift_ban':
|
|
264
|
+
return `${head} operator ${id(event.operator_id)} lifted the ban on user ${who} in group ${groupId ?? 'unknown'}`;
|
|
265
|
+
case 'group_increase':
|
|
266
|
+
return `${head} user ${who} joined group ${groupId ?? 'unknown'}`;
|
|
267
|
+
case 'group_decrease':
|
|
268
|
+
return `${head} user ${who} left group ${groupId ?? 'unknown'}`;
|
|
269
|
+
case 'group_admin':
|
|
270
|
+
return `${head} user ${who} ${subType === 'unset' ? 'lost' : 'gained'} admin in group ${groupId ?? 'unknown'}`;
|
|
271
|
+
case 'group_upload':
|
|
272
|
+
return `${head} user ${who} uploaded ${wireText(event.file?.name) || 'a file'} to group ${groupId ?? 'unknown'}`;
|
|
273
|
+
case 'group_card':
|
|
274
|
+
return `${head} user ${who} changed card in group ${groupId ?? 'unknown'}`;
|
|
275
|
+
case 'notify':
|
|
276
|
+
return `${head} ${subType ?? 'notice'} from user ${who}`;
|
|
277
|
+
default:
|
|
278
|
+
return `${head} from user ${who}`;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** Maps a notice, request, meta event or a self echo onto the same inbound shape, marked by kind. */
|
|
282
|
+
function mapOneBotOther(event, config, now) {
|
|
283
|
+
const postType = typeof event.post_type === 'string' ? event.post_type : '';
|
|
284
|
+
if (postType !== 'notice' && postType !== 'request' && postType !== 'meta_event' && postType !== 'message_sent') {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
const selfId = idOf(event.self_id);
|
|
288
|
+
if (config.selfId !== undefined && selfId !== undefined && selfId !== config.selfId)
|
|
289
|
+
return undefined;
|
|
290
|
+
const groupId = idOf(event.group_id);
|
|
291
|
+
const userId = idOf(event.user_id) ?? idOf(event.target_id) ?? selfId;
|
|
292
|
+
const observedAt = typeof event.time === 'number' && Number.isFinite(event.time) ? event.time * 1000 : now();
|
|
293
|
+
const kind = postType === 'meta_event' ? 'meta' : postType === 'request' ? 'request' : 'notice';
|
|
294
|
+
const type = postType === 'notice'
|
|
295
|
+
? eventTypeOf('notice', event.notice_type)
|
|
296
|
+
: postType === 'request'
|
|
297
|
+
? eventTypeOf('request', event.request_type)
|
|
298
|
+
: postType === 'meta_event'
|
|
299
|
+
? eventTypeOf('meta_event', event.meta_event_type)
|
|
300
|
+
: 'sent';
|
|
301
|
+
const subType = typeof event.sub_type === 'string' && event.sub_type.length > 0 ? event.sub_type : undefined;
|
|
302
|
+
const identity = [
|
|
303
|
+
typeof event.time === 'number' && Number.isFinite(event.time) ? String(event.time) : '',
|
|
304
|
+
tokenPart(groupId),
|
|
305
|
+
tokenPart(userId),
|
|
306
|
+
tokenPart(subType),
|
|
307
|
+
tokenPart(idOf(event.operator_id)),
|
|
308
|
+
tokenPart(idOf(event.message_id)),
|
|
309
|
+
tokenPart(idOf(event.target_id)),
|
|
310
|
+
tokenPart(event.flag),
|
|
311
|
+
]
|
|
312
|
+
.map((part) => part.slice(0, 32))
|
|
313
|
+
.filter((part) => part.length > 0)
|
|
314
|
+
.join('-');
|
|
315
|
+
const head = `onebot:${tokenPart(config.channel, 40)}:${kind}.${type}:`;
|
|
316
|
+
const eventKey = head.length + identity.length <= 256
|
|
317
|
+
? `${head}${identity.length === 0 ? 'event' : identity}`
|
|
318
|
+
: `${head}${createHash('sha256').update(identity).digest('hex').slice(0, 16)}`;
|
|
319
|
+
const scope = groupId === undefined ? 'c2c' : 'group';
|
|
320
|
+
const sessionKey = groupId === undefined ? `peer:${userId ?? selfId ?? 'unknown'}` : `group:${groupId}`;
|
|
321
|
+
const summary = summaryOf(event, postType, type, subType, groupId, userId);
|
|
322
|
+
return {
|
|
323
|
+
eventKey,
|
|
324
|
+
kind,
|
|
325
|
+
event: { postType, type, ...(subType === undefined ? {} : { subType }) },
|
|
326
|
+
chainId: eventKey,
|
|
327
|
+
sessionKey,
|
|
328
|
+
replyTarget: sessionKey,
|
|
329
|
+
requestId: eventKey,
|
|
330
|
+
source: { kind: 'onebot', instance: config.channel, peer: groupId ?? userId ?? null, scope },
|
|
331
|
+
author: {
|
|
332
|
+
senderId: (postType === 'notice' ? (idOf(event.operator_id) ?? userId) : userId) ?? selfId ?? 'unknown',
|
|
333
|
+
kind: config.bots.includes(idOf(event.operator_id) ?? userId ?? '') ? 'bot' : 'person',
|
|
334
|
+
personId: null,
|
|
335
|
+
},
|
|
336
|
+
rawText: summary,
|
|
337
|
+
body: { text: summary, media: [], replyTo: null },
|
|
338
|
+
transport: {
|
|
339
|
+
messageId: eventKey,
|
|
340
|
+
timestamp: observedAt,
|
|
341
|
+
mentioned: { self: false, others: false, all: false },
|
|
342
|
+
isReply: false,
|
|
343
|
+
signature: null,
|
|
344
|
+
},
|
|
345
|
+
declared: { nickname: '', avatar: '' },
|
|
346
|
+
adapter: {},
|
|
347
|
+
receivedAt: now(),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/** Maps one OneBot event onto the inbound message core takes; returns undefined for an event we do not read. */
|
|
351
|
+
export function mapOneBotEvent(event, config, chainOf, warn, now = Date.now) {
|
|
352
|
+
if (event.post_type === 'message')
|
|
353
|
+
return mapOneBotMessage(event, config, chainOf, warn, now);
|
|
354
|
+
return mapOneBotOther(event, config, now);
|
|
355
|
+
}
|
|
356
|
+
class Seen {
|
|
357
|
+
#seen = new Map();
|
|
358
|
+
#now;
|
|
359
|
+
#ttlMs;
|
|
360
|
+
#limit;
|
|
361
|
+
constructor(ttlMs, limit, now) {
|
|
362
|
+
this.#ttlMs = ttlMs;
|
|
363
|
+
this.#limit = limit;
|
|
364
|
+
this.#now = now;
|
|
365
|
+
}
|
|
366
|
+
/** True when this key was already recorded inside the window; records it otherwise. */
|
|
367
|
+
claim(key) {
|
|
368
|
+
const at = this.#now();
|
|
369
|
+
const seen = this.#seen.get(key);
|
|
370
|
+
if (seen !== undefined) {
|
|
371
|
+
this.#seen.delete(key);
|
|
372
|
+
if (at - seen < this.#ttlMs) {
|
|
373
|
+
this.#seen.set(key, seen);
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
this.#seen.set(key, at);
|
|
378
|
+
while (this.#seen.size > this.#limit) {
|
|
379
|
+
const oldest = this.#seen.keys().next();
|
|
380
|
+
if (oldest.done === true)
|
|
381
|
+
break;
|
|
382
|
+
this.#seen.delete(oldest.value);
|
|
383
|
+
}
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
get size() {
|
|
387
|
+
return this.#seen.size;
|
|
388
|
+
}
|
|
389
|
+
prune() {
|
|
390
|
+
const at = this.#now();
|
|
391
|
+
for (const [key, seen] of this.#seen) {
|
|
392
|
+
if (at - seen >= this.#ttlMs)
|
|
393
|
+
this.#seen.delete(key);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/** The OneBot 11 transport: events arrive over reverse HTTP, replies call the implementation's HTTP API. */
|
|
398
|
+
export class OneBotAdapter {
|
|
399
|
+
kind = 'onebot';
|
|
400
|
+
guidance = { peer: ONEBOT_PEER_GUIDANCE, group: ONEBOT_GROUP_GUIDANCE };
|
|
401
|
+
capabilities;
|
|
402
|
+
#config;
|
|
403
|
+
#stats;
|
|
404
|
+
#now;
|
|
405
|
+
#dedupe;
|
|
406
|
+
#chainRoot = new Map();
|
|
407
|
+
#ports;
|
|
408
|
+
#server;
|
|
409
|
+
#forward;
|
|
410
|
+
#reverse;
|
|
411
|
+
#warn;
|
|
412
|
+
constructor(config, options = {}) {
|
|
413
|
+
this.#config = config;
|
|
414
|
+
this.capabilities = {
|
|
415
|
+
text: true,
|
|
416
|
+
image: { in: true, out: true, fromUrl: true, fromHandle: true },
|
|
417
|
+
file: { in: true, out: true, fromUrl: config.fileFromUrl === true, fromHandle: true },
|
|
418
|
+
voice: { in: true, out: true, fromUrl: true, fromHandle: true },
|
|
419
|
+
recall: false,
|
|
420
|
+
history: {
|
|
421
|
+
group: config.history?.group === true,
|
|
422
|
+
private: config.history?.private === true,
|
|
423
|
+
},
|
|
424
|
+
mention: true,
|
|
425
|
+
reply: true,
|
|
426
|
+
stream: false,
|
|
427
|
+
};
|
|
428
|
+
this.#stats = new ChannelStats(options.now);
|
|
429
|
+
this.#now = options.now ?? Date.now;
|
|
430
|
+
this.#warn = options.warn;
|
|
431
|
+
this.#dedupe = new Seen(ONEBOT_DEDUPE_TTL_MS, ONEBOT_DEDUPE_LIMIT, this.#now);
|
|
432
|
+
}
|
|
433
|
+
get snapshot() {
|
|
434
|
+
return this.#stats.snapshot();
|
|
435
|
+
}
|
|
436
|
+
checkConfig() {
|
|
437
|
+
if (this.#config.accessToken !== undefined && this.#config.accessToken.trim().length === 0) {
|
|
438
|
+
throw new ConfigError('transport.accessToken must name a credential', ['adapters', 'transport', 'accessToken']);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
sessionScope(key) {
|
|
442
|
+
if (key.startsWith('group:'))
|
|
443
|
+
return 'group';
|
|
444
|
+
if (key.startsWith('peer:'))
|
|
445
|
+
return 'c2c';
|
|
446
|
+
return undefined;
|
|
447
|
+
}
|
|
448
|
+
checkSessionKey(key) {
|
|
449
|
+
return this.#targetProblem(key, 'session key');
|
|
450
|
+
}
|
|
451
|
+
checkTarget(target) {
|
|
452
|
+
return this.#targetProblem(target, 'target');
|
|
453
|
+
}
|
|
454
|
+
describeTargets() {
|
|
455
|
+
return 'group:<id> or peer:<id>';
|
|
456
|
+
}
|
|
457
|
+
status() {
|
|
458
|
+
const snapshot = this.#stats.snapshot();
|
|
459
|
+
const listening = snapshot.state === 'listening';
|
|
460
|
+
const forward = this.#forward;
|
|
461
|
+
return buildStatus({
|
|
462
|
+
channel: this.#config.channel,
|
|
463
|
+
account: this.#config.channel,
|
|
464
|
+
bot: this.#config.botName,
|
|
465
|
+
health: forward === undefined ? buildListenerHealth({
|
|
466
|
+
listenState: listening ? 'listening' : snapshot.state === 'failed' ? 'failed' : 'stopped',
|
|
467
|
+
boundPort: snapshot.boundPort,
|
|
468
|
+
lastAuthenticatedAt: this.#reverse?.lastAuthenticatedAt() ?? snapshot.lastAuthenticatedAt,
|
|
469
|
+
lastFailure: snapshot.lastFailure,
|
|
470
|
+
queueDepth: snapshot.inFlight,
|
|
471
|
+
}) : forward.health(),
|
|
472
|
+
counters: countersOf(snapshot),
|
|
473
|
+
capabilities: this.capabilities,
|
|
474
|
+
attributes: { stableMessageId: true },
|
|
475
|
+
quota: { softLimit: 0, hardLimit: 0 },
|
|
476
|
+
sampledAt: snapshot.sampledAt,
|
|
477
|
+
queueDepth: snapshot.inFlight,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
async send(input) {
|
|
481
|
+
const target = this.#parseTarget(input.target);
|
|
482
|
+
if (target === undefined) {
|
|
483
|
+
return { ok: false, code: 'no_url', retryable: false, reason: `this adapter cannot address "${input.target}"` };
|
|
484
|
+
}
|
|
485
|
+
const file = (input.media ?? []).find((item) => item.type === 'file');
|
|
486
|
+
if (file !== undefined) {
|
|
487
|
+
if (input.body.length > 0) {
|
|
488
|
+
this.#warn?.('dsbot-channel: dropped the text beside a file: a file send carries its name only');
|
|
489
|
+
}
|
|
490
|
+
return this.#sendFile(target, file, input.timeoutMs);
|
|
491
|
+
}
|
|
492
|
+
const message = this.#messageOf(input);
|
|
493
|
+
if (message === undefined) {
|
|
494
|
+
return { ok: false, code: 'rejected', retryable: false, reason: 'there is nothing this channel can carry' };
|
|
495
|
+
}
|
|
496
|
+
const action = target.scope === 'group' ? 'send_group_msg' : 'send_private_msg';
|
|
497
|
+
const params = target.scope === 'group'
|
|
498
|
+
? { group_id: target.id, message }
|
|
499
|
+
: { user_id: target.id, message };
|
|
500
|
+
const answered = await this.#call(action, params, input.timeoutMs);
|
|
501
|
+
if (!answered.ok) {
|
|
502
|
+
this.#stats.sendFailed(answered.code);
|
|
503
|
+
return answered;
|
|
504
|
+
}
|
|
505
|
+
this.#rememberChain(String(answered.messageId), input.chainId ?? input.eventId);
|
|
506
|
+
this.#rememberSent(`${target.scope === 'group' ? 'group' : 'peer'}:${target.id}`, String(answered.messageId));
|
|
507
|
+
return answered;
|
|
508
|
+
}
|
|
509
|
+
/** Pages earlier messages through the implementation's history API. */
|
|
510
|
+
async history(input) {
|
|
511
|
+
const target = this.#parseTarget(input.target);
|
|
512
|
+
if (target === undefined) {
|
|
513
|
+
return { ok: false, code: 'unsupported', reason: `this adapter cannot address "${input.target}"` };
|
|
514
|
+
}
|
|
515
|
+
const segment = target.scope === 'group' ? 'group' : 'private';
|
|
516
|
+
if (this.capabilities.history[segment] !== true) {
|
|
517
|
+
return { ok: false, code: 'unsupported', reason: `capabilities.history.${segment} is false` };
|
|
518
|
+
}
|
|
519
|
+
const action = target.scope === 'group' ? 'get_group_msg_history' : 'get_friend_msg_history';
|
|
520
|
+
const params = {
|
|
521
|
+
...(target.scope === 'group' ? { group_id: target.id } : { user_id: target.id }),
|
|
522
|
+
count: input.limit,
|
|
523
|
+
...(input.before === undefined ? {} : { message_seq: input.before }),
|
|
524
|
+
};
|
|
525
|
+
const answered = await this.#historyCall(action, params);
|
|
526
|
+
if (!answered.ok)
|
|
527
|
+
return answered;
|
|
528
|
+
const raw = Array.isArray(answered.data.messages) ? answered.data.messages : [];
|
|
529
|
+
const records = [];
|
|
530
|
+
for (const entry of raw) {
|
|
531
|
+
const record = this.#historyRecord(entry);
|
|
532
|
+
if (record !== undefined)
|
|
533
|
+
records.push(record);
|
|
534
|
+
}
|
|
535
|
+
// Oldest first, so a reader follows the conversation the way it happened.
|
|
536
|
+
records.sort((left, right) => left.timestamp - right.timestamp);
|
|
537
|
+
// `next` is the cursor to pass back as `before` for earlier messages: the oldest record on
|
|
538
|
+
// this page, not an offset. A page without `next` means there is nothing older.
|
|
539
|
+
const next = records.length > 0 && records.length >= input.limit ? records[0].cursor : undefined;
|
|
540
|
+
return { ok: true, records, ...(next === undefined ? {} : { next }) };
|
|
541
|
+
}
|
|
542
|
+
#historyRecord(entry) {
|
|
543
|
+
if (entry === null || typeof entry !== 'object')
|
|
544
|
+
return undefined;
|
|
545
|
+
const one = entry;
|
|
546
|
+
const messageId = idOf(one.message_id);
|
|
547
|
+
if (messageId === undefined)
|
|
548
|
+
return undefined;
|
|
549
|
+
const sender = (one.sender === null || typeof one.sender !== 'object' ? {} : one.sender);
|
|
550
|
+
const card = typeof sender.card === 'string' && sender.card.length > 0 ? sender.card : undefined;
|
|
551
|
+
const nickname = card ?? (typeof sender.nickname === 'string' && sender.nickname.length > 0 ? sender.nickname : undefined);
|
|
552
|
+
const cursor = idOf(one.message_seq) ?? idOf(one.real_seq) ?? messageId;
|
|
553
|
+
const seconds = typeof one.time === 'number' && Number.isFinite(one.time) ? one.time : 0;
|
|
554
|
+
return {
|
|
555
|
+
messageId,
|
|
556
|
+
cursor,
|
|
557
|
+
timestamp: seconds * 1000,
|
|
558
|
+
senderId: idOf(sender.user_id) ?? '',
|
|
559
|
+
text: segmentText(one.message),
|
|
560
|
+
...(nickname === undefined ? {} : { nickname }),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
async #historyCall(action, params) {
|
|
564
|
+
const ports = this.#ports;
|
|
565
|
+
if (ports === undefined) {
|
|
566
|
+
return { ok: false, code: 'unreachable', reason: 'the onebot adapter is not started' };
|
|
567
|
+
}
|
|
568
|
+
if (this.#config.protocol !== 'http') {
|
|
569
|
+
const socket = this.#socketLink();
|
|
570
|
+
if (socket === undefined) {
|
|
571
|
+
return { ok: false, code: 'unreachable', reason: 'the socket is not open' };
|
|
572
|
+
}
|
|
573
|
+
const answer = await socket.request(action, params, undefined);
|
|
574
|
+
if (!answer.ok) {
|
|
575
|
+
if (/^消息.*不存在$/.test(answer.reason))
|
|
576
|
+
return { ok: true, data: {} };
|
|
577
|
+
if (/^记录.*不存在$/.test(answer.reason))
|
|
578
|
+
return { ok: false, code: 'rejected', reason: answer.reason };
|
|
579
|
+
return { ok: false, code: answer.code, reason: answer.reason };
|
|
580
|
+
}
|
|
581
|
+
const data = answer.frame.data;
|
|
582
|
+
return { ok: true, data: data !== null && typeof data === 'object' ? data : {} };
|
|
583
|
+
}
|
|
584
|
+
const api = this.#config.api;
|
|
585
|
+
if (api === undefined) {
|
|
586
|
+
return { ok: false, code: 'unsupported', reason: 'this protocol has no http action api' };
|
|
587
|
+
}
|
|
588
|
+
const token = this.#config.accessToken === undefined ? undefined : await ports.resolveSecret(this.#config.accessToken);
|
|
589
|
+
if (this.#config.accessToken !== undefined && token === undefined) {
|
|
590
|
+
return { ok: false, code: 'credential', reason: 'the onebot access token did not resolve' };
|
|
591
|
+
}
|
|
592
|
+
let response;
|
|
593
|
+
try {
|
|
594
|
+
response = await fetch(`${api.url}/${action}`, {
|
|
595
|
+
method: 'POST',
|
|
596
|
+
headers: {
|
|
597
|
+
'content-type': 'application/json',
|
|
598
|
+
...(token === undefined ? {} : { authorization: `Bearer ${token}` }),
|
|
599
|
+
},
|
|
600
|
+
body: JSON.stringify(params),
|
|
601
|
+
signal: AbortSignal.timeout(this.#config.sendTimeoutMs),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
const timeout = error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError');
|
|
606
|
+
return {
|
|
607
|
+
ok: false,
|
|
608
|
+
code: timeout ? 'timeout' : 'unreachable',
|
|
609
|
+
reason: timeout ? 'the onebot api did not answer in time' : `the onebot api is unreachable: ${String(error)}`,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
let parsed;
|
|
613
|
+
try {
|
|
614
|
+
parsed = (await response.json());
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
if (response.status === 404) {
|
|
618
|
+
return { ok: false, code: 'unsupported', reason: 'the implementation has no such history action' };
|
|
619
|
+
}
|
|
620
|
+
return { ok: false, code: 'rejected', reason: `the api answered ${response.status} without json` };
|
|
621
|
+
}
|
|
622
|
+
const message = this.#oneLine(parsed.message ?? '');
|
|
623
|
+
if (response.status === 401 || response.status === 403 || parsed.retcode === 1403) {
|
|
624
|
+
return { ok: false, code: 'unauthorized', reason: message === '' ? 'unauthorized' : message };
|
|
625
|
+
}
|
|
626
|
+
if (/^消息.*不存在$/.test(message))
|
|
627
|
+
return { ok: true, data: {} };
|
|
628
|
+
if (/^记录.*不存在$/.test(message))
|
|
629
|
+
return { ok: false, code: 'rejected', reason: message };
|
|
630
|
+
if (response.status === 404) {
|
|
631
|
+
return { ok: false, code: 'unsupported', reason: message === '' ? 'the implementation has no such history action' : message };
|
|
632
|
+
}
|
|
633
|
+
if (!response.ok || parsed.status === 'failed' || (parsed.retcode !== undefined && parsed.retcode !== 0)) {
|
|
634
|
+
return {
|
|
635
|
+
ok: false,
|
|
636
|
+
code: response.status >= 500 ? 'unreachable' : 'rejected',
|
|
637
|
+
reason: message === '' ? `the api answered ${response.status}` : message,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
if (parsed.data === null || typeof parsed.data !== 'object')
|
|
641
|
+
return { ok: true, data: {} };
|
|
642
|
+
return { ok: true, data: parsed.data };
|
|
643
|
+
}
|
|
644
|
+
/** Uploads one file; the implementation takes a url only when the profile said it does. */
|
|
645
|
+
async #sendFile(target, item, timeoutMs) {
|
|
646
|
+
const capability = this.capabilities.file;
|
|
647
|
+
if (capability.out !== true) {
|
|
648
|
+
return { ok: false, code: 'unsupported', retryable: false, reason: 'this channel cannot send files' };
|
|
649
|
+
}
|
|
650
|
+
if (item.url !== null && capability.fromUrl !== true) {
|
|
651
|
+
return {
|
|
652
|
+
ok: false,
|
|
653
|
+
code: 'unsupported',
|
|
654
|
+
retryable: false,
|
|
655
|
+
reason: 'this channel cannot take a file from a url; transport.fileFromUrl is false',
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
const file = item.url ?? item.handle;
|
|
659
|
+
if (file === null) {
|
|
660
|
+
return { ok: false, code: 'no_url', retryable: false, reason: 'the file names neither a url nor a handle' };
|
|
661
|
+
}
|
|
662
|
+
if (item.url === null) {
|
|
663
|
+
if (capability.fromHandle !== true) {
|
|
664
|
+
return { ok: false, code: 'unsupported', retryable: false, reason: 'this channel cannot take a file from a handle' };
|
|
665
|
+
}
|
|
666
|
+
const owner = item.owner;
|
|
667
|
+
if (owner === undefined || owner.kind !== this.kind || owner.instance !== this.#config.channel) {
|
|
668
|
+
this.#warn?.('dsbot-channel: dropped a file whose handle belongs to another instance');
|
|
669
|
+
return {
|
|
670
|
+
ok: false,
|
|
671
|
+
code: 'rejected',
|
|
672
|
+
retryable: false,
|
|
673
|
+
reason: 'This file handle was issued by another instance; send it back on the instance that received it.',
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const name = item.name;
|
|
678
|
+
if (name === undefined || name.trim().length === 0) {
|
|
679
|
+
return { ok: false, code: 'rejected', retryable: false, reason: 'a file needs a name' };
|
|
680
|
+
}
|
|
681
|
+
const action = target.scope === 'group' ? 'upload_group_file' : 'upload_private_file';
|
|
682
|
+
const params = target.scope === 'group' ? { group_id: target.id, file, name } : { user_id: target.id, file, name };
|
|
683
|
+
const sent = await this.#uploadCall(action, params, timeoutMs);
|
|
684
|
+
if (sent.ok)
|
|
685
|
+
this.#rememberSent(`${target.scope === 'group' ? 'group' : 'peer'}:${target.id}`, sent.messageId);
|
|
686
|
+
return sent;
|
|
687
|
+
}
|
|
688
|
+
async #uploadCall(action, params, timeoutMs) {
|
|
689
|
+
const ports = this.#ports;
|
|
690
|
+
if (ports === undefined) {
|
|
691
|
+
return { ok: false, code: 'unreachable', retryable: true, reason: 'the onebot adapter is not started' };
|
|
692
|
+
}
|
|
693
|
+
if (this.#config.protocol !== 'http') {
|
|
694
|
+
const socket = this.#socketLink();
|
|
695
|
+
if (socket === undefined) {
|
|
696
|
+
return { ok: false, code: 'unreachable', retryable: true, reason: 'the socket is not open' };
|
|
697
|
+
}
|
|
698
|
+
const answer = await socket.request(action, params, timeoutMs);
|
|
699
|
+
if (!answer.ok) {
|
|
700
|
+
return { ok: false, code: answer.code, retryable: answer.code === 'unreachable', reason: answer.reason };
|
|
701
|
+
}
|
|
702
|
+
const data = answer.frame.data;
|
|
703
|
+
const payload = data !== null && typeof data === 'object' ? data : {};
|
|
704
|
+
const given = idOf(payload.file_id) ?? idOf(payload.message_id);
|
|
705
|
+
return { ok: true, messageId: given ?? `local:${randomUUID()}`, duplicate: false };
|
|
706
|
+
}
|
|
707
|
+
const api = this.#config.api;
|
|
708
|
+
if (api === undefined) {
|
|
709
|
+
return { ok: false, code: 'unsupported', retryable: false, reason: 'this protocol has no http action api' };
|
|
710
|
+
}
|
|
711
|
+
const token = this.#config.accessToken === undefined ? undefined : await ports.resolveSecret(this.#config.accessToken);
|
|
712
|
+
if (this.#config.accessToken !== undefined && token === undefined) {
|
|
713
|
+
return { ok: false, code: 'credential', retryable: false, reason: 'the onebot access token did not resolve' };
|
|
714
|
+
}
|
|
715
|
+
let response;
|
|
716
|
+
try {
|
|
717
|
+
response = await fetch(`${api.url}/${action}`, {
|
|
718
|
+
method: 'POST',
|
|
719
|
+
headers: {
|
|
720
|
+
'content-type': 'application/json',
|
|
721
|
+
...(token === undefined ? {} : { authorization: `Bearer ${token}` }),
|
|
722
|
+
},
|
|
723
|
+
body: JSON.stringify(params),
|
|
724
|
+
signal: AbortSignal.timeout(timeoutMs ?? this.#config.sendTimeoutMs),
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
const timeout = error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError');
|
|
729
|
+
return {
|
|
730
|
+
ok: false,
|
|
731
|
+
code: timeout ? 'timeout' : 'unreachable',
|
|
732
|
+
retryable: !timeout,
|
|
733
|
+
reason: timeout ? 'the onebot api did not answer in time' : `the onebot api is unreachable: ${String(error)}`,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
let parsed;
|
|
737
|
+
try {
|
|
738
|
+
parsed = (await response.json());
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
if (response.status === 404) {
|
|
742
|
+
return { ok: false, code: 'unsupported', retryable: false, reason: 'the implementation has no upload action' };
|
|
743
|
+
}
|
|
744
|
+
return {
|
|
745
|
+
ok: false,
|
|
746
|
+
code: 'rejected',
|
|
747
|
+
retryable: response.status >= 500,
|
|
748
|
+
reason: `the api answered ${response.status} without json`,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
const message = this.#oneLine(parsed.message ?? '');
|
|
752
|
+
if (response.status === 401 || response.status === 403) {
|
|
753
|
+
return { ok: false, code: 'unauthorized', retryable: false, reason: message === '' ? 'unauthorized' : message };
|
|
754
|
+
}
|
|
755
|
+
if (response.status === 404) {
|
|
756
|
+
return {
|
|
757
|
+
ok: false,
|
|
758
|
+
code: 'unsupported',
|
|
759
|
+
retryable: false,
|
|
760
|
+
reason: message === '' ? 'the implementation has no upload action' : message,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
if (!response.ok || parsed.status !== 'ok') {
|
|
764
|
+
return {
|
|
765
|
+
ok: false,
|
|
766
|
+
code: response.status >= 500 ? 'unreachable' : 'rejected',
|
|
767
|
+
retryable: response.status >= 500,
|
|
768
|
+
reason: message === '' ? `the api answered ${response.status}` : message,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
const data = (parsed.data ?? {});
|
|
772
|
+
const given = idOf(data.file_id) ?? idOf(data.message_id);
|
|
773
|
+
return { ok: true, messageId: given ?? `local:${randomUUID()}`, duplicate: false };
|
|
774
|
+
}
|
|
775
|
+
/** The text alone when there is no media, otherwise the segments this instance may carry. */
|
|
776
|
+
#messageOf(input) {
|
|
777
|
+
const segments = [];
|
|
778
|
+
if (input.body.length > 0)
|
|
779
|
+
segments.push({ type: 'text', data: { text: input.body } });
|
|
780
|
+
for (const item of input.media ?? []) {
|
|
781
|
+
const segment = item.type === 'image' ? 'image' : item.type === 'voice' ? 'record' : undefined;
|
|
782
|
+
if (segment === undefined) {
|
|
783
|
+
this.#warn?.(`dsbot-channel: dropped a ${item.type} from the send: this version sends images and voice`);
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
const capability = item.type === 'image' ? this.capabilities.image : this.capabilities.voice;
|
|
787
|
+
if (capability.out !== true) {
|
|
788
|
+
this.#warn?.(`dsbot-channel: dropped a ${item.type} from the send: capabilities.${item.type}.out is false`);
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
if (item.url === null && item.handle === null) {
|
|
792
|
+
this.#warn?.('dsbot-channel: dropped a media item from the send: it names neither a url nor a handle');
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
if (item.url === null && (typeof item.handle !== 'string' || handleProblem(item.handle) !== undefined)) {
|
|
796
|
+
this.#warn?.('dsbot-channel: dropped a media item from the send: its handle is not one printable token');
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
const source = item.url !== null ? capability.fromUrl : capability.fromHandle;
|
|
800
|
+
if (source !== true) {
|
|
801
|
+
this.#warn?.(`dsbot-channel: dropped a ${item.type} from the send: this channel cannot take it from that source`);
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
if (item.url === null) {
|
|
805
|
+
const owner = item.owner;
|
|
806
|
+
if (owner === undefined || owner.kind !== this.kind || owner.instance !== this.#config.channel) {
|
|
807
|
+
this.#warn?.(`dsbot-channel: dropped a ${item.type} whose handle belongs to another instance`);
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
segments.push({ type: segment, data: { file: (item.url ?? item.handle) } });
|
|
812
|
+
}
|
|
813
|
+
if (segments.length === 0)
|
|
814
|
+
return undefined;
|
|
815
|
+
if (segments.length === 1 && segments[0].type === 'text')
|
|
816
|
+
return input.body;
|
|
817
|
+
return segments;
|
|
818
|
+
}
|
|
819
|
+
/** The socket a websocket protocol speaks through, whichever direction it dials. */
|
|
820
|
+
#socketLink() {
|
|
821
|
+
if (this.#config.protocol === 'ws-forward')
|
|
822
|
+
return this.#forward;
|
|
823
|
+
if (this.#config.protocol === 'ws-reverse')
|
|
824
|
+
return this.#reverse;
|
|
825
|
+
return undefined;
|
|
826
|
+
}
|
|
827
|
+
async start(ports) {
|
|
828
|
+
if (this.#config.protocol === 'ws-forward')
|
|
829
|
+
return this.#startForward(ports);
|
|
830
|
+
if (this.#config.protocol === 'ws-reverse')
|
|
831
|
+
return this.#startReverse(ports);
|
|
832
|
+
const listen = this.#config.listen;
|
|
833
|
+
if (this.#config.protocol !== 'http' || listen === undefined) {
|
|
834
|
+
throw new ConfigError(`transport.protocol "${this.#config.protocol}" is not wired yet; this build answers over http`, ['adapters', 'transport', 'protocol']);
|
|
835
|
+
}
|
|
836
|
+
this.#ports = ports;
|
|
837
|
+
if (this.#config.accessToken !== undefined) {
|
|
838
|
+
const token = await ports.resolveSecret(this.#config.accessToken);
|
|
839
|
+
if (token === undefined) {
|
|
840
|
+
throw new ConfigError('transport.accessToken names a credential that did not resolve', [
|
|
841
|
+
'adapters',
|
|
842
|
+
'transport',
|
|
843
|
+
'accessToken',
|
|
844
|
+
]);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
const server = createServer((request, response) => {
|
|
848
|
+
void this.#handle(request, response);
|
|
849
|
+
});
|
|
850
|
+
await new Promise((resolve, reject) => {
|
|
851
|
+
server.once('error', reject);
|
|
852
|
+
server.listen(listen.port, listen.host, () => {
|
|
853
|
+
server.off('error', reject);
|
|
854
|
+
resolve();
|
|
855
|
+
});
|
|
856
|
+
}).catch((error) => {
|
|
857
|
+
this.#stats.failed(error instanceof Error ? error.message : String(error));
|
|
858
|
+
throw error;
|
|
859
|
+
});
|
|
860
|
+
this.#server = server;
|
|
861
|
+
this.#stats.listening(server.address().port);
|
|
862
|
+
return {
|
|
863
|
+
stop: async () => {
|
|
864
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
865
|
+
this.#stats.closed();
|
|
866
|
+
},
|
|
867
|
+
address: () => ({ port: server.address().port }),
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
/** Stops the listener without going through the handle, for tests and for a failed mount. */
|
|
871
|
+
async stop() {
|
|
872
|
+
const server = this.#server;
|
|
873
|
+
this.#server = undefined;
|
|
874
|
+
if (server === undefined)
|
|
875
|
+
return;
|
|
876
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
877
|
+
this.#stats.closed();
|
|
878
|
+
}
|
|
879
|
+
#recentSent = new Map();
|
|
880
|
+
#rememberSent(session, messageId) {
|
|
881
|
+
const list = this.#recentSent.get(session) ?? [];
|
|
882
|
+
list.push(messageId);
|
|
883
|
+
while (list.length > 20)
|
|
884
|
+
list.shift();
|
|
885
|
+
this.#recentSent.set(session, list);
|
|
886
|
+
}
|
|
887
|
+
#rememberChain(messageId, chainId) {
|
|
888
|
+
this.#chainRoot.set(messageId, chainId);
|
|
889
|
+
while (this.#chainRoot.size > ONEBOT_CHAIN_LIMIT) {
|
|
890
|
+
const oldest = this.#chainRoot.keys().next();
|
|
891
|
+
if (oldest.done === true)
|
|
892
|
+
break;
|
|
893
|
+
this.#chainRoot.delete(oldest.value);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
#targetProblem(value, label) {
|
|
897
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
898
|
+
return `${label} must be a non-empty string`;
|
|
899
|
+
if (this.#parseTarget(value) === undefined)
|
|
900
|
+
return `${label} must look like group:<id> or peer:<id>: "${value}"`;
|
|
901
|
+
return undefined;
|
|
902
|
+
}
|
|
903
|
+
#parseTarget(value) {
|
|
904
|
+
const match = /^(group|peer):([0-9A-Za-z_-]+)$/.exec(value);
|
|
905
|
+
if (match === null)
|
|
906
|
+
return undefined;
|
|
907
|
+
return { scope: match[1] === 'group' ? 'group' : 'c2c', id: match[2] };
|
|
908
|
+
}
|
|
909
|
+
async #call(action, params, timeoutMs) {
|
|
910
|
+
const ports = this.#ports;
|
|
911
|
+
if (ports === undefined)
|
|
912
|
+
return { ok: false, code: 'unreachable', retryable: true, reason: 'the onebot adapter is not started' };
|
|
913
|
+
if (this.#config.protocol !== 'http') {
|
|
914
|
+
const socket = this.#socketLink();
|
|
915
|
+
if (socket === undefined)
|
|
916
|
+
return { ok: false, code: 'unreachable', retryable: true, reason: 'the socket is not open' };
|
|
917
|
+
const answer = await socket.request(action, params, timeoutMs);
|
|
918
|
+
if (!answer.ok) {
|
|
919
|
+
return { ok: false, code: answer.code, retryable: answer.code === 'unreachable', reason: answer.reason };
|
|
920
|
+
}
|
|
921
|
+
const data = answer.frame.data;
|
|
922
|
+
const messageId = data !== null && typeof data === 'object' ? idOf(data.message_id) : undefined;
|
|
923
|
+
if (messageId === undefined) {
|
|
924
|
+
return { ok: false, code: 'rejected', retryable: false, reason: 'the api answered without a message id' };
|
|
925
|
+
}
|
|
926
|
+
return { ok: true, messageId, duplicate: false };
|
|
927
|
+
}
|
|
928
|
+
const api = this.#config.api;
|
|
929
|
+
if (api === undefined) {
|
|
930
|
+
return { ok: false, code: 'unsupported', retryable: false, reason: 'this protocol has no http action api' };
|
|
931
|
+
}
|
|
932
|
+
const token = this.#config.accessToken === undefined ? undefined : await ports.resolveSecret(this.#config.accessToken);
|
|
933
|
+
if (this.#config.accessToken !== undefined && token === undefined) {
|
|
934
|
+
return { ok: false, code: 'credential', retryable: false, reason: 'the onebot access token did not resolve' };
|
|
935
|
+
}
|
|
936
|
+
let response;
|
|
937
|
+
try {
|
|
938
|
+
response = await fetch(`${api.url}/${action}`, {
|
|
939
|
+
method: 'POST',
|
|
940
|
+
headers: {
|
|
941
|
+
'content-type': 'application/json',
|
|
942
|
+
...(token === undefined ? {} : { authorization: `Bearer ${token}` }),
|
|
943
|
+
},
|
|
944
|
+
body: JSON.stringify(params),
|
|
945
|
+
signal: AbortSignal.timeout(timeoutMs ?? this.#config.sendTimeoutMs),
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
catch (error) {
|
|
949
|
+
const timeout = error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError');
|
|
950
|
+
return {
|
|
951
|
+
ok: false,
|
|
952
|
+
code: timeout ? 'timeout' : 'unreachable',
|
|
953
|
+
retryable: !timeout,
|
|
954
|
+
reason: timeout ? 'the onebot api did not answer in time' : `the onebot api is unreachable: ${String(error)}`,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
let parsed;
|
|
958
|
+
try {
|
|
959
|
+
parsed = (await response.json());
|
|
960
|
+
}
|
|
961
|
+
catch {
|
|
962
|
+
return { ok: false, code: 'rejected', retryable: response.status >= 500, reason: `the api answered ${response.status} without json` };
|
|
963
|
+
}
|
|
964
|
+
if (response.status === 401 || response.status === 403 || parsed.retcode === 1403) {
|
|
965
|
+
return { ok: false, code: 'unauthorized', retryable: false, reason: this.#oneLine(parsed.message ?? 'unauthorized') };
|
|
966
|
+
}
|
|
967
|
+
if (!response.ok || (parsed.retcode !== undefined && parsed.retcode !== 0)) {
|
|
968
|
+
return {
|
|
969
|
+
ok: false,
|
|
970
|
+
code: response.status >= 500 ? 'unreachable' : 'rejected',
|
|
971
|
+
retryable: response.status >= 500,
|
|
972
|
+
reason: this.#oneLine(parsed.message ?? `the api answered ${response.status}`),
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
const messageId = idOf(parsed.data?.message_id);
|
|
976
|
+
if (messageId === undefined)
|
|
977
|
+
return { ok: false, code: 'rejected', retryable: false, reason: 'the api answered without a message id' };
|
|
978
|
+
return { ok: true, messageId, duplicate: false };
|
|
979
|
+
}
|
|
980
|
+
#oneLine(value) {
|
|
981
|
+
return String(value).replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
982
|
+
}
|
|
983
|
+
async #authorized(request) {
|
|
984
|
+
if (this.#config.accessToken === undefined)
|
|
985
|
+
return true;
|
|
986
|
+
const expected = await this.#ports?.resolveSecret(this.#config.accessToken);
|
|
987
|
+
if (expected === undefined || expected.length === 0)
|
|
988
|
+
return false;
|
|
989
|
+
const header = request.headers.authorization;
|
|
990
|
+
if (typeof header !== 'string')
|
|
991
|
+
return false;
|
|
992
|
+
const match = /^(?:Bearer|Token)\s+(.+)$/i.exec(header.trim());
|
|
993
|
+
if (match === null)
|
|
994
|
+
return false;
|
|
995
|
+
const offered = Buffer.from(match[1], 'utf8');
|
|
996
|
+
const secret = Buffer.from(expected, 'utf8');
|
|
997
|
+
return offered.length === secret.length && timingSafeEqual(offered, secret);
|
|
998
|
+
}
|
|
999
|
+
async #handle(request, response) {
|
|
1000
|
+
if (this.#config.status !== false &&
|
|
1001
|
+
answerStatus(request, response, {
|
|
1002
|
+
route: ONEBOT_STATUS_ROUTE,
|
|
1003
|
+
report: () => this.status(),
|
|
1004
|
+
onRefused: () => this.#stats.unknownPath(),
|
|
1005
|
+
})) {
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
const url = request.url ?? '/';
|
|
1009
|
+
if (request.method !== 'POST' || url !== this.#config.listen?.path) {
|
|
1010
|
+
this.#stats.unknownPath();
|
|
1011
|
+
response.writeHead(404, { 'content-type': 'application/json' });
|
|
1012
|
+
response.end('{"status":"failed","retcode":1404,"message":"not found"}');
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
if (!(await this.#authorized(request))) {
|
|
1016
|
+
this.#stats.authFailure();
|
|
1017
|
+
this.#stats.rejected('unauthorized');
|
|
1018
|
+
response.writeHead(401, { 'content-type': 'application/json' });
|
|
1019
|
+
response.end('{"status":"failed","retcode":1403,"message":"unauthorized"}');
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const raw = await this.#readBody(request);
|
|
1023
|
+
if (raw === undefined) {
|
|
1024
|
+
response.writeHead(413, { 'content-type': 'application/json' });
|
|
1025
|
+
response.end('{"status":"failed","retcode":1404,"message":"body too large"}');
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
let event;
|
|
1029
|
+
try {
|
|
1030
|
+
event = JSON.parse(raw);
|
|
1031
|
+
}
|
|
1032
|
+
catch {
|
|
1033
|
+
response.writeHead(400, { 'content-type': 'application/json' });
|
|
1034
|
+
response.end('{"status":"failed","retcode":1400,"message":"body must be json"}');
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
// OneBot implementations resend a non-2xx answer, so every event is accepted or dropped here, not refused.
|
|
1038
|
+
response.writeHead(204);
|
|
1039
|
+
response.end();
|
|
1040
|
+
await this.#acceptEvent(event);
|
|
1041
|
+
}
|
|
1042
|
+
/** Turns one OneBot event into a message and hands it to core, whatever carried it. */
|
|
1043
|
+
async #acceptEvent(event) {
|
|
1044
|
+
const message = mapOneBotEvent(event, this.#config, (messageId) => this.#chainRoot.get(messageId), this.#warn, this.#now);
|
|
1045
|
+
if (message !== undefined &&
|
|
1046
|
+
this.#dedupe.claim(`${message.source.scope}:${message.replyTarget}:${message.requestId}`)) {
|
|
1047
|
+
this.#warn?.(`dsbot-channel: kept a repeated onebot event ${message.requestId} from ${message.replyTarget} out of a turn`);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
if (message !== undefined && (message.kind ?? 'message') === 'message' && message.body.replyTo !== null) {
|
|
1051
|
+
const recent = this.#recentSent.get(message.sessionKey);
|
|
1052
|
+
if (recent !== undefined && recent.includes(message.body.replyTo))
|
|
1053
|
+
message.replyToMe = true;
|
|
1054
|
+
}
|
|
1055
|
+
const code = oneBotEventClass(event);
|
|
1056
|
+
if (code !== undefined)
|
|
1057
|
+
this.#stats.event(code);
|
|
1058
|
+
if (message === undefined)
|
|
1059
|
+
return;
|
|
1060
|
+
this.#rememberChain(message.requestId, message.chainId);
|
|
1061
|
+
const ports = this.#ports;
|
|
1062
|
+
if (ports === undefined)
|
|
1063
|
+
return;
|
|
1064
|
+
try {
|
|
1065
|
+
const verdict = await ports.onMessage(message);
|
|
1066
|
+
if (!verdict.ok) {
|
|
1067
|
+
this.#warn?.(`dsbot-channel: kept ${message.requestId} from ${message.replyTarget} out of a turn: ${verdict.code} ${verdict.reason}`);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
catch (error) {
|
|
1071
|
+
this.#warn?.(`dsbot-channel: handing off ${message.requestId} failed: ${String(error)}`);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
/** Hosts a socket the implementation dials, and answers its actions on that socket. */
|
|
1075
|
+
async #startReverse(ports) {
|
|
1076
|
+
const listen = this.#config.listen;
|
|
1077
|
+
if (listen === undefined) {
|
|
1078
|
+
throw new ConfigError('transport.listen is required for ws-reverse', ['adapters', 'transport', 'listen']);
|
|
1079
|
+
}
|
|
1080
|
+
const token = this.#config.accessToken === undefined ? undefined : await ports.resolveSecret(this.#config.accessToken);
|
|
1081
|
+
if (this.#config.accessToken !== undefined && token === undefined) {
|
|
1082
|
+
throw new ConfigError('transport.accessToken names a credential that did not resolve', [
|
|
1083
|
+
'adapters',
|
|
1084
|
+
'transport',
|
|
1085
|
+
'accessToken',
|
|
1086
|
+
]);
|
|
1087
|
+
}
|
|
1088
|
+
this.#ports = ports;
|
|
1089
|
+
const link = new ReverseLink({
|
|
1090
|
+
path: listen.path,
|
|
1091
|
+
token,
|
|
1092
|
+
...(this.#config.selfId === undefined ? {} : { selfId: this.#config.selfId }),
|
|
1093
|
+
maxFrameBytes: ONEBOT_MAX_BODY_BYTES,
|
|
1094
|
+
timeoutMs: this.#config.sendTimeoutMs,
|
|
1095
|
+
...(this.#config.heartbeat === undefined ? {} : { heartbeat: this.#config.heartbeat }),
|
|
1096
|
+
onEvent: (frame) => {
|
|
1097
|
+
void this.#acceptEvent(frame);
|
|
1098
|
+
},
|
|
1099
|
+
onMismatch: () => this.#stats.protocolMismatch(),
|
|
1100
|
+
onAuthFailure: () => this.#stats.authFailure(),
|
|
1101
|
+
onUnknownPath: () => this.#stats.unknownPath(),
|
|
1102
|
+
warn: (message) => this.#warn?.(message),
|
|
1103
|
+
});
|
|
1104
|
+
this.#reverse = link;
|
|
1105
|
+
const server = createServer((request, response) => {
|
|
1106
|
+
if (this.#config.status !== false &&
|
|
1107
|
+
answerStatus(request, response, {
|
|
1108
|
+
route: ONEBOT_STATUS_ROUTE,
|
|
1109
|
+
report: () => this.status(),
|
|
1110
|
+
onRefused: () => this.#stats.unknownPath(),
|
|
1111
|
+
})) {
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
this.#stats.unknownPath();
|
|
1115
|
+
response.writeHead(404, { 'content-type': 'application/json' });
|
|
1116
|
+
response.end('{"status":"failed","retcode":1404,"message":"not found"}');
|
|
1117
|
+
});
|
|
1118
|
+
link.attach(server);
|
|
1119
|
+
await new Promise((resolve, reject) => {
|
|
1120
|
+
server.once('error', reject);
|
|
1121
|
+
server.listen(listen.port, listen.host, () => {
|
|
1122
|
+
server.off('error', reject);
|
|
1123
|
+
resolve();
|
|
1124
|
+
});
|
|
1125
|
+
}).catch((error) => {
|
|
1126
|
+
this.#stats.failed(error instanceof Error ? error.message : String(error));
|
|
1127
|
+
throw error;
|
|
1128
|
+
});
|
|
1129
|
+
this.#server = server;
|
|
1130
|
+
this.#stats.listening(server.address().port);
|
|
1131
|
+
return {
|
|
1132
|
+
stop: async () => {
|
|
1133
|
+
link.close();
|
|
1134
|
+
this.#reverse = undefined;
|
|
1135
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
1136
|
+
this.#stats.closed();
|
|
1137
|
+
},
|
|
1138
|
+
address: () => ({ port: server.address().port }),
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
/** Dials the implementation's socket and hands what arrives to the same event path. */
|
|
1142
|
+
async #startForward(ports) {
|
|
1143
|
+
const api = this.#config.api;
|
|
1144
|
+
if (api === undefined) {
|
|
1145
|
+
throw new ConfigError('transport.api is required for ws-forward', ['adapters', 'transport', 'api']);
|
|
1146
|
+
}
|
|
1147
|
+
const token = this.#config.accessToken === undefined ? undefined : await ports.resolveSecret(this.#config.accessToken);
|
|
1148
|
+
if (this.#config.accessToken !== undefined && token === undefined) {
|
|
1149
|
+
throw new ConfigError('transport.accessToken names a credential that did not resolve', [
|
|
1150
|
+
'adapters',
|
|
1151
|
+
'transport',
|
|
1152
|
+
'accessToken',
|
|
1153
|
+
]);
|
|
1154
|
+
}
|
|
1155
|
+
this.#ports = ports;
|
|
1156
|
+
const link = new ForwardLink({
|
|
1157
|
+
apiUrl: api.url,
|
|
1158
|
+
...(this.#config.event === undefined ? {} : { eventUrl: this.#config.event.url }),
|
|
1159
|
+
token,
|
|
1160
|
+
reconnect: this.#config.reconnect ?? { initialMs: 500, maxMs: 30_000, factor: 2 },
|
|
1161
|
+
...(this.#config.heartbeat === undefined ? {} : { heartbeat: this.#config.heartbeat }),
|
|
1162
|
+
timeoutMs: this.#config.sendTimeoutMs,
|
|
1163
|
+
maxFrameBytes: ONEBOT_MAX_BODY_BYTES,
|
|
1164
|
+
onEvent: (frame) => {
|
|
1165
|
+
void this.#acceptEvent(frame);
|
|
1166
|
+
},
|
|
1167
|
+
onMismatch: () => this.#stats.protocolMismatch(),
|
|
1168
|
+
warn: (message) => this.#warn?.(message),
|
|
1169
|
+
});
|
|
1170
|
+
this.#forward = link;
|
|
1171
|
+
link.start();
|
|
1172
|
+
return {
|
|
1173
|
+
stop: async () => {
|
|
1174
|
+
link.close();
|
|
1175
|
+
this.#forward = undefined;
|
|
1176
|
+
},
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
#readBody(request) {
|
|
1180
|
+
return new Promise((resolve) => {
|
|
1181
|
+
const chunks = [];
|
|
1182
|
+
let size = 0;
|
|
1183
|
+
let settled = false;
|
|
1184
|
+
const settle = (value) => {
|
|
1185
|
+
if (settled)
|
|
1186
|
+
return;
|
|
1187
|
+
settled = true;
|
|
1188
|
+
resolve(value);
|
|
1189
|
+
};
|
|
1190
|
+
request.on('data', (chunk) => {
|
|
1191
|
+
size += chunk.length;
|
|
1192
|
+
if (size > ONEBOT_MAX_BODY_BYTES) {
|
|
1193
|
+
chunks.length = 0;
|
|
1194
|
+
settle(undefined);
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
chunks.push(chunk);
|
|
1198
|
+
});
|
|
1199
|
+
request.on('end', () => settle(Buffer.concat(chunks).toString('utf8')));
|
|
1200
|
+
request.on('error', () => settle(undefined));
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
}
|