@xmanrui/dsh-im 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,14 +32,11 @@ import {
32
32
  prefetchInboundFiles,
33
33
  } from '../shared/inbound-file.mjs';
34
34
  import {
35
- materializeOutboundArtifact,
36
- releaseOutboundArtifact,
37
35
  trackOutboundArtifactProviderPromise,
38
36
  } from '../shared/semantic/artifact.mjs';
37
+ import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
39
38
  import {
40
- createArtifactFailureReceipt,
41
39
  createDeliveryReceipt,
42
- mergeDeliveryReceipts,
43
40
  providerMessageIdsFor,
44
41
  } from '../shared/semantic/delivery.mjs';
45
42
  import { sendMarkdownReply } from './markdown-reply.mjs';
@@ -210,7 +207,7 @@ function qqArtifactError(error, { dispatched = false } = {}) {
210
207
  if (status === 401 || status === 403) wrapped.code = 'artifact-permission-required';
211
208
  else if (status === 413) wrapped.code = 'artifact-too-large';
212
209
  else if (status === 429) wrapped.code = 'artifact-rate-limited';
213
- else if (status === 400 || status === 404) {
210
+ else if ([400, 404, 405, 406, 410, 415, 422].includes(status)) {
214
211
  wrapped.code = 'artifact-provider-rejected';
215
212
  } else {
216
213
  wrapped.code = dispatched ? 'artifact-delivery-uncertain' : 'artifact-provider-failed';
@@ -245,6 +242,68 @@ function waitWithSignal(promise, signal) {
245
242
  });
246
243
  }
247
244
 
245
+ /** Send one materialized artifact through QQ's native image message. */
246
+ export async function sendQqImage(
247
+ bot,
248
+ target,
249
+ file,
250
+ { signal, timeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS } = {},
251
+ ) {
252
+ signal?.throwIfAborted();
253
+ if (typeof bot?.sendImage !== 'function') {
254
+ const unavailable = new Error('QQ image delivery is unavailable');
255
+ unavailable.code = 'artifact-provider-unavailable';
256
+ throw unavailable;
257
+ }
258
+
259
+ try {
260
+ const timeout = AbortSignal.timeout(timeoutMs);
261
+ const waitSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
262
+ const pending = bot.sendImage(
263
+ target,
264
+ { buffer: file.bytes },
265
+ { onProgress: () => signal?.throwIfAborted() },
266
+ );
267
+ trackOutboundArtifactProviderPromise(file, pending);
268
+ return await waitWithSignal(pending, waitSignal);
269
+ } catch (error) {
270
+ if (signal?.aborted) throw abortReason(signal);
271
+ throw qqArtifactError(error, { dispatched: true });
272
+ }
273
+ }
274
+
275
+ async function sendQqFile(
276
+ bot,
277
+ target,
278
+ file,
279
+ { signal, timeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS } = {},
280
+ ) {
281
+ signal?.throwIfAborted();
282
+ if (typeof bot?.sendFile !== 'function') {
283
+ const unavailable = new Error('QQ file delivery is unavailable');
284
+ unavailable.code = 'artifact-provider-unavailable';
285
+ throw unavailable;
286
+ }
287
+
288
+ try {
289
+ const timeout = AbortSignal.timeout(timeoutMs);
290
+ const waitSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
291
+ const pending = bot.sendFile(
292
+ target,
293
+ { buffer: file.bytes },
294
+ {
295
+ fileName: file.fileName,
296
+ onProgress: () => signal?.throwIfAborted(),
297
+ },
298
+ );
299
+ trackOutboundArtifactProviderPromise(file, pending);
300
+ return await waitWithSignal(pending, waitSignal);
301
+ } catch (error) {
302
+ if (signal?.aborted) throw abortReason(signal);
303
+ throw qqArtifactError(error, { dispatched: true });
304
+ }
305
+ }
306
+
248
307
  function canClaimInteractionReply(message, pending) {
249
308
  return pending.questions[pending.index]
250
309
  && nonEmptyString(message?.senderId) === pending.actor
@@ -491,80 +550,35 @@ export class QqHarnessBridge {
491
550
  if (artifacts.length === 0) {
492
551
  return { receipt: baseReceipt, failureNoticeVisible: false };
493
552
  }
494
- const receipts = baseReceipt ? [baseReceipt] : [];
495
- let failureNoticeVisible = false;
496
- for (const artifact of artifacts) {
497
- this.#signal?.throwIfAborted();
498
- try {
499
- if (typeof this.#bot.sendFile !== 'function') {
500
- const unavailable = new Error('QQ file delivery is unavailable');
501
- unavailable.code = 'artifact-provider-unavailable';
502
- throw unavailable;
503
- }
504
- const file = await materializeOutboundArtifact(artifact, {
505
- signal: this.#signal,
506
- });
507
- this.#signal?.throwIfAborted();
508
- let result;
509
- try {
510
- const timeout = AbortSignal.timeout(this.#fileUploadTimeoutMs);
511
- const waitSignal = this.#signal ? AbortSignal.any([this.#signal, timeout]) : timeout;
512
- const pending = this.#bot.sendFile(
513
- target,
514
- { buffer: file.bytes },
515
- {
516
- fileName: file.fileName,
517
- onProgress: () => this.#signal?.throwIfAborted(),
518
- },
519
- );
520
- trackOutboundArtifactProviderPromise(file, pending);
521
- result = await waitWithSignal(pending, waitSignal);
522
- } catch (error) {
523
- if (this.#signal?.aborted) throw abortReason(this.#signal);
524
- throw qqArtifactError(error, { dispatched: true });
525
- }
526
- this.#signal?.throwIfAborted();
527
- const messageId = nonEmptyString(result?.message?.id);
528
- receipts.push(createDeliveryReceipt({
529
- deliveryId: file.deliveryKey,
530
- presentation: 'qq-file',
531
- providerMessageIds: messageId ? [messageId] : [],
532
- artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
533
- }));
534
- this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
535
- } catch (rawError) {
536
- if (this.#signal?.aborted) throw rawError;
537
- const error = qqArtifactError(rawError);
538
- this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
539
- this.#logger.warn?.(
540
- `[dsh-im:qq] result file delivery failed (${error?.code ?? error?.name ?? 'unknown'})`,
541
- );
542
- let providerMessageIds = [];
543
- try {
544
- const notice = await this.#bot.sendText(target, artifactFailureText(artifact?.fileName, error));
545
- failureNoticeVisible = true;
546
- providerMessageIds = providerMessageIdsFor(notice);
547
- } catch (noticeError) {
548
- if (this.#signal?.aborted) throw noticeError;
549
- this.#logger.warn?.('[dsh-im:qq] unable to send the safe result-file failure notice');
550
- }
551
- receipts.push(createArtifactFailureReceipt({
552
- artifactId: artifact?.artifactId ?? 'unknown',
553
- deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
554
- error,
555
- providerMessageIds,
556
- }));
557
- } finally {
558
- releaseOutboundArtifact(artifact);
559
- }
560
- }
561
- return {
562
- receipt: mergeDeliveryReceipts({
563
- deliveryId: replyTo,
564
- presentation: baseReceipt ? 'qq-text-and-files' : 'qq-files',
565
- receipts,
553
+ const delivery = await deliverOutboundArtifacts({
554
+ artifacts,
555
+ baseReceipt,
556
+ deliveryId: replyTo,
557
+ aggregatePresentation: baseReceipt ? 'qq-text-and-files' : 'qq-files',
558
+ alwaysMerge: true,
559
+ channelKey: 'qq',
560
+ signal: this.#signal,
561
+ sendImage: (file) => sendQqImage(this.#bot, target, file, {
562
+ signal: this.#signal,
563
+ timeoutMs: this.#fileUploadTimeoutMs,
564
+ }),
565
+ sendFile: (file) => sendQqFile(this.#bot, target, file, {
566
+ signal: this.#signal,
567
+ timeoutMs: this.#fileUploadTimeoutMs,
566
568
  }),
567
- failureNoticeVisible,
569
+ sendFailureNotice: (artifact, error) => this.#bot.sendText(
570
+ target,
571
+ artifactFailureText(artifact?.fileName, error),
572
+ ),
573
+ logger: this.#logger,
574
+ });
575
+ this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
576
+ + delivery.artifactsSent;
577
+ this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
578
+ + delivery.artifactSendErrors;
579
+ return {
580
+ receipt: delivery.receipt,
581
+ failureNoticeVisible: delivery.failureNoticeVisible,
568
582
  };
569
583
  }
570
584
 
@@ -0,0 +1,170 @@
1
+ import {
2
+ materializeOutboundArtifact,
3
+ releaseOutboundArtifact,
4
+ } from './artifact.mjs';
5
+ import {
6
+ createArtifactFailureReceipt,
7
+ createDeliveryReceipt,
8
+ mergeDeliveryReceipts,
9
+ providerMessageIdsFor,
10
+ } from './delivery.mjs';
11
+
12
+ function unavailableError() {
13
+ const error = new Error('Native file delivery is unavailable');
14
+ error.code = 'artifact-provider-unavailable';
15
+ return error;
16
+ }
17
+
18
+ function isAbort(error, signal) {
19
+ return signal?.aborted
20
+ || error?.name === 'AbortError'
21
+ || error?.code === 'ABORT_ERR';
22
+ }
23
+
24
+ function providerIds(value) {
25
+ if (Array.isArray(value)) {
26
+ return [...new Set(value
27
+ .filter((candidate) => (
28
+ (typeof candidate === 'string' && candidate.trim())
29
+ || Number.isSafeInteger(candidate)
30
+ ))
31
+ .map(String))];
32
+ }
33
+ return providerMessageIdsFor(value);
34
+ }
35
+
36
+ async function sendMaterializedArtifact(file, {
37
+ sendFile,
38
+ sendImage,
39
+ signal,
40
+ }) {
41
+ if (file.mediaType?.startsWith('image/') && typeof sendImage === 'function') {
42
+ try {
43
+ return {
44
+ presentation: 'image',
45
+ result: await sendImage(file),
46
+ };
47
+ } catch (error) {
48
+ if (isAbort(error, signal) || error?.code === 'artifact-delivery-uncertain') {
49
+ throw error;
50
+ }
51
+ }
52
+ }
53
+ signal?.throwIfAborted();
54
+ if (typeof sendFile !== 'function') throw unavailableError();
55
+ return {
56
+ presentation: 'file',
57
+ result: await sendFile(file),
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Deliver registered artifacts with one shared image-first policy while keeping
63
+ * provider protocol details inside the channel-supplied send closures.
64
+ */
65
+ export async function deliverOutboundArtifacts({
66
+ artifacts = [],
67
+ baseReceipt = null,
68
+ deliveryId,
69
+ aggregatePresentation,
70
+ alwaysMerge = false,
71
+ channelKey,
72
+ signal,
73
+ sendFile,
74
+ sendImage,
75
+ sendFailureNotice,
76
+ logger,
77
+ }) {
78
+ const receipts = baseReceipt ? [baseReceipt] : [];
79
+ let userVisible = Boolean(baseReceipt);
80
+ let failureNoticeVisible = false;
81
+ let artifactsSent = 0;
82
+ let artifactSendErrors = 0;
83
+
84
+ let artifactIndex = 0;
85
+ try {
86
+ while (artifactIndex < artifacts.length) {
87
+ const artifact = artifacts[artifactIndex];
88
+ artifactIndex += 1;
89
+ try {
90
+ signal?.throwIfAborted();
91
+ const file = await materializeOutboundArtifact(artifact, { signal });
92
+ signal?.throwIfAborted();
93
+ const sent = await sendMaterializedArtifact(file, {
94
+ sendFile,
95
+ sendImage,
96
+ signal,
97
+ });
98
+ signal?.throwIfAborted();
99
+ receipts.push(createDeliveryReceipt({
100
+ deliveryId: file.deliveryKey,
101
+ presentation: `${channelKey}-${sent.presentation}`,
102
+ providerMessageIds: providerIds(sent.result),
103
+ artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
104
+ }));
105
+ artifactsSent += 1;
106
+ userVisible = true;
107
+ } catch (error) {
108
+ if (isAbort(error, signal)) throw error;
109
+ artifactSendErrors += 1;
110
+ logger?.warn?.(
111
+ `[dsh-im:${channelKey}] result artifact delivery failed (${error?.code ?? 'unknown'})`,
112
+ );
113
+ let messageIds = [];
114
+ if (typeof sendFailureNotice === 'function') {
115
+ try {
116
+ signal?.throwIfAborted();
117
+ const notice = await sendFailureNotice(artifact, error);
118
+ signal?.throwIfAborted();
119
+ messageIds = providerIds(notice);
120
+ failureNoticeVisible = true;
121
+ } catch (noticeError) {
122
+ if (isAbort(noticeError, signal)) throw noticeError;
123
+ logger?.warn?.(
124
+ `[dsh-im:${channelKey}] unable to send the safe artifact failure notice`,
125
+ );
126
+ }
127
+ }
128
+ const failureReceipt = createArtifactFailureReceipt({
129
+ artifactId: artifact?.artifactId ?? 'unknown',
130
+ deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
131
+ error,
132
+ providerMessageIds: messageIds,
133
+ });
134
+ receipts.push(failureReceipt);
135
+ if (failureNoticeVisible || failureReceipt.artifacts[0]?.outcome === 'unknown') {
136
+ userVisible = true;
137
+ }
138
+ } finally {
139
+ releaseOutboundArtifact(artifact);
140
+ }
141
+ }
142
+ } finally {
143
+ while (artifactIndex < artifacts.length) {
144
+ releaseOutboundArtifact(artifacts[artifactIndex]);
145
+ artifactIndex += 1;
146
+ }
147
+ }
148
+
149
+ let receipt = null;
150
+ if (receipts.length === 1 && !alwaysMerge) {
151
+ [receipt] = receipts;
152
+ } else if (receipts.length > 0) {
153
+ receipt = mergeDeliveryReceipts({
154
+ deliveryId: deliveryId
155
+ ?? baseReceipt?.deliveryId
156
+ ?? artifacts[0]?.deliveryKey,
157
+ presentation: aggregatePresentation
158
+ ?? `${channelKey}-${baseReceipt ? 'text-and-files' : 'files'}`,
159
+ receipts,
160
+ });
161
+ }
162
+
163
+ return {
164
+ receipt,
165
+ userVisible,
166
+ failureNoticeVisible,
167
+ artifactsSent,
168
+ artifactSendErrors,
169
+ };
170
+ }
@@ -576,7 +576,7 @@ export function createOutboundArtifactTool({ registry = outboundArtifactRegistry
576
576
  };
577
577
  const definition = Object.freeze({
578
578
  name: OUTBOUND_ARTIFACT_TOOL,
579
- description: 'Send a readable file to the user through the current conversation. Existing and newly created files are both valid.',
579
+ description: 'Send a readable file or generated image to the user through the current conversation. Existing and newly created files are both valid.',
580
580
  parameters: {
581
581
  type: 'object',
582
582
  additionalProperties: false,
@@ -654,7 +654,7 @@ export function installOutboundArtifactTool(ctx, { registry = outboundArtifactRe
654
654
  ctx.systemPrompt.section({
655
655
  name: 'dsh-im:return-file',
656
656
  order: 115,
657
- text: `When the user asks to receive a file, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery.`,
657
+ text: `When the user asks to receive a file or generated image, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery.`,
658
658
  });
659
659
  return true;
660
660
  }
@@ -34,6 +34,8 @@ export function providerMessageIdsFor(value) {
34
34
  value.message?.ts,
35
35
  value.key?.id,
36
36
  value.data?.message_id,
37
+ value.body?.msgid,
38
+ value.body?.message_id,
37
39
  ];
38
40
  const id = candidates.find((candidate) => (
39
41
  (typeof candidate === 'string' && candidate.trim())
@@ -32,14 +32,9 @@ import {
32
32
  harnessQuestionText,
33
33
  validHarnessQuestion,
34
34
  } from './harness-question.mjs';
35
+ import { deliverOutboundArtifacts } from './semantic/artifact-delivery.mjs';
35
36
  import {
36
- materializeOutboundArtifact,
37
- releaseOutboundArtifact,
38
- } from './semantic/artifact.mjs';
39
- import {
40
- createArtifactFailureReceipt,
41
37
  createDeliveryReceipt,
42
- mergeDeliveryReceipts,
43
38
  providerMessageIdsFor,
44
39
  } from './semantic/delivery.mjs';
45
40
 
@@ -339,73 +334,29 @@ export class TextHarnessBridge {
339
334
  }
340
335
 
341
336
  async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt) {
342
- const receipts = baseReceipt ? [baseReceipt] : [];
343
- let userVisible = Boolean(baseReceipt);
344
- for (const artifact of artifacts) {
345
- this.#signal?.throwIfAborted();
346
- try {
347
- if (typeof this.#bot.sendFile !== 'function') {
348
- const unavailable = new Error('Native file delivery is unavailable');
349
- unavailable.code = 'artifact-provider-unavailable';
350
- throw unavailable;
351
- }
352
- const file = await materializeOutboundArtifact(artifact, {
353
- signal: this.#signal,
354
- });
355
- this.#signal?.throwIfAborted();
356
- const result = await this.#bot.sendFile(target, file);
357
- receipts.push(createDeliveryReceipt({
358
- deliveryId: file.deliveryKey,
359
- presentation: `${this.#descriptor.key}-file`,
360
- providerMessageIds: providerMessageIdsFor(result),
361
- artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
362
- }));
363
- userVisible = true;
364
- this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
365
- } catch (error) {
366
- if (this.#signal?.aborted) throw error;
367
- this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
368
- this.#logger.warn?.(
369
- `[dsh-im:${this.#descriptor.key}] result file delivery failed (${error?.code ?? 'unknown'})`,
370
- );
371
- let providerMessageIds = [];
372
- let noticeSent = false;
373
- try {
374
- const notice = await this.#bot.sendText(
375
- target,
376
- artifactFailureText(artifact?.fileName, error, this.#descriptor),
377
- );
378
- providerMessageIds = providerMessageIdsFor(notice);
379
- noticeSent = true;
380
- } catch {
381
- this.#logger.warn?.(
382
- `[dsh-im:${this.#descriptor.key}] unable to send the safe result-file failure notice`,
383
- );
384
- }
385
- const failureReceipt = createArtifactFailureReceipt({
386
- artifactId: artifact?.artifactId ?? 'unknown',
387
- deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
388
- error,
389
- providerMessageIds,
390
- });
391
- receipts.push(failureReceipt);
392
- if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
393
- } finally {
394
- releaseOutboundArtifact(artifact);
395
- }
396
- }
397
- const receipt = receipts.length === 0
398
- ? null
399
- : receipts.length === 1
400
- ? receipts[0]
401
- : mergeDeliveryReceipts({
402
- deliveryId: replyTo,
403
- presentation: baseReceipt
404
- ? `${this.#descriptor.key}-text-and-files`
405
- : `${this.#descriptor.key}-files`,
406
- receipts,
407
- });
408
- return { receipt, userVisible };
337
+ const delivery = await deliverOutboundArtifacts({
338
+ artifacts,
339
+ baseReceipt,
340
+ deliveryId: replyTo,
341
+ channelKey: this.#descriptor.key,
342
+ signal: this.#signal,
343
+ sendImage: typeof this.#bot.sendImage === 'function'
344
+ ? (file) => this.#bot.sendImage(target, file)
345
+ : undefined,
346
+ sendFile: typeof this.#bot.sendFile === 'function'
347
+ ? (file) => this.#bot.sendFile(target, file)
348
+ : undefined,
349
+ sendFailureNotice: (artifact, error) => this.#bot.sendText(
350
+ target,
351
+ artifactFailureText(artifact?.fileName, error, this.#descriptor),
352
+ ),
353
+ logger: this.#logger,
354
+ });
355
+ this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
356
+ + delivery.artifactsSent;
357
+ this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
358
+ + delivery.artifactSendErrors;
359
+ return { receipt: delivery.receipt, userVisible: delivery.userVisible };
409
360
  }
410
361
 
411
362
  async #process(message, messageId, senderId, conversationKey, {
@@ -35,33 +35,33 @@ function preserveProviderMetadata(target, source) {
35
35
  return target;
36
36
  }
37
37
 
38
- function telegramArtifactProviderError(cause) {
38
+ function telegramArtifactProviderError(cause, mediaLabel = 'document') {
39
39
  const providerCode = Number(cause?.providerCode);
40
40
  const status = Number(cause?.status);
41
41
  const message = cleanString(cause?.message) ?? '';
42
42
  let code = 'artifact-provider-rejected';
43
- let summary = 'Telegram rejected the document.';
43
+ let summary = `Telegram rejected the ${mediaLabel}.`;
44
44
  if (providerCode === 401 || providerCode === 403 || status === 401 || status === 403) {
45
45
  code = 'artifact-permission-required';
46
- summary = 'Telegram denied permission to send the document.';
46
+ summary = `Telegram denied permission to send the ${mediaLabel}.`;
47
47
  } else if (providerCode === 413 || status === 413
48
48
  || /(?:file|request|entity).{0,20}(?:too (?:big|large)|size limit)|too (?:big|large)/i.test(message)) {
49
49
  code = 'artifact-too-large';
50
- summary = 'The document exceeds Telegram\'s size limit.';
50
+ summary = `The ${mediaLabel} exceeds Telegram's size limit.`;
51
51
  } else if (providerCode === 429 || status === 429) {
52
52
  code = 'artifact-rate-limited';
53
- summary = 'Telegram rate-limited document delivery.';
53
+ summary = `Telegram rate-limited ${mediaLabel} delivery.`;
54
54
  } else if (providerCode >= 500 || status >= 500) {
55
55
  code = 'artifact-delivery-uncertain';
56
- summary = 'Telegram document delivery result is uncertain.';
56
+ summary = `Telegram ${mediaLabel} delivery result is uncertain.`;
57
57
  }
58
58
  const error = new Error(summary, { cause });
59
59
  error.code = code;
60
60
  return preserveProviderMetadata(error, cause);
61
61
  }
62
62
 
63
- function uncertainTelegramDelivery(cause) {
64
- const error = new Error('Telegram document delivery result is uncertain', { cause });
63
+ function uncertainTelegramDelivery(cause, mediaLabel = 'document') {
64
+ const error = new Error(`Telegram ${mediaLabel} delivery result is uncertain`, { cause });
65
65
  error.code = 'artifact-delivery-uncertain';
66
66
  return preserveProviderMetadata(error, cause);
67
67
  }
@@ -184,15 +184,41 @@ export class TelegramApi {
184
184
  }
185
185
 
186
186
  async sendDocument({ chatId, file, replyToMessageId, messageThreadId, signal }) {
187
+ return this.#sendArtifact('sendDocument', 'document', 'document', {
188
+ chatId,
189
+ file,
190
+ replyToMessageId,
191
+ messageThreadId,
192
+ signal,
193
+ });
194
+ }
195
+
196
+ async sendPhoto({ chatId, file, replyToMessageId, messageThreadId, signal }) {
197
+ return this.#sendArtifact('sendPhoto', 'photo', 'photo', {
198
+ chatId,
199
+ file,
200
+ replyToMessageId,
201
+ messageThreadId,
202
+ signal,
203
+ });
204
+ }
205
+
206
+ async #sendArtifact(method, fieldName, mediaLabel, {
207
+ chatId,
208
+ file,
209
+ replyToMessageId,
210
+ messageThreadId,
211
+ signal,
212
+ }) {
187
213
  if (!file || typeof file !== 'object'
188
214
  || typeof file.fileName !== 'string' || !file.fileName
189
215
  || !Buffer.isBuffer(file.bytes)) {
190
- throw new TypeError('A Telegram document is required');
216
+ throw new TypeError(`A Telegram ${mediaLabel} is required`);
191
217
  }
192
218
  const payload = new FormData();
193
219
  payload.append('chat_id', String(chatId));
194
220
  payload.append(
195
- 'document',
221
+ fieldName,
196
222
  new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
197
223
  file.fileName,
198
224
  );
@@ -206,7 +232,7 @@ export class TelegramApi {
206
232
  if (signal?.aborted) throw abortReason(signal);
207
233
  const uploadSignal = requestSignal(signal, this.#fileUploadTimeoutMs);
208
234
  try {
209
- return await this.#call('sendDocument', payload, {
235
+ return await this.#call(method, payload, {
210
236
  signal: uploadSignal,
211
237
  timeoutMs: this.#fileUploadTimeoutMs,
212
238
  multipart: true,
@@ -214,9 +240,9 @@ export class TelegramApi {
214
240
  } catch (error) {
215
241
  if (signal?.aborted) throw abortReason(signal);
216
242
  if (error?.code?.startsWith?.('telegram-')) {
217
- throw telegramArtifactProviderError(error);
243
+ throw telegramArtifactProviderError(error, mediaLabel);
218
244
  }
219
- throw uncertainTelegramDelivery(error);
245
+ throw uncertainTelegramDelivery(error, mediaLabel);
220
246
  }
221
247
  }
222
248
 
@@ -211,6 +211,16 @@ export class TelegramBotClient {
211
211
  });
212
212
  }
213
213
 
214
+ sendImage(target, file) {
215
+ return this.#api.sendPhoto({
216
+ chatId: target.chatId,
217
+ file,
218
+ replyToMessageId: target.replyToMessageId,
219
+ messageThreadId: target.messageThreadId,
220
+ signal: this.#signal,
221
+ });
222
+ }
223
+
214
224
  async openStream(target) {
215
225
  const stream = createEditableMessageStream({
216
226
  limit: 4_000,