koishi-plugin-dynamic-bot 0.0.3 → 0.0.5
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/lib/dbk/send.d.ts +1 -1
- package/lib/index.js +4767 -135
- package/package.json +2 -3
- package/src/dbk/send.ts +126 -27
package/package.json
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
"type": "git",
|
|
5
5
|
"url": "https://github.com/incubator4/dynamic-bot-koishi"
|
|
6
6
|
},
|
|
7
|
-
"
|
|
8
|
-
"version": "0.0.3",
|
|
7
|
+
"version": "0.0.5",
|
|
9
8
|
"license": "Apache-2.0",
|
|
10
9
|
"contributors": [
|
|
11
10
|
"AresWang <incubator4@outlook.com>"
|
|
@@ -44,7 +43,7 @@
|
|
|
44
43
|
},
|
|
45
44
|
"scripts": {
|
|
46
45
|
"build": "pnpm build:js && pnpm build:dts",
|
|
47
|
-
"build:js": "esbuild src/index.ts --bundle --platform=node --format=
|
|
46
|
+
"build:js": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=lib/index.js --external:koishi",
|
|
48
47
|
"build:dts": "tsc -p tsconfig.json"
|
|
49
48
|
},
|
|
50
49
|
"typings": "lib/index.d.ts"
|
package/src/dbk/send.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { create } from "@bufbuild/protobuf";
|
|
2
2
|
import { h, type Bot, type Context } from "koishi";
|
|
3
|
+
import type {} from "@koishijs/plugin-http";
|
|
3
4
|
import { BotStatus, ErrorCode, SendStatus } from "../gen/dbk/v1/common_pb";
|
|
4
5
|
import {
|
|
5
6
|
SendReceiptSchema,
|
|
@@ -15,6 +16,11 @@ import { DbkRpcError } from "./error";
|
|
|
15
16
|
|
|
16
17
|
const SEND_TIMEOUT_MS = 25_000;
|
|
17
18
|
const MENTION_ALL_FALLBACK = "@全体成员";
|
|
19
|
+
const MEDIA_FALLBACK_TYPE = {
|
|
20
|
+
image: "image/png",
|
|
21
|
+
video: "video/mp4",
|
|
22
|
+
audio: "audio/mpeg",
|
|
23
|
+
} as const;
|
|
18
24
|
|
|
19
25
|
type El = ReturnType<typeof h>;
|
|
20
26
|
|
|
@@ -101,11 +107,29 @@ export async function sendMessage(ctx: Context, request: SendParams): Promise<Se
|
|
|
101
107
|
}
|
|
102
108
|
};
|
|
103
109
|
|
|
110
|
+
const sendRendered = async (render: Promise<El[]>): Promise<void> => {
|
|
111
|
+
if (aborted) return;
|
|
112
|
+
let elements: El[];
|
|
113
|
+
try {
|
|
114
|
+
elements = await render;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (aborted) return;
|
|
117
|
+
if (isTimeoutError(error)) {
|
|
118
|
+
unknownReasons.push(errorMessage(error) || "media fetch timed out");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
failuresRetryable = failuresRetryable && isRetryableSendError(error);
|
|
122
|
+
failures.push(errorMessage(error) || "media fetch failed");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await sendElements(elements);
|
|
126
|
+
};
|
|
127
|
+
|
|
104
128
|
const work = (async (): Promise<SendResult> => {
|
|
105
129
|
for (const unit of request.units) {
|
|
106
130
|
if (aborted) break;
|
|
107
131
|
if (unit.body.case === "normal") {
|
|
108
|
-
await
|
|
132
|
+
await sendRendered(segmentsToElements(ctx, unit.body.value.segments, { mentionAll }));
|
|
109
133
|
} else if (unit.body.case === "forward") {
|
|
110
134
|
// v1: never emit a single merged-forward receipt. Always one send per node.
|
|
111
135
|
const nodes = unit.body.value.nodes;
|
|
@@ -116,7 +140,7 @@ export async function sendMessage(ctx: Context, request: SendParams): Promise<Se
|
|
|
116
140
|
}
|
|
117
141
|
for (const node of nodes) {
|
|
118
142
|
if (aborted) break;
|
|
119
|
-
await
|
|
143
|
+
await sendRendered(forwardNodeElements(ctx, node, { mentionAll }));
|
|
120
144
|
}
|
|
121
145
|
} else {
|
|
122
146
|
failuresRetryable = false;
|
|
@@ -148,7 +172,11 @@ export async function sendMessage(ctx: Context, request: SendParams): Promise<Se
|
|
|
148
172
|
}
|
|
149
173
|
}
|
|
150
174
|
|
|
151
|
-
export function segmentsToElements(
|
|
175
|
+
export async function segmentsToElements(
|
|
176
|
+
ctx: Context,
|
|
177
|
+
segments: Segment[],
|
|
178
|
+
options?: SegmentRenderOptions,
|
|
179
|
+
): Promise<El[]> {
|
|
152
180
|
const elements: El[] = [];
|
|
153
181
|
const quoted = new Set<string>();
|
|
154
182
|
|
|
@@ -169,17 +197,17 @@ export function segmentsToElements(segments: Segment[], options?: SegmentRenderO
|
|
|
169
197
|
}
|
|
170
198
|
case "image": {
|
|
171
199
|
const uri = segment.body.value.uri.trim();
|
|
172
|
-
if (uri) elements.push(
|
|
200
|
+
if (uri) elements.push(await fetchMediaElement(ctx, "image", uri));
|
|
173
201
|
break;
|
|
174
202
|
}
|
|
175
203
|
case "video": {
|
|
176
204
|
const uri = segment.body.value.uri.trim();
|
|
177
|
-
if (uri) elements.push(
|
|
205
|
+
if (uri) elements.push(await fetchMediaElement(ctx, "video", uri));
|
|
178
206
|
break;
|
|
179
207
|
}
|
|
180
208
|
case "audio": {
|
|
181
209
|
const uri = segment.body.value.uri.trim();
|
|
182
|
-
if (uri) elements.push(
|
|
210
|
+
if (uri) elements.push(await fetchMediaElement(ctx, "audio", uri));
|
|
183
211
|
break;
|
|
184
212
|
}
|
|
185
213
|
case "mention": {
|
|
@@ -215,13 +243,65 @@ export function segmentsToElements(segments: Segment[], options?: SegmentRenderO
|
|
|
215
243
|
return elements;
|
|
216
244
|
}
|
|
217
245
|
|
|
218
|
-
function forwardNodeElements(
|
|
219
|
-
|
|
246
|
+
async function forwardNodeElements(
|
|
247
|
+
ctx: Context,
|
|
248
|
+
node: ForwardNode,
|
|
249
|
+
options?: SegmentRenderOptions,
|
|
250
|
+
): Promise<El[]> {
|
|
251
|
+
const body = await segmentsToElements(ctx, node.segments, options);
|
|
220
252
|
const name = node.senderName.trim();
|
|
221
253
|
if (!name) return body;
|
|
222
254
|
return [h.text(`${name}\n`), ...body];
|
|
223
255
|
}
|
|
224
256
|
|
|
257
|
+
type MediaKind = keyof typeof MEDIA_FALLBACK_TYPE;
|
|
258
|
+
|
|
259
|
+
async function fetchMediaElement(ctx: Context, kind: MediaKind, uri: string): Promise<El> {
|
|
260
|
+
const http = ctx.http;
|
|
261
|
+
if (typeof http?.file !== "function") {
|
|
262
|
+
throw mediaFetchError(kind, uri, new Error("Koishi http service is required to send media"));
|
|
263
|
+
}
|
|
264
|
+
let file: { data?: unknown; mime?: string; type?: string };
|
|
265
|
+
try {
|
|
266
|
+
file = await http.file(uri);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
throw mediaFetchError(kind, uri, error);
|
|
269
|
+
}
|
|
270
|
+
let buffer: Buffer;
|
|
271
|
+
try {
|
|
272
|
+
buffer = mediaBytes(file.data);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
throw mediaFetchError(kind, uri, error);
|
|
275
|
+
}
|
|
276
|
+
if (buffer.length === 0) {
|
|
277
|
+
throw mediaFetchError(kind, uri, new Error("empty body"));
|
|
278
|
+
}
|
|
279
|
+
const mime = (file.mime ?? file.type ?? "").trim() || MEDIA_FALLBACK_TYPE[kind];
|
|
280
|
+
if (kind === "image") return h.image(buffer, mime);
|
|
281
|
+
if (kind === "video") return h.video(buffer, mime);
|
|
282
|
+
return h.audio(buffer, mime);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function mediaBytes(data: unknown): Buffer {
|
|
286
|
+
if (Buffer.isBuffer(data)) return data;
|
|
287
|
+
if (data instanceof ArrayBuffer) return Buffer.from(data);
|
|
288
|
+
if (ArrayBuffer.isView(data)) {
|
|
289
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
290
|
+
}
|
|
291
|
+
throw new Error("media body is not binary");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function mediaFetchError(kind: MediaKind, uri: string, cause: unknown): Error {
|
|
295
|
+
const error = new Error(`failed to fetch ${kind} ${displayUri(uri)}: ${errorMessage(cause) || "unknown error"}`);
|
|
296
|
+
error.cause = cause;
|
|
297
|
+
return error;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function displayUri(uri: string): string {
|
|
301
|
+
if (uri.startsWith("data:")) return "data:...";
|
|
302
|
+
return uri.length > 200 ? `${uri.slice(0, 200)}...` : uri;
|
|
303
|
+
}
|
|
304
|
+
|
|
225
305
|
function withQuote(elements: El[], quoteId: string | undefined): El[] {
|
|
226
306
|
if (!quoteId) return elements;
|
|
227
307
|
if (hasQuote(elements, quoteId)) return elements;
|
|
@@ -297,30 +377,35 @@ function failed(reason: string, retryable: boolean): SendResult {
|
|
|
297
377
|
}
|
|
298
378
|
|
|
299
379
|
function isRetryableSendError(error: unknown): boolean {
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
380
|
+
for (const item of errorChain(error)) {
|
|
381
|
+
const text = errorMessage(item);
|
|
382
|
+
const lower = text.toLowerCase();
|
|
383
|
+
const status = httpStatusOf(item);
|
|
384
|
+
|
|
385
|
+
if (status === 401 || status === 403 || isForbidden(lower)) return false;
|
|
386
|
+
if (status === 400 || status === 404) return false;
|
|
387
|
+
if (status === 429 || (status !== undefined && status >= 500)) return true;
|
|
388
|
+
if (isRetryableNetwork(item, lower)) return true;
|
|
389
|
+
if (/\brate.?limit\b|too many requests/.test(lower)) return true;
|
|
390
|
+
}
|
|
309
391
|
return false;
|
|
310
392
|
}
|
|
311
393
|
|
|
312
394
|
function isTimeoutError(error: unknown): boolean {
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
395
|
+
for (const item of errorChain(error)) {
|
|
396
|
+
const text = errorMessage(item).toLowerCase();
|
|
397
|
+
const status = httpStatusOf(item);
|
|
398
|
+
if (item instanceof SendTimeoutError) return true;
|
|
399
|
+
if (status === 408 || status === 504) return true;
|
|
400
|
+
if (item && typeof item === "object") {
|
|
401
|
+
const name = (item as { name?: unknown }).name;
|
|
402
|
+
if (name === "TimeoutError" || name === "AbortError") return true;
|
|
403
|
+
const code = (item as { code?: unknown }).code;
|
|
404
|
+
if (code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR") return true;
|
|
405
|
+
}
|
|
406
|
+
if (text.includes("timed out") || text.includes("timeout")) return true;
|
|
322
407
|
}
|
|
323
|
-
return
|
|
408
|
+
return false;
|
|
324
409
|
}
|
|
325
410
|
|
|
326
411
|
function httpStatusOf(error: unknown): number | undefined {
|
|
@@ -337,6 +422,20 @@ function httpStatusOf(error: unknown): number | undefined {
|
|
|
337
422
|
return undefined;
|
|
338
423
|
}
|
|
339
424
|
|
|
425
|
+
function errorChain(error: unknown): unknown[] {
|
|
426
|
+
const seen = new Set<unknown>();
|
|
427
|
+
const chain: unknown[] = [];
|
|
428
|
+
let current: unknown = error;
|
|
429
|
+
while (current != null && !seen.has(current) && chain.length < 4) {
|
|
430
|
+
seen.add(current);
|
|
431
|
+
chain.push(current);
|
|
432
|
+
current = typeof current === "object" && "cause" in current
|
|
433
|
+
? (current as { cause?: unknown }).cause
|
|
434
|
+
: undefined;
|
|
435
|
+
}
|
|
436
|
+
return chain;
|
|
437
|
+
}
|
|
438
|
+
|
|
340
439
|
function errorMessage(error: unknown): string {
|
|
341
440
|
if (error instanceof Error) return error.message.trim();
|
|
342
441
|
if (typeof error === "string") return error.trim();
|