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/package.json CHANGED
@@ -4,8 +4,7 @@
4
4
  "type": "git",
5
5
  "url": "https://github.com/incubator4/dynamic-bot-koishi"
6
6
  },
7
- "type": "module",
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=esm --outfile=lib/index.js --packages=external",
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 sendElements(segmentsToElements(unit.body.value.segments, { mentionAll }));
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 sendElements(forwardNodeElements(node, { mentionAll }));
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(segments: Segment[], options?: SegmentRenderOptions): El[] {
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(h.image(uri));
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(h.video(uri));
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(h.audio(uri));
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(node: ForwardNode, options?: SegmentRenderOptions): El[] {
219
- const body = segmentsToElements(node.segments, options);
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 text = errorMessage(error);
301
- const lower = text.toLowerCase();
302
- const status = httpStatusOf(error);
303
-
304
- if (status === 401 || status === 403 || isForbidden(lower)) return false;
305
- if (status === 400 || status === 404) return false;
306
- if (status === 429 || (status !== undefined && status >= 500)) return true;
307
- if (isRetryableNetwork(error, lower)) return true;
308
- if (/\brate.?limit\b|too many requests/.test(lower)) return true;
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 text = errorMessage(error).toLowerCase();
314
- const status = httpStatusOf(error);
315
- if (error instanceof SendTimeoutError) return true;
316
- if (status === 408 || status === 504) return true;
317
- if (error && typeof error === "object") {
318
- const name = (error as { name?: unknown }).name;
319
- if (name === "TimeoutError" || name === "AbortError") return true;
320
- const code = (error as { code?: unknown }).code;
321
- if (code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR") return true;
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 text.includes("timed out") || text.includes("timeout");
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();