koishi-plugin-dynamic-bot 0.0.4 → 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 CHANGED
@@ -12,5 +12,5 @@ export interface SegmentRenderOptions {
12
12
  mentionAll?: boolean;
13
13
  }
14
14
  export declare function sendMessage(ctx: Context, request: SendParams): Promise<SendResult>;
15
- export declare function segmentsToElements(segments: Segment[], options?: SegmentRenderOptions): El[];
15
+ export declare function segmentsToElements(ctx: Context, segments: Segment[], options?: SegmentRenderOptions): Promise<El[]>;
16
16
  export {};
package/lib/index.js CHANGED
@@ -6086,6 +6086,11 @@ function withTimeout(promise, ms) {
6086
6086
  var import_koishi = require("koishi");
6087
6087
  var SEND_TIMEOUT_MS = 25e3;
6088
6088
  var MENTION_ALL_FALLBACK = "@\u5168\u4F53\u6210\u5458";
6089
+ var MEDIA_FALLBACK_TYPE = {
6090
+ image: "image/png",
6091
+ video: "video/mp4",
6092
+ audio: "audio/mpeg"
6093
+ };
6089
6094
  async function sendMessage(ctx, request) {
6090
6095
  const bot = findBot4(ctx, request.botKey);
6091
6096
  if (!bot) {
@@ -6152,11 +6157,28 @@ async function sendMessage(ctx, request) {
6152
6157
  failures.push(errorMessage2(error) || "send failed");
6153
6158
  }
6154
6159
  };
6160
+ const sendRendered = async (render) => {
6161
+ if (aborted) return;
6162
+ let elements;
6163
+ try {
6164
+ elements = await render;
6165
+ } catch (error) {
6166
+ if (aborted) return;
6167
+ if (isTimeoutError(error)) {
6168
+ unknownReasons.push(errorMessage2(error) || "media fetch timed out");
6169
+ return;
6170
+ }
6171
+ failuresRetryable = failuresRetryable && isRetryableSendError(error);
6172
+ failures.push(errorMessage2(error) || "media fetch failed");
6173
+ return;
6174
+ }
6175
+ await sendElements(elements);
6176
+ };
6155
6177
  const work = (async () => {
6156
6178
  for (const unit of request.units) {
6157
6179
  if (aborted) break;
6158
6180
  if (unit.body.case === "normal") {
6159
- await sendElements(segmentsToElements(unit.body.value.segments, { mentionAll }));
6181
+ await sendRendered(segmentsToElements(ctx, unit.body.value.segments, { mentionAll }));
6160
6182
  } else if (unit.body.case === "forward") {
6161
6183
  const nodes = unit.body.value.nodes;
6162
6184
  if (nodes.length === 0) {
@@ -6166,7 +6188,7 @@ async function sendMessage(ctx, request) {
6166
6188
  }
6167
6189
  for (const node of nodes) {
6168
6190
  if (aborted) break;
6169
- await sendElements(forwardNodeElements(node, { mentionAll }));
6191
+ await sendRendered(forwardNodeElements(ctx, node, { mentionAll }));
6170
6192
  }
6171
6193
  } else {
6172
6194
  failuresRetryable = false;
@@ -6196,7 +6218,7 @@ async function sendMessage(ctx, request) {
6196
6218
  });
6197
6219
  }
6198
6220
  }
6199
- function segmentsToElements(segments, options) {
6221
+ async function segmentsToElements(ctx, segments, options) {
6200
6222
  const elements = [];
6201
6223
  const quoted = /* @__PURE__ */ new Set();
6202
6224
  const addQuote = (id) => {
@@ -6214,17 +6236,17 @@ function segmentsToElements(segments, options) {
6214
6236
  }
6215
6237
  case "image": {
6216
6238
  const uri = segment.body.value.uri.trim();
6217
- if (uri) elements.push(import_koishi.h.image(uri));
6239
+ if (uri) elements.push(await fetchMediaElement(ctx, "image", uri));
6218
6240
  break;
6219
6241
  }
6220
6242
  case "video": {
6221
6243
  const uri = segment.body.value.uri.trim();
6222
- if (uri) elements.push(import_koishi.h.video(uri));
6244
+ if (uri) elements.push(await fetchMediaElement(ctx, "video", uri));
6223
6245
  break;
6224
6246
  }
6225
6247
  case "audio": {
6226
6248
  const uri = segment.body.value.uri.trim();
6227
- if (uri) elements.push(import_koishi.h.audio(uri));
6249
+ if (uri) elements.push(await fetchMediaElement(ctx, "audio", uri));
6228
6250
  break;
6229
6251
  }
6230
6252
  case "mention": {
@@ -6258,13 +6280,55 @@ function segmentsToElements(segments, options) {
6258
6280
  }
6259
6281
  return elements;
6260
6282
  }
6261
- function forwardNodeElements(node, options) {
6262
- const body = segmentsToElements(node.segments, options);
6283
+ async function forwardNodeElements(ctx, node, options) {
6284
+ const body = await segmentsToElements(ctx, node.segments, options);
6263
6285
  const name2 = node.senderName.trim();
6264
6286
  if (!name2) return body;
6265
6287
  return [import_koishi.h.text(`${name2}
6266
6288
  `), ...body];
6267
6289
  }
6290
+ async function fetchMediaElement(ctx, kind, uri) {
6291
+ const http = ctx.http;
6292
+ if (typeof http?.file !== "function") {
6293
+ throw mediaFetchError(kind, uri, new Error("Koishi http service is required to send media"));
6294
+ }
6295
+ let file;
6296
+ try {
6297
+ file = await http.file(uri);
6298
+ } catch (error) {
6299
+ throw mediaFetchError(kind, uri, error);
6300
+ }
6301
+ let buffer;
6302
+ try {
6303
+ buffer = mediaBytes(file.data);
6304
+ } catch (error) {
6305
+ throw mediaFetchError(kind, uri, error);
6306
+ }
6307
+ if (buffer.length === 0) {
6308
+ throw mediaFetchError(kind, uri, new Error("empty body"));
6309
+ }
6310
+ const mime = (file.mime ?? file.type ?? "").trim() || MEDIA_FALLBACK_TYPE[kind];
6311
+ if (kind === "image") return import_koishi.h.image(buffer, mime);
6312
+ if (kind === "video") return import_koishi.h.video(buffer, mime);
6313
+ return import_koishi.h.audio(buffer, mime);
6314
+ }
6315
+ function mediaBytes(data) {
6316
+ if (Buffer.isBuffer(data)) return data;
6317
+ if (data instanceof ArrayBuffer) return Buffer.from(data);
6318
+ if (ArrayBuffer.isView(data)) {
6319
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
6320
+ }
6321
+ throw new Error("media body is not binary");
6322
+ }
6323
+ function mediaFetchError(kind, uri, cause) {
6324
+ const error = new Error(`failed to fetch ${kind} ${displayUri(uri)}: ${errorMessage2(cause) || "unknown error"}`);
6325
+ error.cause = cause;
6326
+ return error;
6327
+ }
6328
+ function displayUri(uri) {
6329
+ if (uri.startsWith("data:")) return "data:...";
6330
+ return uri.length > 200 ? `${uri.slice(0, 200)}...` : uri;
6331
+ }
6268
6332
  function withQuote(elements, quoteId) {
6269
6333
  if (!quoteId) return elements;
6270
6334
  if (hasQuote(elements, quoteId)) return elements;
@@ -6328,28 +6392,33 @@ function failed2(reason, retryable) {
6328
6392
  });
6329
6393
  }
6330
6394
  function isRetryableSendError(error) {
6331
- const text = errorMessage2(error);
6332
- const lower = text.toLowerCase();
6333
- const status = httpStatusOf2(error);
6334
- if (status === 401 || status === 403 || isForbidden2(lower)) return false;
6335
- if (status === 400 || status === 404) return false;
6336
- if (status === 429 || status !== void 0 && status >= 500) return true;
6337
- if (isRetryableNetwork2(error, lower)) return true;
6338
- if (/\brate.?limit\b|too many requests/.test(lower)) return true;
6395
+ for (const item of errorChain(error)) {
6396
+ const text = errorMessage2(item);
6397
+ const lower = text.toLowerCase();
6398
+ const status = httpStatusOf2(item);
6399
+ if (status === 401 || status === 403 || isForbidden2(lower)) return false;
6400
+ if (status === 400 || status === 404) return false;
6401
+ if (status === 429 || status !== void 0 && status >= 500) return true;
6402
+ if (isRetryableNetwork2(item, lower)) return true;
6403
+ if (/\brate.?limit\b|too many requests/.test(lower)) return true;
6404
+ }
6339
6405
  return false;
6340
6406
  }
6341
6407
  function isTimeoutError(error) {
6342
- const text = errorMessage2(error).toLowerCase();
6343
- const status = httpStatusOf2(error);
6344
- if (error instanceof SendTimeoutError) return true;
6345
- if (status === 408 || status === 504) return true;
6346
- if (error && typeof error === "object") {
6347
- const name2 = error.name;
6348
- if (name2 === "TimeoutError" || name2 === "AbortError") return true;
6349
- const code = error.code;
6350
- if (code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR") return true;
6408
+ for (const item of errorChain(error)) {
6409
+ const text = errorMessage2(item).toLowerCase();
6410
+ const status = httpStatusOf2(item);
6411
+ if (item instanceof SendTimeoutError) return true;
6412
+ if (status === 408 || status === 504) return true;
6413
+ if (item && typeof item === "object") {
6414
+ const name2 = item.name;
6415
+ if (name2 === "TimeoutError" || name2 === "AbortError") return true;
6416
+ const code = item.code;
6417
+ if (code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR") return true;
6418
+ }
6419
+ if (text.includes("timed out") || text.includes("timeout")) return true;
6351
6420
  }
6352
- return text.includes("timed out") || text.includes("timeout");
6421
+ return false;
6353
6422
  }
6354
6423
  function httpStatusOf2(error) {
6355
6424
  if (!error || typeof error !== "object") return void 0;
@@ -6364,6 +6433,17 @@ function httpStatusOf2(error) {
6364
6433
  if (typeof record.code === "number" && record.code >= 400 && record.code < 600) return record.code;
6365
6434
  return void 0;
6366
6435
  }
6436
+ function errorChain(error) {
6437
+ const seen = /* @__PURE__ */ new Set();
6438
+ const chain = [];
6439
+ let current = error;
6440
+ while (current != null && !seen.has(current) && chain.length < 4) {
6441
+ seen.add(current);
6442
+ chain.push(current);
6443
+ current = typeof current === "object" && "cause" in current ? current.cause : void 0;
6444
+ }
6445
+ return chain;
6446
+ }
6367
6447
  function errorMessage2(error) {
6368
6448
  if (error instanceof Error) return error.message.trim();
6369
6449
  if (typeof error === "string") return error.trim();
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "type": "git",
5
5
  "url": "https://github.com/incubator4/dynamic-bot-koishi"
6
6
  },
7
- "version": "0.0.4",
7
+ "version": "0.0.5",
8
8
  "license": "Apache-2.0",
9
9
  "contributors": [
10
10
  "AresWang <incubator4@outlook.com>"
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();