pi-braid-telegram 0.0.0-stage → 0.1.4

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.
@@ -0,0 +1,604 @@
1
+ /**
2
+ * telegram_send tool logic — pure, unit-testable without pi-core runtime.
3
+ *
4
+ * Responsibilities:
5
+ * - param validation: `text` is required and must be non-empty.
6
+ * - truncation contract: when text exceeds
7
+ * the applicable limit, the crafting model distills first (LLM rewrite);
8
+ * if that is unavailable/failed/still-over, the result is an ERROR to the
9
+ * agent (nothing sent) unless an explicit strategy:"truncate" was passed.
10
+ * There is NO silent hard-truncation path. `limit` (chars) sets an
11
+ * explicit cap; `strategy` ("truncate"|"error") is the explicit handling.
12
+ * Refuses empty.
13
+ * - delivery: text via sendMessage, or voice via sendVoiceNote when a TTS
14
+ * synthesis hook is supplied (when no TTS source is configured, requesting
15
+ * voice returns a clear error rather than silently sending text). Outbound
16
+ * carries NO [tg-end] marker — that marker is an INBOUND parse boundary
17
+ * only.
18
+ */
19
+
20
+ import type {
21
+ TelegramClient,
22
+ TelegramReplyMarkup,
23
+ TelegramSendResult,
24
+ ParseMode,
25
+ } from "./telegram-client.js";
26
+ import {
27
+ prepareDecisionOptions,
28
+ type DecisionOptionRows,
29
+ } from "./decision-buttons.js";
30
+ import {
31
+ sendMessageChunks,
32
+ resolveDeliveryFormat,
33
+ TELEGRAM_MAX_TEXT_LENGTH,
34
+ type ConversionCounts,
35
+ } from "./format.js";
36
+ import { markAgentDeliveredMessage } from "./notify/agent-delivery.js";
37
+ import {
38
+ DEFAULT_SEND_MAX_CHARS,
39
+ resolveSendMaxChars,
40
+ } from "./telegram-settings.js";
41
+
42
+ /**
43
+ * Over-limit strategies (truncation contract):
44
+ * an explicit `strategy` always wins; the DEFAULT when the body exceeds the
45
+ * applicable cap with no explicit choice is an ERROR to the agent — never a
46
+ * silent hard truncate. The crafting-model distillation stays the automatic
47
+ * first rescue (an LLM rewrite, not a truncate); only its FAILURE falls
48
+ * through to the default error.
49
+ */
50
+ export type OverLimitStrategy = "truncate" | "error";
51
+
52
+ export interface TelegramSendToolParams {
53
+ text: string;
54
+ voice?: boolean;
55
+ voiceText?: string;
56
+ /** Rich-format parse mode (HTML | Markdown). Honored on the text path. */
57
+ parse_mode?: ParseMode;
58
+ /** Rich mode: text may exceed the default cap — split-to-fit instead. */
59
+ rich?: boolean;
60
+ /**
61
+ * Explicit char cap for the plain path (default: the settings-driven
62
+ * `braid.telegram.sendMaxChars`, 4096 when unset). An explicit limit is a
63
+ * user choice: text within the limit sends verbatim; over the limit the
64
+ * `strategy` applies (default: error, never truncate).
65
+ */
66
+ limit?: number;
67
+ /**
68
+ * Explicit over-limit handling: "truncate" hard-truncates to the limit
69
+ * (ellipsis, `truncated:true`), "error" refuses with an explanatory error.
70
+ * When set, crafting is SKIPPED — an explicit strategy means deliver MY text
71
+ * (sized), not a rewrite. Absent + over limit + craft failed → error.
72
+ */
73
+ strategy?: OverLimitStrategy;
74
+ /**
75
+ * Decision buttons: 1-2 rows of up to 4 `{ label, value }` buttons attached
76
+ * to the message as an inline keyboard. A tap reaches the agent as an
77
+ * inbound `[decision] <token> → <label> (<value>)` message. Malformed rows
78
+ * refuse the whole send (nothing delivered).
79
+ */
80
+ options?: DecisionOptionRows;
81
+ /**
82
+ * Correlation id for the decision buttons (1-16 chars of A-Za-z0-9_-).
83
+ * Generated when omitted; echoed back on every tap and returned in the
84
+ * outcome. Only meaningful together with `options`.
85
+ */
86
+ decisionToken?: string;
87
+ }
88
+
89
+ export interface TelegramSendOutcome {
90
+ ok: boolean;
91
+ messageId?: number;
92
+ error?: string;
93
+ /**
94
+ * true ONLY when the delivered text was hard-truncated to the applicable
95
+ * limit via an EXPLICIT `strategy: "truncate"` choice. There is no silent
96
+ * truncation path anymore (A1): over-limit without an explicit strategy is
97
+ * an error result, and the crafting path is an LLM rewrite (never flagged
98
+ * as truncation).
99
+ */
100
+ truncated: boolean;
101
+ warn?: string;
102
+ /**
103
+ * Per-kind counts of MarkdownV2 delimiter pairs (`**bold**`, `__underline__`,
104
+ * `~strike~`, `||spoiler||`, `` `code` ``) that were rewritten to HTML so the
105
+ * delivered message renders them instead of showing the raw markers. Present
106
+ * only when a conversion happened and the message shipped as HTML.
107
+ */
108
+ converted?: ConversionCounts;
109
+ charCount: number;
110
+ channel: "text" | "voice";
111
+ /** Rich split-to-fit: number of messages sent (1 = single message). */
112
+ chunks?: number;
113
+ /**
114
+ * Correlation id carried by the decision buttons (present when `options`
115
+ * were sent): a tap returns as `[decision] <token> → <label> (<value>)`.
116
+ */
117
+ decisionToken?: string;
118
+ }
119
+
120
+ /** Optional TTS hook. When absent, voice delivery is refused with a clear error. */
121
+ export interface VoiceTtsHook {
122
+ /**
123
+ * Synthesize `text` into OGG/Opus bytes for a Telegram voice note. A throw
124
+ * is surfaced by runTelegramSend as a failed voice outcome — never a silent
125
+ * text fallback.
126
+ */
127
+ synthesize(text: string): Promise<Buffer | Uint8Array>;
128
+ }
129
+
130
+ export interface TelegramSendDeps {
131
+ client: Pick<TelegramClient, "sendMessage" | "sendVoiceNote">;
132
+ token: string;
133
+ chatId: number | string;
134
+ /** Voice path. Absent (no TTS source configured) => voice is refused. */
135
+ tts?: VoiceTtsHook;
136
+ /**
137
+ * LLM-crafted content hook: on the default over-limit path (no
138
+ * explicit strategy), the crafting model produces the actual message
139
+ * instead of truncation. Return `null` when crafting is unavailable/not
140
+ * applicable — the caller now ERRORS (A1: never silent-truncate) instead
141
+ * of falling back to a hard truncate. When present, the hook is also used
142
+ * for /telegram send. Never decorates with [tg-end].
143
+ */
144
+ craft?: (text: string) => Promise<{ text: string; crafted: boolean } | null>;
145
+ /**
146
+ * Default cap override — tests/wiring seam. Absent: the settings-driven
147
+ * `braid.telegram.sendMaxChars` (4096 when unset), read per call so an
148
+ * edit to ~/.pi/agent/settings.json applies without a restart.
149
+ */
150
+ sendMaxChars?: number;
151
+ /**
152
+ * Inbound-poller liveness check: consulted
153
+ * AFTER a successful send — when it returns false the outcome gains a
154
+ * `warn`, because a dead poller means the owner's reply is never consumed
155
+ * and the agent waits forever. Intentional outbound-only deployments
156
+ * (BRAID_TG_POLL_DISABLED) pass () => true. Absent => no check.
157
+ */
158
+ pollerRunning?: () => boolean;
159
+ /**
160
+ * Worker-role send: the poller is the orchestrator seat's machine-wide
161
+ * singleton, so the dead-poller warning must NOT advise enabling it —
162
+ * the seat owns inbound; this delivery is outbound-only.
163
+ */
164
+ workerRole?: boolean;
165
+ }
166
+
167
+ /**
168
+ * Validate tool params. Refuses empty/whitespace `text`.
169
+ */
170
+ export function validateSendParams(
171
+ params: Partial<TelegramSendToolParams>,
172
+ ): { ok: true; text: string } | { ok: false; error: string } {
173
+ const text = typeof params?.text === "string" ? params.text.trim() : "";
174
+ if (!text) {
175
+ return {
176
+ ok: false,
177
+ error: "telegram_send: `text` is required and must be non-empty",
178
+ };
179
+ }
180
+ return { ok: true, text };
181
+ }
182
+
183
+ /**
184
+ * Concise enforcement: returns the (trimmed) message, truncating with an
185
+ * ellipsis when it exceeds `maxLen`. `truncated` flags the shortening so the
186
+ * agent knows delivery was shortened. Used ONLY for an explicit
187
+ * `strategy: "truncate"` choice (A1: no silent truncation path remains).
188
+ */
189
+ export function enforceConcise(
190
+ text: string,
191
+ maxLen: number = DEFAULT_SEND_MAX_CHARS,
192
+ ): { text: string; truncated: boolean } {
193
+ const trimmed = text.trim();
194
+ if (trimmed.length <= maxLen) return { text: trimmed, truncated: false };
195
+ const head = trimmed.slice(0, Math.max(0, maxLen - 1)).trimEnd();
196
+ return { text: head + "…", truncated: true };
197
+ }
198
+
199
+ /**
200
+ * Build the over-limit error the agent receives when nothing was sent (A1
201
+ * default path). States the limit, the body size, and the three options —
202
+ * the agent can then pick rich:true, a shorter text, or an explicit strategy.
203
+ */
204
+ export function overLimitError(textLen: number, limit: number): string {
205
+ return (
206
+ `telegram_send: text is ${textLen} chars; the limit is ${limit} — NOTHING was sent. ` +
207
+ `Pick one: rich:true (long formatted report, split-to-fit at Telegram's 4096-cap), ` +
208
+ `shorter text, or strategy:"truncate" (explicit hard truncation) / strategy:"error" (this error).`
209
+ );
210
+ }
211
+
212
+ /**
213
+ * The cap a plain send actually enforces: the explicit `limit` when it is a
214
+ * usable positive number, else the settings-driven default. Shared so the
215
+ * pre-send length check and the conversion's `renderedCap` agree on one value.
216
+ */
217
+ function resolveEffectiveLimit(limit: unknown, defaultLimit: number): number {
218
+ return typeof limit === "number" && Number.isFinite(limit) && limit >= 1
219
+ ? Math.floor(limit)
220
+ : defaultLimit;
221
+ }
222
+
223
+ /**
224
+ * Resolve the outbound plain text under the truncation contract.
225
+ *
226
+ * body ≤ effectiveLimit -> send verbatim (no crafting)
227
+ * strategy "truncate" (explicit) -> hard-truncate to the limit (skip crafting:
228
+ * the user chose truncate, not a rewrite)
229
+ * strategy "error" (explicit) -> refuse (nothing sent)
230
+ * no strategy (default) -> crafting model distills (stays; an LLM
231
+ * rewrite, not a truncate). Craft
232
+ * unavailable/failed/empty OR crafted
233
+ * result still over the limit -> ERROR.
234
+ * NEVER a silent hard truncate.
235
+ *
236
+ * Returns either the text to deliver (truncated:false unless the explicit
237
+ * truncate strategy ran) or an `error` — the caller sends nothing on error.
238
+ */
239
+ async function resolveSendText(
240
+ text: string,
241
+ opts: {
242
+ limit?: number;
243
+ /** Cap when `limit` is absent (settings-driven; see TelegramSendDeps). */
244
+ defaultLimit: number;
245
+ strategy?: OverLimitStrategy;
246
+ craft?: TelegramSendDeps["craft"];
247
+ },
248
+ ): Promise<{ text: string; truncated: boolean } | { error: string }> {
249
+ const trimmed = text.trim();
250
+ const effectiveLimit = resolveEffectiveLimit(opts.limit, opts.defaultLimit);
251
+
252
+ if (trimmed.length <= effectiveLimit) {
253
+ return { text: trimmed, truncated: false };
254
+ }
255
+
256
+ // Explicit strategy ALWAYS wins (and skips crafting — the user chose how
257
+ // to handle THEIR text; a rewrite would not be their choice).
258
+ if (opts.strategy === "truncate") {
259
+ const enforced = enforceConcise(trimmed, effectiveLimit);
260
+ return { text: enforced.text, truncated: true };
261
+ }
262
+ if (opts.strategy === "error") {
263
+ return { error: overLimitError(trimmed.length, effectiveLimit) };
264
+ }
265
+
266
+ // Default path: try the crafting model (distillation stays per contract);
267
+ // any failure — unavailable, malformed, or crafted-but-still-over — is an
268
+ // ERROR to the agent (never a silent truncate). The error names the limit
269
+ // and the options so the agent can pick rich / shorter / explicit strategy.
270
+ if (opts.craft) {
271
+ try {
272
+ const crafted = await opts.craft(trimmed);
273
+ if (crafted?.crafted && crafted.text?.trim()) {
274
+ const craftedText = crafted.text.trim();
275
+ if (craftedText.length <= effectiveLimit) {
276
+ return { text: craftedText, truncated: false };
277
+ }
278
+ // Crafted but still over: fall through to the default error (the
279
+ // distillation did not produce a deliverable message).
280
+ }
281
+ } catch {
282
+ // crafting failed — fall through to the default error
283
+ }
284
+ }
285
+ return { error: overLimitError(trimmed.length, effectiveLimit) };
286
+ }
287
+
288
+ /**
289
+ * Run the send. Orchestrates validate -> cap/craft/strategy -> deliver
290
+ * text/voice. With `rich: true` the cap/craft step is SKIPPED: the text
291
+ * is delivered verbatim, split-to-fit across Telegram's 4096 cap, honoring
292
+ * `parse_mode`. On the plain path, over-limit without an explicit strategy
293
+ * is an ERROR result (nothing sent) — never a silent truncate (A1).
294
+ */
295
+ export async function runTelegramSend(
296
+ params: Partial<TelegramSendToolParams>,
297
+ deps: TelegramSendDeps,
298
+ ): Promise<TelegramSendOutcome> {
299
+ const outcome = await runTelegramSendInner(params, deps);
300
+ // Use-time inbound-liveness guard: a successful
301
+ // send is the worst moment for the poller to be silently dead — the owner
302
+ // answers and nothing consumes it. Only ok outcomes carry the warning; the
303
+ // intentional outbound-only mode satisfies the check via its dep closure.
304
+ if (outcome.ok && deps.pollerRunning && !deps.pollerRunning()) {
305
+ const pollerWarn = deps.workerRole
306
+ ? "inbound poller OFF — the orchestrator seat owns the Telegram poller; this message is delivered without inbound (owner replies reach the seat session)"
307
+ : "inbound poller OFF — owner replies won't reach the agent; enable via telegram_poller action=enable";
308
+ outcome.warn = outcome.warn ? `${outcome.warn}; ${pollerWarn}` : pollerWarn;
309
+ }
310
+ return outcome;
311
+ }
312
+
313
+ async function runTelegramSendInner(
314
+ params: Partial<TelegramSendToolParams>,
315
+ deps: TelegramSendDeps,
316
+ ): Promise<TelegramSendOutcome> {
317
+ const validated = validateSendParams(params);
318
+ if (!validated.ok) {
319
+ return {
320
+ ok: false,
321
+ error: validated.error,
322
+ truncated: false,
323
+ charCount: 0,
324
+ channel: "text",
325
+ };
326
+ }
327
+
328
+ // Defensive limit validation: an explicit but malformed limit is refused,
329
+ // never silently ignored (explicit params must not fall back quietly).
330
+ if (
331
+ params.limit !== undefined &&
332
+ (typeof params.limit !== "number" ||
333
+ !Number.isFinite(params.limit) ||
334
+ params.limit < 1)
335
+ ) {
336
+ return {
337
+ ok: false,
338
+ error: "telegram_send: `limit` must be a positive number of characters",
339
+ truncated: false,
340
+ charCount: validated.text.length,
341
+ channel: "text",
342
+ };
343
+ }
344
+ if (
345
+ params.strategy !== undefined &&
346
+ params.strategy !== "truncate" &&
347
+ params.strategy !== "error"
348
+ ) {
349
+ return {
350
+ ok: false,
351
+ error: 'telegram_send: `strategy` must be "truncate" or "error"',
352
+ truncated: false,
353
+ charCount: validated.text.length,
354
+ channel: "text",
355
+ };
356
+ }
357
+
358
+ // decision buttons (explicit param; malformed rows refuse the send)
359
+ // Prepared once here, threaded into the delivery paths below. The voice
360
+ // path cannot carry buttons through this tool, so the combination is
361
+ // refused rather than silently dropping the keyboard.
362
+ let decision: { token: string; keyboard: TelegramReplyMarkup } | undefined;
363
+ if (params.options !== undefined || params.decisionToken !== undefined) {
364
+ if (params.voice === true) {
365
+ return {
366
+ ok: false,
367
+ error:
368
+ "telegram_send: `options` are not supported on the voice path — send a text message to attach decision buttons",
369
+ truncated: false,
370
+ charCount: validated.text.length,
371
+ channel: "voice",
372
+ };
373
+ }
374
+ const prepared = prepareDecisionOptions(
375
+ params.options,
376
+ params.decisionToken,
377
+ );
378
+ if (!prepared.ok) {
379
+ return {
380
+ ok: false,
381
+ error: prepared.error,
382
+ truncated: false,
383
+ charCount: validated.text.length,
384
+ channel: "text",
385
+ };
386
+ }
387
+ decision = { token: prepared.token, keyboard: prepared.keyboard };
388
+ }
389
+
390
+ const wantsVoice = params.voice === true;
391
+ const voiceText = wantsVoice
392
+ ? typeof params.voiceText === "string" && params.voiceText.trim()
393
+ ? params.voiceText
394
+ : validated.text
395
+ : undefined;
396
+
397
+ if (wantsVoice) {
398
+ if (!deps.tts) {
399
+ return {
400
+ ok: false,
401
+ error:
402
+ "telegram_send: voice notes requested but no TTS source is configured " +
403
+ '(braid.telegram.tts source is "none"). Pass `voice: false` (or omit) to send text.',
404
+ truncated: false,
405
+ charCount: validated.text.length,
406
+ channel: "voice",
407
+ };
408
+ }
409
+ let ogg: Buffer | Uint8Array;
410
+ try {
411
+ ogg = await deps.tts.synthesize(voiceText ?? validated.text);
412
+ } catch (error) {
413
+ return {
414
+ ok: false,
415
+ error: `telegram_send: voice synthesis failed — ${
416
+ error instanceof Error ? error.message : String(error)
417
+ }`,
418
+ truncated: false,
419
+ charCount: validated.text.length,
420
+ channel: "voice",
421
+ };
422
+ }
423
+ const result: TelegramSendResult = await deps.client.sendVoiceNote(
424
+ deps.token,
425
+ deps.chatId,
426
+ ogg,
427
+ validated.text,
428
+ );
429
+ return resultToOutcome(result, {
430
+ truncated: false,
431
+ charCount: validated.text.length,
432
+ channel: "voice",
433
+ });
434
+ }
435
+
436
+ const parseMode =
437
+ params.parse_mode === "HTML" || params.parse_mode === "Markdown"
438
+ ? params.parse_mode
439
+ : undefined;
440
+
441
+ // rich path: no cap enforcement — split-to-fit + optional parse_mode
442
+ if (params.rich === true) {
443
+ const fmt = resolveDeliveryFormat(parseMode, validated.text);
444
+ const result = await sendMessageChunks(deps.client, {
445
+ token: deps.token,
446
+ chatId: deps.chatId,
447
+ text: fmt.text,
448
+ ...(fmt.parseMode ? { parseMode: fmt.parseMode } : {}),
449
+ // Buttons ride the LAST chunk: the end of the report is where the
450
+ // reader answers, and an early chunk would leave a stale keyboard
451
+ // behind it.
452
+ ...(decision ? { replyMarkup: decision.keyboard } : {}),
453
+ });
454
+ if (result.ok) {
455
+ // The agent explicitly delivered a report — live-update finish() will
456
+ // suppress its own separate turn summary (no duplicate message).
457
+ markAgentDeliveredMessage();
458
+ return {
459
+ ok: true,
460
+ messageId: result.messageIds[0],
461
+ truncated: false,
462
+ charCount: validated.text.length,
463
+ channel: "text",
464
+ chunks: result.total,
465
+ ...(decision ? { decisionToken: decision.token } : {}),
466
+ ...(fmt.converted ? { converted: fmt.converted } : {}),
467
+ ...(fmt.downgraded
468
+ ? {
469
+ warn: "parse_mode Markdown dropped — text has unbalanced markdown delimiters",
470
+ }
471
+ : {}),
472
+ };
473
+ }
474
+ return {
475
+ ok: false,
476
+ error: `telegram_send failed: ${result.error ?? "unknown error"}`,
477
+ truncated: false,
478
+ charCount: validated.text.length,
479
+ channel: "text",
480
+ chunks: result.total,
481
+ ...(decision ? { decisionToken: decision.token } : {}),
482
+ };
483
+ }
484
+
485
+ // default (plain) path — truncation contract
486
+ // Over-limit without an explicit strategy is an ERROR (nothing sent);
487
+ // crafting still distills first; an explicit truncate strategy is the
488
+ // only hard-truncation path. The default cap is long (sendMaxChars,
489
+ // 4096 when unset): plain sends are verbatim up to Telegram's per-message
490
+ // limit — there is no tweet-length default.
491
+ const defaultLimit =
492
+ typeof deps.sendMaxChars === "number" &&
493
+ Number.isFinite(deps.sendMaxChars) &&
494
+ deps.sendMaxChars >= 1
495
+ ? Math.floor(deps.sendMaxChars)
496
+ : resolveSendMaxChars();
497
+ const effectiveLimit = resolveEffectiveLimit(params.limit, defaultLimit);
498
+ const resolved = await resolveSendText(validated.text, {
499
+ limit: params.limit,
500
+ defaultLimit,
501
+ strategy: params.strategy,
502
+ craft: deps.craft,
503
+ });
504
+ if ("error" in resolved) {
505
+ return {
506
+ ok: false,
507
+ error: resolved.error,
508
+ truncated: false,
509
+ charCount: validated.text.length,
510
+ channel: "text",
511
+ };
512
+ }
513
+ const { text, truncated } = resolved;
514
+ // The conversion may grow the body (tags, escaped characters). Cap it at the
515
+ // effective limit: when the rendered result would cross the cap the
516
+ // conversion is refused and the text ships as authored (it was within the
517
+ // cap before conversion), so a converted body never bypasses a configured
518
+ // sendMaxChars.
519
+ const fmt = resolveDeliveryFormat(parseMode, text, false, effectiveLimit);
520
+ // A convertible body sits under the cap as authored, but its HTML rendering
521
+ // (tags + escaped reserved characters) can cross Telegram's per-message cap
522
+ // on marker-dense text. Split-to-fit then, mirroring the rich path: the
523
+ // conversion must never turn a deliverable send into an API error.
524
+ if (fmt.converted && fmt.text.length > TELEGRAM_MAX_TEXT_LENGTH) {
525
+ const chunked = await sendMessageChunks(deps.client, {
526
+ token: deps.token,
527
+ chatId: deps.chatId,
528
+ text: fmt.text,
529
+ ...(fmt.parseMode ? { parseMode: fmt.parseMode } : {}),
530
+ ...(decision ? { replyMarkup: decision.keyboard } : {}),
531
+ });
532
+ if (chunked.ok) {
533
+ markAgentDeliveredMessage();
534
+ const outcome: TelegramSendOutcome = {
535
+ ok: true,
536
+ messageId: chunked.messageIds[0],
537
+ truncated,
538
+ charCount: text.length,
539
+ channel: "text",
540
+ chunks: chunked.total,
541
+ converted: fmt.converted,
542
+ ...(decision ? { decisionToken: decision.token } : {}),
543
+ };
544
+ return outcome;
545
+ }
546
+ return {
547
+ ok: false,
548
+ error: `telegram_send failed: ${chunked.error ?? "unknown error"}`,
549
+ truncated,
550
+ charCount: text.length,
551
+ channel: "text",
552
+ chunks: chunked.total,
553
+ ...(decision ? { decisionToken: decision.token } : {}),
554
+ };
555
+ }
556
+ const result: TelegramSendResult = await deps.client.sendMessage(
557
+ deps.token,
558
+ deps.chatId,
559
+ fmt.text,
560
+ {
561
+ ...(fmt.parseMode ? { parseMode: fmt.parseMode } : {}),
562
+ ...(decision ? { replyMarkup: decision.keyboard } : {}),
563
+ },
564
+ );
565
+ const outcome = resultToOutcome(result, {
566
+ truncated,
567
+ charCount: text.length,
568
+ channel: "text",
569
+ });
570
+ if (fmt.converted) outcome.converted = fmt.converted;
571
+ if (decision) outcome.decisionToken = decision.token;
572
+ return outcome;
573
+ }
574
+
575
+ function resultToOutcome(
576
+ result: TelegramSendResult,
577
+ extra: { truncated: boolean; charCount: number; channel: "text" | "voice" },
578
+ ): TelegramSendOutcome {
579
+ if (result.ok) {
580
+ // The agent explicitly delivered a report — live-update finish() will
581
+ // suppress its own separate turn summary (no duplicate message).
582
+ markAgentDeliveredMessage();
583
+ return {
584
+ ok: true,
585
+ messageId: result.messageId,
586
+ truncated: extra.truncated,
587
+ warn: extra.truncated
588
+ ? `message truncated to the explicit limit (strategy: "truncate")`
589
+ : undefined,
590
+ charCount: extra.charCount,
591
+ channel: extra.channel,
592
+ };
593
+ }
594
+ return {
595
+ ok: false,
596
+ error: `telegram_send failed: ${result.error ?? "unknown error"}`,
597
+ truncated: extra.truncated,
598
+ warn: extra.truncated
599
+ ? `message truncated to the explicit limit (strategy: "truncate")`
600
+ : undefined,
601
+ charCount: extra.charCount,
602
+ channel: extra.channel,
603
+ };
604
+ }