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.
- package/LICENSE +21 -0
- package/README.md +204 -2
- package/docs/live-update.md +190 -0
- package/docs/pause-control.md +151 -0
- package/docs/turn-complete-notify.md +110 -0
- package/package.json +60 -4
- package/src/index.ts +1853 -0
- package/src/lib/audio-normalize.ts +91 -0
- package/src/lib/decision-buttons.ts +574 -0
- package/src/lib/document.ts +203 -0
- package/src/lib/format.ts +809 -0
- package/src/lib/html-check.ts +280 -0
- package/src/lib/inbound-poller.ts +1248 -0
- package/src/lib/inbound-queue.ts +157 -0
- package/src/lib/local-time.ts +181 -0
- package/src/lib/notify/agent-delivery.ts +29 -0
- package/src/lib/notify/craft.ts +157 -0
- package/src/lib/notify/live-update.ts +716 -0
- package/src/lib/notify/model.ts +197 -0
- package/src/lib/notify/rate-limit.ts +44 -0
- package/src/lib/notify/turn-notify.ts +138 -0
- package/src/lib/pause-control.ts +425 -0
- package/src/lib/photo.ts +182 -0
- package/src/lib/poller-lock.ts +127 -0
- package/src/lib/session-role.ts +32 -0
- package/src/lib/state-paths.ts +164 -0
- package/src/lib/stt-keys.ts +211 -0
- package/src/lib/telegram-client.ts +699 -0
- package/src/lib/telegram-inbox.ts +203 -0
- package/src/lib/telegram-settings.ts +92 -0
- package/src/lib/tool-logic.ts +604 -0
- package/src/lib/transcribe.ts +574 -0
- package/src/lib/tts.ts +451 -0
- package/src/lib/venice-catalog-base-url.ts +45 -0
- package/src/lib/voice.ts +192 -0
- package/src/lib/watchdog-tunnel.ts +102 -0
|
@@ -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
|
+
}
|