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,699 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Low-level Telegram Bot API client built on Node 22's global fetch.
|
|
3
|
+
*
|
|
4
|
+
* Zero npm dependencies. Every method takes an injectable fetch (defaults to
|
|
5
|
+
* `globalThis.fetch`) so tests can substitute a recorded-fixture fake without
|
|
6
|
+
* touching the network.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors the agent-zero `_telegram_integration` plugin's `helpers/telegram_client.py`
|
|
9
|
+
* against `https://api.telegram.org/bot<token>/<method>` using plain fetch
|
|
10
|
+
* instead of aiogram.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type TelegramFetch = typeof fetch;
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_BASE_URL = "https://api.telegram.org";
|
|
16
|
+
|
|
17
|
+
/** Telegram parse_mode for formatted text (HTML | Markdown). */
|
|
18
|
+
export type ParseMode = "HTML" | "Markdown";
|
|
19
|
+
|
|
20
|
+
/** Inline keyboard (reply_markup) — pause/resume control. */
|
|
21
|
+
export interface TelegramReplyMarkup {
|
|
22
|
+
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Options accepted by every client call; `fetch` is injectable for tests. */
|
|
26
|
+
export interface TelegramClientOptions {
|
|
27
|
+
/** Fetch implementation to use. Defaults to globalThis.fetch. */
|
|
28
|
+
fetch?: TelegramFetch;
|
|
29
|
+
/** API base url override (tests / mirrors). Defaults to https://api.telegram.org. */
|
|
30
|
+
baseUrl?: string;
|
|
31
|
+
/**
|
|
32
|
+
* parse_mode for sendMessage/editMessageText (HTML | Markdown) — rich
|
|
33
|
+
* formatted responses. Optional; when absent the message is
|
|
34
|
+
* delivered as plain text.
|
|
35
|
+
*/
|
|
36
|
+
parseMode?: ParseMode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Per-call options for sendMessage/editMessageText: the shared client
|
|
41
|
+
* options plus an optional inline keyboard (reply_markup) attached to the
|
|
42
|
+
* message. `createTelegramClient` merges its bound options
|
|
43
|
+
* with these per-call ones.
|
|
44
|
+
*/
|
|
45
|
+
export interface TelegramSendOptions extends TelegramClientOptions {
|
|
46
|
+
/** Inline keyboard (reply_markup) to attach to the message/edited message. */
|
|
47
|
+
replyMarkup?: TelegramReplyMarkup;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Wire types (Telegram Bot API shapes relevant to this channel)
|
|
51
|
+
|
|
52
|
+
export interface TelegramFrom {
|
|
53
|
+
id: number;
|
|
54
|
+
is_bot?: boolean;
|
|
55
|
+
first_name?: string;
|
|
56
|
+
last_name?: string;
|
|
57
|
+
username?: string;
|
|
58
|
+
language_code?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TelegramChat {
|
|
62
|
+
id: number;
|
|
63
|
+
type: "private" | "group" | "supergroup" | "channel";
|
|
64
|
+
username?: string;
|
|
65
|
+
first_name?: string;
|
|
66
|
+
last_name?: string;
|
|
67
|
+
title?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface TelegramMessage {
|
|
71
|
+
message_id: number;
|
|
72
|
+
from?: TelegramFrom;
|
|
73
|
+
chat: TelegramChat;
|
|
74
|
+
date: number;
|
|
75
|
+
text?: string;
|
|
76
|
+
caption?: string;
|
|
77
|
+
voice?: unknown;
|
|
78
|
+
chat_type?: string;
|
|
79
|
+
[key: string]: unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Inline-keyboard tap — `data` carries the button's callback_data. */
|
|
83
|
+
export interface TelegramCallbackQuery {
|
|
84
|
+
id: string;
|
|
85
|
+
from?: TelegramFrom;
|
|
86
|
+
/** The message the button was attached to (absent when the tap is inline-mode). */
|
|
87
|
+
message?: TelegramMessage;
|
|
88
|
+
data?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface TelegramUpdate {
|
|
92
|
+
update_id: number;
|
|
93
|
+
message?: TelegramMessage;
|
|
94
|
+
edited_message?: TelegramMessage;
|
|
95
|
+
channel_post?: TelegramMessage;
|
|
96
|
+
callback_query?: TelegramCallbackQuery;
|
|
97
|
+
[key: string]: unknown;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Result shapes returned to callers (never throw for API/network errors)
|
|
101
|
+
|
|
102
|
+
export interface TelegramSendResult {
|
|
103
|
+
ok: boolean;
|
|
104
|
+
messageId?: number;
|
|
105
|
+
error?: string;
|
|
106
|
+
raw?: unknown;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface TelegramGetUpdatesResult {
|
|
110
|
+
ok: boolean;
|
|
111
|
+
updates: TelegramUpdate[];
|
|
112
|
+
error?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface TelegramGetMeResult {
|
|
116
|
+
ok: boolean;
|
|
117
|
+
bot?: {
|
|
118
|
+
id: number;
|
|
119
|
+
is_bot?: boolean;
|
|
120
|
+
username?: string;
|
|
121
|
+
first_name?: string;
|
|
122
|
+
};
|
|
123
|
+
error?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface TelegramTestTokenResult extends TelegramGetMeResult {
|
|
127
|
+
httpStatus?: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Result of a bare action call (e.g. sendChatAction) that returns ok:true only. */
|
|
131
|
+
export interface TelegramActionResult {
|
|
132
|
+
ok: boolean;
|
|
133
|
+
error?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Errors
|
|
137
|
+
|
|
138
|
+
export class TelegramApiError extends Error {
|
|
139
|
+
readonly status: number | undefined;
|
|
140
|
+
readonly raw: unknown;
|
|
141
|
+
constructor(message: string, status?: number, raw?: unknown) {
|
|
142
|
+
super(message);
|
|
143
|
+
this.name = "TelegramApiError";
|
|
144
|
+
this.status = status;
|
|
145
|
+
this.raw = raw;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function apiUrl(baseUrl: string, token: string, method: string): string {
|
|
150
|
+
return `${baseUrl.replace(/\/$/, "")}/bot${token}/${method}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function parseJsonOk(response: Response): Promise<any> {
|
|
154
|
+
const status = response.status;
|
|
155
|
+
let json: any = null;
|
|
156
|
+
try {
|
|
157
|
+
json = await response.json();
|
|
158
|
+
} catch {
|
|
159
|
+
// non-JSON error body (e.g. 404 from a wrong token route)
|
|
160
|
+
}
|
|
161
|
+
if (!response.ok || json == null || json.ok !== true) {
|
|
162
|
+
const description =
|
|
163
|
+
json?.description ?? json?.error ?? json?.message ?? `HTTP ${status}`;
|
|
164
|
+
throw new TelegramApiError(String(description), status, json ?? undefined);
|
|
165
|
+
}
|
|
166
|
+
return json;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function toErrorMessage(error: unknown): string {
|
|
170
|
+
if (error instanceof Error) return error.message;
|
|
171
|
+
return String(error);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Outbound: text
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Send a text message. Returns the delivered `message_id` via `result.messageId`.
|
|
178
|
+
* Honors `options.parseMode` (HTML | Markdown) ��� rich formatted responses.
|
|
179
|
+
* See sendMessage request shape: https://core.telegram.org/bots/api#sendmessage
|
|
180
|
+
*/
|
|
181
|
+
export async function sendMessage(
|
|
182
|
+
token: string,
|
|
183
|
+
chatId: number | string,
|
|
184
|
+
text: string,
|
|
185
|
+
options: TelegramSendOptions = {},
|
|
186
|
+
): Promise<TelegramSendResult> {
|
|
187
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
188
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
189
|
+
try {
|
|
190
|
+
const body: Record<string, unknown> = { chat_id: chatId, text };
|
|
191
|
+
if (options.parseMode) body.parse_mode = options.parseMode;
|
|
192
|
+
if (options.replyMarkup) body.reply_markup = options.replyMarkup;
|
|
193
|
+
const res = await fetchFn(apiUrl(baseUrl, token, "sendMessage"), {
|
|
194
|
+
method: "POST",
|
|
195
|
+
headers: { "Content-Type": "application/json" },
|
|
196
|
+
body: JSON.stringify(body),
|
|
197
|
+
});
|
|
198
|
+
const json = await parseJsonOk(res);
|
|
199
|
+
return {
|
|
200
|
+
ok: true,
|
|
201
|
+
messageId: json?.result?.message_id as number | undefined,
|
|
202
|
+
raw: json?.result ?? undefined,
|
|
203
|
+
};
|
|
204
|
+
} catch (error) {
|
|
205
|
+
return { ok: false, error: toErrorMessage(error) };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Outbound: edit / chat action (live-update)
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Edit a previously-sent message in place (live-update tracked message).
|
|
213
|
+
* Mirrors sendMessage's result contract: returns the (possibly unchanged)
|
|
214
|
+
* `message_id` on success. Telegram returns `result: Message | true` — when
|
|
215
|
+
* the body is identical it replies `true` (no message_id), which is still a
|
|
216
|
+
* success ({ ok:true }). See editMessageText:
|
|
217
|
+
* https://core.telegram.org/bots/api#editmessagetext
|
|
218
|
+
*/
|
|
219
|
+
export async function editMessageText(
|
|
220
|
+
token: string,
|
|
221
|
+
chatId: number | string,
|
|
222
|
+
messageId: number,
|
|
223
|
+
text: string,
|
|
224
|
+
options: TelegramSendOptions = {},
|
|
225
|
+
): Promise<TelegramSendResult> {
|
|
226
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
227
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
228
|
+
try {
|
|
229
|
+
const body: Record<string, unknown> = {
|
|
230
|
+
chat_id: chatId,
|
|
231
|
+
message_id: messageId,
|
|
232
|
+
text,
|
|
233
|
+
};
|
|
234
|
+
if (options.parseMode) body.parse_mode = options.parseMode;
|
|
235
|
+
if (options.replyMarkup) body.reply_markup = options.replyMarkup;
|
|
236
|
+
const res = await fetchFn(apiUrl(baseUrl, token, "editMessageText"), {
|
|
237
|
+
method: "POST",
|
|
238
|
+
headers: { "Content-Type": "application/json" },
|
|
239
|
+
body: JSON.stringify(body),
|
|
240
|
+
});
|
|
241
|
+
const json = await parseJsonOk(res);
|
|
242
|
+
const result = json?.result;
|
|
243
|
+
return {
|
|
244
|
+
ok: true,
|
|
245
|
+
// result may be the boolean `true` (unchanged body) — no message_id then.
|
|
246
|
+
messageId:
|
|
247
|
+
result && typeof result === "object"
|
|
248
|
+
? (result as { message_id?: number }).message_id
|
|
249
|
+
: undefined,
|
|
250
|
+
raw: result ?? undefined,
|
|
251
|
+
};
|
|
252
|
+
} catch (error) {
|
|
253
|
+
// Telegram rejects an edit that changes nothing with 400
|
|
254
|
+
// "message is not modified" (e.g. re-tapping a control button). That is
|
|
255
|
+
// an idempotent no-op, not a failure — report ok:true so callers don't
|
|
256
|
+
// log spurious warnings (pause-control status taps). parseJsonOk throws
|
|
257
|
+
// TelegramApiError before any body check runs, so inspect it here.
|
|
258
|
+
if (
|
|
259
|
+
error instanceof TelegramApiError &&
|
|
260
|
+
String(error.message).includes("message is not modified")
|
|
261
|
+
) {
|
|
262
|
+
return { ok: true, raw: undefined };
|
|
263
|
+
}
|
|
264
|
+
return { ok: false, error: toErrorMessage(error) };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Replace or remove the inline keyboard attached to a message — an empty
|
|
270
|
+
* `{ inline_keyboard: [] }` removes it (the decision-button single-use path
|
|
271
|
+
* strips the keyboard after the first tap). Same result contract as
|
|
272
|
+
* editMessageText: Telegram answers `result: Message | true`, and an
|
|
273
|
+
* identical body comes back as `true` (no-op, still ok). See
|
|
274
|
+
* editMessageReplyMarkup:
|
|
275
|
+
* https://core.telegram.org/bots/api#editmessagereplymarkup
|
|
276
|
+
*/
|
|
277
|
+
export async function editMessageReplyMarkup(
|
|
278
|
+
token: string,
|
|
279
|
+
chatId: number | string,
|
|
280
|
+
messageId: number,
|
|
281
|
+
options: TelegramSendOptions = {},
|
|
282
|
+
): Promise<TelegramSendResult> {
|
|
283
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
284
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
285
|
+
try {
|
|
286
|
+
const body: Record<string, unknown> = {
|
|
287
|
+
chat_id: chatId,
|
|
288
|
+
message_id: messageId,
|
|
289
|
+
};
|
|
290
|
+
if (options.replyMarkup) body.reply_markup = options.replyMarkup;
|
|
291
|
+
const res = await fetchFn(
|
|
292
|
+
apiUrl(baseUrl, token, "editMessageReplyMarkup"),
|
|
293
|
+
{
|
|
294
|
+
method: "POST",
|
|
295
|
+
headers: { "Content-Type": "application/json" },
|
|
296
|
+
body: JSON.stringify(body),
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
const json = await parseJsonOk(res);
|
|
300
|
+
const result = json?.result;
|
|
301
|
+
return {
|
|
302
|
+
ok: true,
|
|
303
|
+
messageId:
|
|
304
|
+
result && typeof result === "object"
|
|
305
|
+
? (result as { message_id?: number }).message_id
|
|
306
|
+
: undefined,
|
|
307
|
+
raw: result ?? undefined,
|
|
308
|
+
};
|
|
309
|
+
} catch (error) {
|
|
310
|
+
// Re-editing with identical markup is an idempotent no-op (Telegram
|
|
311
|
+
// replies 400 "message is not modified") — report ok:true like
|
|
312
|
+
// editMessageText does so callers don't log spurious warnings.
|
|
313
|
+
if (
|
|
314
|
+
error instanceof TelegramApiError &&
|
|
315
|
+
String(error.message).includes("message is not modified")
|
|
316
|
+
) {
|
|
317
|
+
return { ok: true, raw: undefined };
|
|
318
|
+
}
|
|
319
|
+
return { ok: false, error: toErrorMessage(error) };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Answer an inline-keyboard callback_query. The button tap
|
|
325
|
+
* shows a brief loading spinner on the client until this is answered;
|
|
326
|
+
* `text` shows as a toast (unless `showAlert: true`, which pops an alert).
|
|
327
|
+
* Bare `ok:true` result (Telegram returns no message).
|
|
328
|
+
* See answerCallbackQuery: https://core.telegram.org/bots/api#answercallbackquery
|
|
329
|
+
*/
|
|
330
|
+
export interface AnswerCallbackQueryOptions {
|
|
331
|
+
/** Toast/alert text shown to the user who tapped the button. */
|
|
332
|
+
text?: string;
|
|
333
|
+
/** true → show an alert dialog instead of a toast. Default false. */
|
|
334
|
+
showAlert?: boolean;
|
|
335
|
+
fetch?: TelegramFetch;
|
|
336
|
+
baseUrl?: string;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function answerCallbackQuery(
|
|
340
|
+
token: string,
|
|
341
|
+
callbackQueryId: string,
|
|
342
|
+
options: AnswerCallbackQueryOptions = {},
|
|
343
|
+
): Promise<TelegramActionResult> {
|
|
344
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
345
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
346
|
+
try {
|
|
347
|
+
const body: Record<string, unknown> = {
|
|
348
|
+
callback_query_id: callbackQueryId,
|
|
349
|
+
};
|
|
350
|
+
if (options.text) body.text = options.text;
|
|
351
|
+
if (options.showAlert !== undefined) body.show_alert = options.showAlert;
|
|
352
|
+
const res = await fetchFn(apiUrl(baseUrl, token, "answerCallbackQuery"), {
|
|
353
|
+
method: "POST",
|
|
354
|
+
headers: { "Content-Type": "application/json" },
|
|
355
|
+
body: JSON.stringify(body),
|
|
356
|
+
});
|
|
357
|
+
await parseJsonOk(res);
|
|
358
|
+
return { ok: true };
|
|
359
|
+
} catch (error) {
|
|
360
|
+
return { ok: false, error: toErrorMessage(error) };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Send a chat action (typing indicator, etc.) to keep the UI alive — used
|
|
366
|
+
* before the mid-turn live-update edits so the owner sees the bot is working.
|
|
367
|
+
* Bare `ok:true` result (Telegram returns no message).
|
|
368
|
+
* See sendChatAction: https://core.telegram.org/bots/api#sendchataction
|
|
369
|
+
*/
|
|
370
|
+
export async function sendChatAction(
|
|
371
|
+
token: string,
|
|
372
|
+
chatId: number | string,
|
|
373
|
+
action: string = "typing",
|
|
374
|
+
options: TelegramClientOptions = {},
|
|
375
|
+
): Promise<TelegramActionResult> {
|
|
376
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
377
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
378
|
+
try {
|
|
379
|
+
const body = { chat_id: chatId, action };
|
|
380
|
+
const res = await fetchFn(apiUrl(baseUrl, token, "sendChatAction"), {
|
|
381
|
+
method: "POST",
|
|
382
|
+
headers: { "Content-Type": "application/json" },
|
|
383
|
+
body: JSON.stringify(body),
|
|
384
|
+
});
|
|
385
|
+
await parseJsonOk(res);
|
|
386
|
+
return { ok: true };
|
|
387
|
+
} catch (error) {
|
|
388
|
+
return { ok: false, error: toErrorMessage(error) };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Outbound: voice note (OGG/Opus)
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Send a voice note via sendVoice (OGG/Opus), mirroring agent-zero's send_voice path.
|
|
396
|
+
* `oggPathOrBuffer` is either a filesystem path to an .ogg file or a Buffer of
|
|
397
|
+
* OGG/Opus bytes (multipart/form-data upload, filename voice.ogg).
|
|
398
|
+
*/
|
|
399
|
+
export async function sendVoiceNote(
|
|
400
|
+
token: string,
|
|
401
|
+
chatId: number | string,
|
|
402
|
+
oggPathOrBuffer: string | Buffer | Uint8Array,
|
|
403
|
+
caption?: string,
|
|
404
|
+
options: TelegramClientOptions = {},
|
|
405
|
+
): Promise<TelegramSendResult> {
|
|
406
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
407
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
408
|
+
try {
|
|
409
|
+
const form = new FormData();
|
|
410
|
+
form.append("chat_id", String(chatId));
|
|
411
|
+
if (caption && caption.trim()) form.append("caption", caption.trim());
|
|
412
|
+
const audio = await oggToBlob(oggPathOrBuffer);
|
|
413
|
+
form.append("voice", audio, "voice.ogg");
|
|
414
|
+
const res = await fetchFn(apiUrl(baseUrl, token, "sendVoice"), {
|
|
415
|
+
method: "POST",
|
|
416
|
+
body: form,
|
|
417
|
+
});
|
|
418
|
+
const json = await parseJsonOk(res);
|
|
419
|
+
return {
|
|
420
|
+
ok: true,
|
|
421
|
+
messageId: json?.result?.message_id as number | undefined,
|
|
422
|
+
raw: json?.result ?? undefined,
|
|
423
|
+
};
|
|
424
|
+
} catch (error) {
|
|
425
|
+
return { ok: false, error: toErrorMessage(error) };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function oggToBlob(
|
|
430
|
+
oggPathOrBuffer: string | Buffer | Uint8Array,
|
|
431
|
+
): Promise<Blob> {
|
|
432
|
+
let bytes: Uint8Array;
|
|
433
|
+
if (typeof oggPathOrBuffer === "string") {
|
|
434
|
+
const { readFile } = await import("node:fs/promises");
|
|
435
|
+
bytes = await readFile(oggPathOrBuffer);
|
|
436
|
+
} else {
|
|
437
|
+
bytes = oggPathOrBuffer;
|
|
438
|
+
}
|
|
439
|
+
// TS lib requires a Uint8Array<ArrayBuffer> for BlobPart; copy so the
|
|
440
|
+
// backing store is a plain ArrayBuffer (shared/foreign buffers are rejected
|
|
441
|
+
// by the typings even though they work at runtime).
|
|
442
|
+
const copy = new Uint8Array(bytes.byteLength);
|
|
443
|
+
copy.set(bytes);
|
|
444
|
+
return new Blob([copy], { type: "audio/ogg" });
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Inbound: long poll
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Fetch updates via getUpdates with offset (confirm-on-read) and optional
|
|
451
|
+
* long-poll `timeout` seconds. A timeout produces `{ ok: true, updates: [] }`.
|
|
452
|
+
* See: https://core.telegram.org/bots/api#getupdates
|
|
453
|
+
*/
|
|
454
|
+
export async function getUpdates(
|
|
455
|
+
token: string,
|
|
456
|
+
offset?: number,
|
|
457
|
+
timeout?: number,
|
|
458
|
+
options: TelegramClientOptions = {},
|
|
459
|
+
): Promise<TelegramGetUpdatesResult> {
|
|
460
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
461
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
462
|
+
const params = new URLSearchParams();
|
|
463
|
+
if (offset != null && Number.isFinite(offset))
|
|
464
|
+
params.set("offset", String(offset));
|
|
465
|
+
if (timeout != null && Number.isFinite(timeout))
|
|
466
|
+
params.set("timeout", String(timeout));
|
|
467
|
+
params.set("limit", "100");
|
|
468
|
+
const qs = params.toString();
|
|
469
|
+
const url = apiUrl(baseUrl, token, "getUpdates") + (qs ? `?${qs}` : "");
|
|
470
|
+
try {
|
|
471
|
+
const res = await fetchFn(url, { method: "GET" });
|
|
472
|
+
const json = await parseJsonOk(res);
|
|
473
|
+
const result = json?.result;
|
|
474
|
+
return {
|
|
475
|
+
ok: true,
|
|
476
|
+
updates: Array.isArray(result) ? (result as TelegramUpdate[]) : [],
|
|
477
|
+
};
|
|
478
|
+
} catch (error) {
|
|
479
|
+
return { ok: false, updates: [], error: toErrorMessage(error) };
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Inbound: media download (photo inbound, owner photo attachments)
|
|
484
|
+
|
|
485
|
+
/** Result of a getFile call (file metadata incl. the download file_path). */
|
|
486
|
+
export interface TelegramGetFileResult {
|
|
487
|
+
ok: boolean;
|
|
488
|
+
file?: {
|
|
489
|
+
file_id?: string;
|
|
490
|
+
file_unique_id?: string;
|
|
491
|
+
file_size?: number;
|
|
492
|
+
file_path?: string;
|
|
493
|
+
};
|
|
494
|
+
error?: string;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Result of a file-bytes download from the /file/bot<token>/<file_path> route. */
|
|
498
|
+
export interface TelegramDownloadResult {
|
|
499
|
+
ok: boolean;
|
|
500
|
+
bytes?: Uint8Array;
|
|
501
|
+
error?: string;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* getFile: resolve a file_id to its download metadata (file_path on the bot
|
|
506
|
+
* file CDN). Returns `{ ok, file?, error? }`; never throws. See:
|
|
507
|
+
* https://core.telegram.org/bots/api#getfile
|
|
508
|
+
*/
|
|
509
|
+
export async function getFile(
|
|
510
|
+
token: string,
|
|
511
|
+
fileId: string,
|
|
512
|
+
options: TelegramClientOptions = {},
|
|
513
|
+
): Promise<TelegramGetFileResult> {
|
|
514
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
515
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
516
|
+
const url =
|
|
517
|
+
apiUrl(baseUrl, token, "getFile") +
|
|
518
|
+
`?file_id=${encodeURIComponent(fileId)}`;
|
|
519
|
+
try {
|
|
520
|
+
const res = await fetchFn(url, { method: "GET" });
|
|
521
|
+
const json = await parseJsonOk(res);
|
|
522
|
+
return { ok: true, file: json?.result ?? undefined };
|
|
523
|
+
} catch (error) {
|
|
524
|
+
return { ok: false, error: toErrorMessage(error) };
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Download raw file bytes from the bot file CDN
|
|
530
|
+
* (`https://api.telegram.org/file/bot<token>/<file_path>`). Returns
|
|
531
|
+
* `{ ok, bytes?, error? }`; never throws. `filePath` is the `file_path` from
|
|
532
|
+
* getFile. See: https://core.telegram.org/bots/api#file
|
|
533
|
+
*/
|
|
534
|
+
export async function downloadFile(
|
|
535
|
+
token: string,
|
|
536
|
+
filePath: string,
|
|
537
|
+
options: TelegramClientOptions = {},
|
|
538
|
+
): Promise<TelegramDownloadResult> {
|
|
539
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
540
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
541
|
+
const url = `${baseUrl.replace(/\/$/, "")}/file/bot${token}/${filePath}`;
|
|
542
|
+
try {
|
|
543
|
+
const res = await fetchFn(url, { method: "GET" });
|
|
544
|
+
if (!res.ok) {
|
|
545
|
+
return { ok: false, error: `HTTP ${res.status} downloading ${filePath}` };
|
|
546
|
+
}
|
|
547
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
548
|
+
return { ok: true, bytes };
|
|
549
|
+
} catch (error) {
|
|
550
|
+
return { ok: false, error: toErrorMessage(error) };
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Validation
|
|
555
|
+
|
|
556
|
+
/** getMe: validates the token and returns bot identity. */
|
|
557
|
+
export async function getMe(
|
|
558
|
+
token: string,
|
|
559
|
+
options: TelegramClientOptions = {},
|
|
560
|
+
): Promise<TelegramGetMeResult> {
|
|
561
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
562
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
563
|
+
try {
|
|
564
|
+
const res = await fetchFn(apiUrl(baseUrl, token, "getMe"), {
|
|
565
|
+
method: "GET",
|
|
566
|
+
});
|
|
567
|
+
const json = await parseJsonOk(res);
|
|
568
|
+
return { ok: true, bot: json?.result ?? undefined };
|
|
569
|
+
} catch (error) {
|
|
570
|
+
return { ok: false, error: toErrorMessage(error) };
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* testToken: zero-failure token validation mirroring agent-zero's
|
|
576
|
+
* `api/test_connection.py`. Returns `{ ok, bot?, error?, httpStatus? }`; never
|
|
577
|
+
* throws. Primary pre-flight check before starting the inbound poller.
|
|
578
|
+
*/
|
|
579
|
+
export async function testToken(
|
|
580
|
+
token: string,
|
|
581
|
+
options: TelegramClientOptions = {},
|
|
582
|
+
): Promise<TelegramTestTokenResult> {
|
|
583
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
584
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
585
|
+
try {
|
|
586
|
+
const res = await fetchFn(apiUrl(baseUrl, token, "getMe"), {
|
|
587
|
+
method: "GET",
|
|
588
|
+
});
|
|
589
|
+
const status = res.status;
|
|
590
|
+
const json = await parseJsonOk(res);
|
|
591
|
+
return { ok: true, httpStatus: status, bot: json?.result ?? undefined };
|
|
592
|
+
} catch (error) {
|
|
593
|
+
const status = error instanceof TelegramApiError ? error.status : undefined;
|
|
594
|
+
return {
|
|
595
|
+
ok: false,
|
|
596
|
+
httpStatus: status,
|
|
597
|
+
error: toErrorMessage(error),
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export interface TelegramClient {
|
|
603
|
+
sendMessage(
|
|
604
|
+
token: string,
|
|
605
|
+
chatId: number | string,
|
|
606
|
+
text: string,
|
|
607
|
+
options?: TelegramSendOptions,
|
|
608
|
+
): Promise<TelegramSendResult>;
|
|
609
|
+
editMessageText(
|
|
610
|
+
token: string,
|
|
611
|
+
chatId: number | string,
|
|
612
|
+
messageId: number,
|
|
613
|
+
text: string,
|
|
614
|
+
options?: TelegramSendOptions,
|
|
615
|
+
): Promise<TelegramSendResult>;
|
|
616
|
+
editMessageReplyMarkup(
|
|
617
|
+
token: string,
|
|
618
|
+
chatId: number | string,
|
|
619
|
+
messageId: number,
|
|
620
|
+
options?: TelegramSendOptions,
|
|
621
|
+
): Promise<TelegramSendResult>;
|
|
622
|
+
answerCallbackQuery(
|
|
623
|
+
token: string,
|
|
624
|
+
callbackQueryId: string,
|
|
625
|
+
options?: AnswerCallbackQueryOptions,
|
|
626
|
+
): Promise<TelegramActionResult>;
|
|
627
|
+
sendChatAction(
|
|
628
|
+
token: string,
|
|
629
|
+
chatId: number | string,
|
|
630
|
+
action?: string,
|
|
631
|
+
options?: TelegramClientOptions,
|
|
632
|
+
): Promise<TelegramActionResult>;
|
|
633
|
+
sendVoiceNote(
|
|
634
|
+
token: string,
|
|
635
|
+
chatId: number | string,
|
|
636
|
+
oggPathOrBuffer: string | Buffer | Uint8Array,
|
|
637
|
+
caption?: string,
|
|
638
|
+
options?: TelegramClientOptions,
|
|
639
|
+
): Promise<TelegramSendResult>;
|
|
640
|
+
getUpdates(
|
|
641
|
+
token: string,
|
|
642
|
+
offset?: number,
|
|
643
|
+
timeout?: number,
|
|
644
|
+
options?: TelegramClientOptions,
|
|
645
|
+
): Promise<TelegramGetUpdatesResult>;
|
|
646
|
+
getFile(
|
|
647
|
+
token: string,
|
|
648
|
+
fileId: string,
|
|
649
|
+
options?: TelegramClientOptions,
|
|
650
|
+
): Promise<TelegramGetFileResult>;
|
|
651
|
+
downloadFile(
|
|
652
|
+
token: string,
|
|
653
|
+
filePath: string,
|
|
654
|
+
options?: TelegramClientOptions,
|
|
655
|
+
): Promise<TelegramDownloadResult>;
|
|
656
|
+
getMe(
|
|
657
|
+
token: string,
|
|
658
|
+
options?: TelegramClientOptions,
|
|
659
|
+
): Promise<TelegramGetMeResult>;
|
|
660
|
+
testToken(
|
|
661
|
+
token: string,
|
|
662
|
+
options?: TelegramClientOptions,
|
|
663
|
+
): Promise<TelegramTestTokenResult>;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Convenience factory binding one injectable fetch (and baseUrl) to every call.
|
|
668
|
+
* `createTelegramClient({ fetch: fakeFetch })` is the primary test seam.
|
|
669
|
+
*/
|
|
670
|
+
export function createTelegramClient(
|
|
671
|
+
options: TelegramClientOptions = {},
|
|
672
|
+
): TelegramClient {
|
|
673
|
+
return {
|
|
674
|
+
sendMessage: (token, chatId, text, perCall) =>
|
|
675
|
+
sendMessage(token, chatId, text, { ...options, ...perCall }),
|
|
676
|
+
editMessageText: (token, chatId, messageId, text, perCall) =>
|
|
677
|
+
editMessageText(token, chatId, messageId, text, {
|
|
678
|
+
...options,
|
|
679
|
+
...perCall,
|
|
680
|
+
}),
|
|
681
|
+
editMessageReplyMarkup: (token, chatId, messageId, perCall) =>
|
|
682
|
+
editMessageReplyMarkup(token, chatId, messageId, {
|
|
683
|
+
...options,
|
|
684
|
+
...perCall,
|
|
685
|
+
}),
|
|
686
|
+
answerCallbackQuery: (token, callbackQueryId, perCall) =>
|
|
687
|
+
answerCallbackQuery(token, callbackQueryId, { ...options, ...perCall }),
|
|
688
|
+
sendChatAction: (token, chatId, action) =>
|
|
689
|
+
sendChatAction(token, chatId, action, options),
|
|
690
|
+
sendVoiceNote: (token, chatId, ogg, caption) =>
|
|
691
|
+
sendVoiceNote(token, chatId, ogg, caption, options),
|
|
692
|
+
getUpdates: (token, offset, timeout) =>
|
|
693
|
+
getUpdates(token, offset, timeout, options),
|
|
694
|
+
getFile: (token, fileId) => getFile(token, fileId, options),
|
|
695
|
+
downloadFile: (token, filePath) => downloadFile(token, filePath, options),
|
|
696
|
+
getMe: (token) => getMe(token, options),
|
|
697
|
+
testToken: (token) => testToken(token, options),
|
|
698
|
+
};
|
|
699
|
+
}
|