@fastagent-sh/fastagent 0.12.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.
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/dist/agent.d.ts +72 -0
- package/dist/agent.js +14 -0
- package/dist/channels/body.d.ts +9 -0
- package/dist/channels/body.js +29 -0
- package/dist/channels/github/github.d.ts +31 -0
- package/dist/channels/github/github.js +80 -0
- package/dist/channels/github/scaffold/channel.ts +25 -0
- package/dist/channels/http.d.ts +13 -0
- package/dist/channels/http.js +161 -0
- package/dist/channels/respond.d.ts +5 -0
- package/dist/channels/respond.js +3 -0
- package/dist/channels/telegram/context-buffer.d.ts +59 -0
- package/dist/channels/telegram/context-buffer.js +139 -0
- package/dist/channels/telegram/invoke-turn.d.ts +53 -0
- package/dist/channels/telegram/invoke-turn.js +127 -0
- package/dist/channels/telegram/parse.d.ts +160 -0
- package/dist/channels/telegram/parse.js +153 -0
- package/dist/channels/telegram/preview.d.ts +22 -0
- package/dist/channels/telegram/preview.js +266 -0
- package/dist/channels/telegram/register-webhook.d.ts +13 -0
- package/dist/channels/telegram/register-webhook.js +73 -0
- package/dist/channels/telegram/scaffold/channel.ts +25 -0
- package/dist/channels/telegram/scaffold/telegram-send.ts +107 -0
- package/dist/channels/telegram/state.d.ts +9 -0
- package/dist/channels/telegram/state.js +58 -0
- package/dist/channels/telegram/telegram-api.d.ts +77 -0
- package/dist/channels/telegram/telegram-api.js +345 -0
- package/dist/channels/telegram/telegram.d.ts +35 -0
- package/dist/channels/telegram/telegram.js +352 -0
- package/dist/channels/telegram/turn-queue.d.ts +21 -0
- package/dist/channels/telegram/turn-queue.js +47 -0
- package/dist/channels/telegram/turn-store.d.ts +38 -0
- package/dist/channels/telegram/turn-store.js +150 -0
- package/dist/cli-auth.d.ts +24 -0
- package/dist/cli-auth.js +30 -0
- package/dist/cli-models.d.ts +11 -0
- package/dist/cli-models.js +20 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1288 -0
- package/dist/collect.d.ts +20 -0
- package/dist/collect.js +27 -0
- package/dist/core.d.ts +10 -0
- package/dist/core.js +8 -0
- package/dist/deploy/container.d.ts +56 -0
- package/dist/deploy/container.js +169 -0
- package/dist/deploy/fly/plan.d.ts +65 -0
- package/dist/deploy/fly/plan.js +142 -0
- package/dist/deploy/fly/run.d.ts +51 -0
- package/dist/deploy/fly/run.js +89 -0
- package/dist/deploy/preflight.d.ts +59 -0
- package/dist/deploy/preflight.js +191 -0
- package/dist/deploy/railway/plan.d.ts +48 -0
- package/dist/deploy/railway/plan.js +91 -0
- package/dist/deploy/railway/run.d.ts +81 -0
- package/dist/deploy/railway/run.js +176 -0
- package/dist/deploy/runner.d.ts +18 -0
- package/dist/deploy/runner.js +25 -0
- package/dist/deploy/secrets.d.ts +49 -0
- package/dist/deploy/secrets.js +88 -0
- package/dist/dev-supervisor.d.ts +14 -0
- package/dist/dev-supervisor.js +158 -0
- package/dist/engines/pi/auth.d.ts +17 -0
- package/dist/engines/pi/auth.js +118 -0
- package/dist/engines/pi/channel.d.ts +31 -0
- package/dist/engines/pi/channel.js +113 -0
- package/dist/engines/pi/chat.d.ts +19 -0
- package/dist/engines/pi/chat.js +200 -0
- package/dist/engines/pi/config.d.ts +110 -0
- package/dist/engines/pi/config.js +285 -0
- package/dist/engines/pi/create.d.ts +127 -0
- package/dist/engines/pi/create.js +222 -0
- package/dist/engines/pi/definition.d.ts +72 -0
- package/dist/engines/pi/definition.js +129 -0
- package/dist/engines/pi/harness.d.ts +50 -0
- package/dist/engines/pi/harness.js +41 -0
- package/dist/engines/pi/invoke.d.ts +28 -0
- package/dist/engines/pi/invoke.js +298 -0
- package/dist/engines/pi/login.d.ts +51 -0
- package/dist/engines/pi/login.js +125 -0
- package/dist/engines/pi/models.d.ts +41 -0
- package/dist/engines/pi/models.js +76 -0
- package/dist/engines/pi/report.d.ts +13 -0
- package/dist/engines/pi/report.js +21 -0
- package/dist/engines/pi/sessions.d.ts +15 -0
- package/dist/engines/pi/sessions.js +130 -0
- package/dist/engines/pi/tool-context.d.ts +17 -0
- package/dist/engines/pi/tool-context.js +13 -0
- package/dist/engines/pi/tool.d.ts +45 -0
- package/dist/engines/pi/tool.js +85 -0
- package/dist/engines/pi/wake-tool.d.ts +20 -0
- package/dist/engines/pi/wake-tool.js +110 -0
- package/dist/engines/pi/workspace.d.ts +53 -0
- package/dist/engines/pi/workspace.js +70 -0
- package/dist/env.d.ts +24 -0
- package/dist/env.js +57 -0
- package/dist/github.d.ts +3 -0
- package/dist/github.js +2 -0
- package/dist/host/node.d.ts +41 -0
- package/dist/host/node.js +50 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/invoke-stream.d.ts +10 -0
- package/dist/invoke-stream.js +26 -0
- package/dist/loader.d.ts +38 -0
- package/dist/loader.js +69 -0
- package/dist/log.d.ts +26 -0
- package/dist/log.js +51 -0
- package/dist/observe.d.ts +12 -0
- package/dist/observe.js +44 -0
- package/dist/pi.d.ts +16 -0
- package/dist/pi.js +12 -0
- package/dist/proxy.d.ts +9 -0
- package/dist/proxy.js +23 -0
- package/dist/runtime.d.ts +26 -0
- package/dist/runtime.js +29 -0
- package/dist/scaffold/add-channel.d.ts +46 -0
- package/dist/scaffold/add-channel.js +227 -0
- package/dist/scaffold/init.d.ts +54 -0
- package/dist/scaffold/init.js +249 -0
- package/dist/scaffold/templates/env.example +24 -0
- package/dist/scaffold/templates/fastagent.config.mjs +19 -0
- package/dist/scaffold/templates/gitignore +8 -0
- package/dist/scaffold/templates/gitignore.agentdir-root +5 -0
- package/dist/scaffold/templates/gitignore.kit +2 -0
- package/dist/scaffold/templates/persona.md +10 -0
- package/dist/scaffold/templates/skills/writing-great-skills/GLOSSARY.md +195 -0
- package/dist/scaffold/templates/skills/writing-great-skills/LICENSE +21 -0
- package/dist/scaffold/templates/skills/writing-great-skills/SKILL.md +82 -0
- package/dist/scaffold/templates/tools/fetch-url.ts +27 -0
- package/dist/scaffold/templates.d.ts +17 -0
- package/dist/scaffold/templates.js +56 -0
- package/dist/scaffold/vendor-skill.d.ts +24 -0
- package/dist/scaffold/vendor-skill.js +157 -0
- package/dist/schedule/audit.d.ts +18 -0
- package/dist/schedule/audit.js +57 -0
- package/dist/schedule/cron.d.ts +7 -0
- package/dist/schedule/cron.js +33 -0
- package/dist/schedule/discover.d.ts +17 -0
- package/dist/schedule/discover.js +64 -0
- package/dist/schedule/schedule.d.ts +35 -0
- package/dist/schedule/schedule.js +23 -0
- package/dist/schedule/scheduler.d.ts +36 -0
- package/dist/schedule/scheduler.js +253 -0
- package/dist/schedule/state.d.ts +14 -0
- package/dist/schedule/state.js +56 -0
- package/dist/schedule/wakeups.d.ts +82 -0
- package/dist/schedule/wakeups.js +196 -0
- package/dist/telegram.d.ts +2 -0
- package/dist/telegram.js +2 -0
- package/dist/tunnel.d.ts +31 -0
- package/dist/tunnel.js +139 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +13 -0
- package/dist/workspace.d.ts +9 -0
- package/dist/workspace.js +45 -0
- package/package.json +97 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram protocol parsing β PURE: message field extraction, the prompt envelope, and the summon/route
|
|
3
|
+
* policy. The defining invariant is purity: no state, no IO, no Bot API calls β plain data-in β data-out.
|
|
4
|
+
* In telegram.ts's pipeline (verify β decide via `route` β run the turn β stream reply), this is the
|
|
5
|
+
* "decide" and prompt-building half; telegram.ts wires it in and owns the stateful lifecycle. Kept
|
|
6
|
+
* separate so this layer tests as plain functions and reads without the factory's noise.
|
|
7
|
+
*/
|
|
8
|
+
/** A Telegram message (the common subset; `[k]` keeps the rest reachable without a types dependency). */
|
|
9
|
+
export interface TelegramMessage {
|
|
10
|
+
message_id: number;
|
|
11
|
+
text?: string;
|
|
12
|
+
/** Entities Telegram's server parsed out of `text` (mentions, commands, URLs, codeβ¦). Offsets are
|
|
13
|
+
* UTF-16 code units β exactly JS string indexing, so `text.slice(offset, offset + length)` is the
|
|
14
|
+
* entity's text verbatim. */
|
|
15
|
+
entities?: {
|
|
16
|
+
type: string;
|
|
17
|
+
offset: number;
|
|
18
|
+
length: number;
|
|
19
|
+
[k: string]: unknown;
|
|
20
|
+
}[];
|
|
21
|
+
/** Caption on a media message (photo/document/β¦) β often the user's instruction for the attachment. */
|
|
22
|
+
caption?: string;
|
|
23
|
+
/** Entities of `caption`, same shape as {@link TelegramMessage.entities}. */
|
|
24
|
+
caption_entities?: {
|
|
25
|
+
type: string;
|
|
26
|
+
offset: number;
|
|
27
|
+
length: number;
|
|
28
|
+
[k: string]: unknown;
|
|
29
|
+
}[];
|
|
30
|
+
/** Photo sizes, smallest β largest. The channel sends the largest to the model as a vision image. */
|
|
31
|
+
photo?: {
|
|
32
|
+
file_id: string;
|
|
33
|
+
file_unique_id: string;
|
|
34
|
+
width: number;
|
|
35
|
+
height: number;
|
|
36
|
+
file_size?: number;
|
|
37
|
+
}[];
|
|
38
|
+
/** Structured payloads worth rendering into the prompt as text (no new modality needed). */
|
|
39
|
+
location?: {
|
|
40
|
+
latitude: number;
|
|
41
|
+
longitude: number;
|
|
42
|
+
[k: string]: unknown;
|
|
43
|
+
};
|
|
44
|
+
contact?: {
|
|
45
|
+
phone_number?: string;
|
|
46
|
+
first_name: string;
|
|
47
|
+
last_name?: string;
|
|
48
|
+
[k: string]: unknown;
|
|
49
|
+
};
|
|
50
|
+
poll?: {
|
|
51
|
+
question: string;
|
|
52
|
+
options?: {
|
|
53
|
+
text: string;
|
|
54
|
+
}[];
|
|
55
|
+
[k: string]: unknown;
|
|
56
|
+
};
|
|
57
|
+
/** Files β the channel downloads document/voice/video/audio on a routed message to disk. */
|
|
58
|
+
document?: {
|
|
59
|
+
file_id: string;
|
|
60
|
+
file_name?: string;
|
|
61
|
+
mime_type?: string;
|
|
62
|
+
file_size?: number;
|
|
63
|
+
[k: string]: unknown;
|
|
64
|
+
};
|
|
65
|
+
voice?: {
|
|
66
|
+
file_id: string;
|
|
67
|
+
[k: string]: unknown;
|
|
68
|
+
};
|
|
69
|
+
video?: {
|
|
70
|
+
file_id: string;
|
|
71
|
+
[k: string]: unknown;
|
|
72
|
+
};
|
|
73
|
+
audio?: {
|
|
74
|
+
file_id: string;
|
|
75
|
+
[k: string]: unknown;
|
|
76
|
+
};
|
|
77
|
+
/** Present in Threaded Mode (topics in private chats); reply with the same id to stay in-thread. */
|
|
78
|
+
message_thread_id?: number;
|
|
79
|
+
/** The message this one replies to, if any β inject its text/media so the agent has the referent. */
|
|
80
|
+
reply_to_message?: TelegramMessage;
|
|
81
|
+
chat: {
|
|
82
|
+
id: number;
|
|
83
|
+
type: string;
|
|
84
|
+
[k: string]: unknown;
|
|
85
|
+
};
|
|
86
|
+
from?: {
|
|
87
|
+
id: number;
|
|
88
|
+
username?: string;
|
|
89
|
+
is_bot?: boolean;
|
|
90
|
+
first_name?: string;
|
|
91
|
+
[k: string]: unknown;
|
|
92
|
+
};
|
|
93
|
+
[k: string]: unknown;
|
|
94
|
+
}
|
|
95
|
+
/** A Telegram update (the common subset the channel ACTS on β an update kind not listed here is ACKed
|
|
96
|
+
* and dropped before `route` sees it, so listing it would be a false promise; `[k]` keeps the raw
|
|
97
|
+
* payload reachable). Narrow for what you route on, e.g. `update.message?.text`. */
|
|
98
|
+
export interface TelegramUpdate {
|
|
99
|
+
update_id: number;
|
|
100
|
+
message?: TelegramMessage;
|
|
101
|
+
channel_post?: TelegramMessage;
|
|
102
|
+
[k: string]: unknown;
|
|
103
|
+
}
|
|
104
|
+
/** What `route` returns: act with these (every field optional β omitted ones default from the message), or null to ignore. */
|
|
105
|
+
export interface TelegramRoute {
|
|
106
|
+
/** Conversation identity (default: `chat` or `chat:thread`). */
|
|
107
|
+
session?: string;
|
|
108
|
+
/** Reply target chat (default: the message's chat). */
|
|
109
|
+
chatId?: number | string;
|
|
110
|
+
/** Reply thread (default: the message's thread). */
|
|
111
|
+
threadId?: number;
|
|
112
|
+
/** Base prompt (default: {@link telegramEnvelope}); the channel still appends attachments + the HTML hint. */
|
|
113
|
+
text?: string;
|
|
114
|
+
}
|
|
115
|
+
/** The actionable message in an update (a fresh message or channel post). Edits (`edited_message` /
|
|
116
|
+
* `edited_channel_post`) are deliberately NOT actionable: answering them re-answers every typo fix (a
|
|
117
|
+
* duplicate reply per edit), so an edited message changes nothing β the standard bot behavior. The
|
|
118
|
+
* trade-off: editing a mention INTO an old message does not summon either; send a new message. */
|
|
119
|
+
export declare function pickMessage(update: TelegramUpdate): TelegramMessage | undefined;
|
|
120
|
+
/** file_ids to send the model as vision images: this message's largest photo + a replied-to photo. */
|
|
121
|
+
export declare function extractImages(m: TelegramMessage): string[];
|
|
122
|
+
/** The message's OWN photo (largest size), without the replied-to message's β the buffer path uses
|
|
123
|
+
* this: each message is its own entry there, so counting the replied-to attachment again would
|
|
124
|
+
* duplicate it and squeeze the attachment cap (the reply relation is expressed by `replyTo` instead). */
|
|
125
|
+
export declare function ownImages(m: TelegramMessage): string[];
|
|
126
|
+
/** file_ids to download to disk for the agent's tools: this message's files + a replied-to message's
|
|
127
|
+
* (so "summarize this file" works when the user replies to a document with the mention). */
|
|
128
|
+
export declare function extractFiles(m: TelegramMessage): string[];
|
|
129
|
+
/** The message's OWN files, without the replied-to message's (see {@link ownImages} for why). */
|
|
130
|
+
export declare function ownFiles(m: TelegramMessage): string[];
|
|
131
|
+
/** A stable sender label for attribution. In a shared (multi-user) session the model must tell who is
|
|
132
|
+
* who across turns; a username-less user still gets a name + id rather than vanishing. */
|
|
133
|
+
export declare function fromLabel(from: TelegramMessage["from"]): string | undefined;
|
|
134
|
+
/** A one-line description of a message's attachment, so the envelope names what was sent even before
|
|
135
|
+
* the agent opens it (and so a media-only message isn't blank). */
|
|
136
|
+
export declare function attachmentSummary(m: TelegramMessage): string | undefined;
|
|
137
|
+
/** A one-line, length-capped rendering of a message's content for the context buffer. */
|
|
138
|
+
export declare function messageText(m: TelegramMessage): string;
|
|
139
|
+
/**
|
|
140
|
+
* The default base prompt: a context envelope (chat/thread/sender + a group note + reply) then the
|
|
141
|
+
* user's text/caption and a compact rendering of structured payloads (location/contact/poll). The
|
|
142
|
+
* sender is named on every message and a group chat is flagged β in a shared multi-user session that is
|
|
143
|
+
* how the model tells participants apart and knows it is not a 1:1. The reply
|
|
144
|
+
* block carries the replied-to sender, message id, and text/caption or an attachment summary (and the
|
|
145
|
+
* channel downloads a replied-to file/photo too). Exported so a custom `route` can reuse it, e.g.
|
|
146
|
+
* `text: `${telegramEnvelope(m)}\n\n[extra]``. The channel still appends downloaded attachments.
|
|
147
|
+
*/
|
|
148
|
+
export declare function telegramEnvelope(m: TelegramMessage): string;
|
|
149
|
+
/**
|
|
150
|
+
* The default routing policy (used when `route` is omitted; exported so a custom route can reuse it):
|
|
151
|
+
* answer private chats always; a group only on a reply to THIS bot (by `botId`) or a `mention` entity
|
|
152
|
+
* naming it (when `botUsername` is supplied β telegramChannel parses the id from the token and resolves
|
|
153
|
+
* the username via getMe). A bare or directed slash command does NOT summon in a group (that was noisy;
|
|
154
|
+
* a bot author who wants commands adds a custom route). Returns `{}` (act; the channel fills
|
|
155
|
+
* session/target/prompt from the message) or `null` (ignore).
|
|
156
|
+
*/
|
|
157
|
+
export declare function defaultTelegramRoute(update: TelegramUpdate, options?: {
|
|
158
|
+
botUsername?: string;
|
|
159
|
+
botId?: number;
|
|
160
|
+
}): TelegramRoute | null;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram protocol parsing β PURE: message field extraction, the prompt envelope, and the summon/route
|
|
3
|
+
* policy. The defining invariant is purity: no state, no IO, no Bot API calls β plain data-in β data-out.
|
|
4
|
+
* In telegram.ts's pipeline (verify β decide via `route` β run the turn β stream reply), this is the
|
|
5
|
+
* "decide" and prompt-building half; telegram.ts wires it in and owns the stateful lifecycle. Kept
|
|
6
|
+
* separate so this layer tests as plain functions and reads without the factory's noise.
|
|
7
|
+
*/
|
|
8
|
+
/** The actionable message in an update (a fresh message or channel post). Edits (`edited_message` /
|
|
9
|
+
* `edited_channel_post`) are deliberately NOT actionable: answering them re-answers every typo fix (a
|
|
10
|
+
* duplicate reply per edit), so an edited message changes nothing β the standard bot behavior. The
|
|
11
|
+
* trade-off: editing a mention INTO an old message does not summon either; send a new message. */
|
|
12
|
+
export function pickMessage(update) {
|
|
13
|
+
return update.message ?? update.channel_post;
|
|
14
|
+
}
|
|
15
|
+
/** file_ids to send the model as vision images: this message's largest photo + a replied-to photo. */
|
|
16
|
+
export function extractImages(m) {
|
|
17
|
+
return [...ownImages(m), ...(m.reply_to_message ? ownImages(m.reply_to_message) : [])];
|
|
18
|
+
}
|
|
19
|
+
/** The message's OWN photo (largest size), without the replied-to message's β the buffer path uses
|
|
20
|
+
* this: each message is its own entry there, so counting the replied-to attachment again would
|
|
21
|
+
* duplicate it and squeeze the attachment cap (the reply relation is expressed by `replyTo` instead). */
|
|
22
|
+
export function ownImages(m) {
|
|
23
|
+
return [m.photo?.at(-1)?.file_id].filter((id) => Boolean(id));
|
|
24
|
+
}
|
|
25
|
+
/** file_ids to download to disk for the agent's tools: this message's files + a replied-to message's
|
|
26
|
+
* (so "summarize this file" works when the user replies to a document with the mention). */
|
|
27
|
+
export function extractFiles(m) {
|
|
28
|
+
return [...ownFiles(m), ...(m.reply_to_message ? ownFiles(m.reply_to_message) : [])];
|
|
29
|
+
}
|
|
30
|
+
/** The message's OWN files, without the replied-to message's (see {@link ownImages} for why). */
|
|
31
|
+
export function ownFiles(m) {
|
|
32
|
+
return [m.document?.file_id, m.voice?.file_id, m.video?.file_id, m.audio?.file_id].filter((id) => Boolean(id));
|
|
33
|
+
}
|
|
34
|
+
/** A stable sender label for attribution. In a shared (multi-user) session the model must tell who is
|
|
35
|
+
* who across turns; a username-less user still gets a name + id rather than vanishing. */
|
|
36
|
+
export function fromLabel(from) {
|
|
37
|
+
if (!from)
|
|
38
|
+
return undefined;
|
|
39
|
+
return from.username ? `@${from.username}` : `${from.first_name ?? "user"} (id ${from.id})`;
|
|
40
|
+
}
|
|
41
|
+
/** A one-line description of a message's attachment, so the envelope names what was sent even before
|
|
42
|
+
* the agent opens it (and so a media-only message isn't blank). */
|
|
43
|
+
export function attachmentSummary(m) {
|
|
44
|
+
if (m.photo?.length)
|
|
45
|
+
return "[photo]";
|
|
46
|
+
if (m.document) {
|
|
47
|
+
return `[document: ${m.document.file_name ?? "file"}${m.document.mime_type ? ` (${m.document.mime_type})` : ""}]`;
|
|
48
|
+
}
|
|
49
|
+
if (m.voice)
|
|
50
|
+
return "[voice message]";
|
|
51
|
+
if (m.video)
|
|
52
|
+
return "[video]";
|
|
53
|
+
if (m.audio)
|
|
54
|
+
return "[audio]";
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
/** A message's readable body: its text, else its caption, else a one-line attachment summary; undefined
|
|
58
|
+
* for an empty/service message. The single source for "what did this message say" β envelope, reply-
|
|
59
|
+
* quote, and the context-buffer line all read through it so their fallbacks cannot drift apart. */
|
|
60
|
+
function bodyOf(m) {
|
|
61
|
+
return m.text ?? m.caption ?? attachmentSummary(m);
|
|
62
|
+
}
|
|
63
|
+
/** A one-line, length-capped rendering of a message's content for the context buffer. */
|
|
64
|
+
export function messageText(m) {
|
|
65
|
+
return (bodyOf(m) ?? "").replace(/\s+/g, " ").trim().slice(0, 280);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The default base prompt: a context envelope (chat/thread/sender + a group note + reply) then the
|
|
69
|
+
* user's text/caption and a compact rendering of structured payloads (location/contact/poll). The
|
|
70
|
+
* sender is named on every message and a group chat is flagged β in a shared multi-user session that is
|
|
71
|
+
* how the model tells participants apart and knows it is not a 1:1. The reply
|
|
72
|
+
* block carries the replied-to sender, message id, and text/caption or an attachment summary (and the
|
|
73
|
+
* channel downloads a replied-to file/photo too). Exported so a custom `route` can reuse it, e.g.
|
|
74
|
+
* `text: `${telegramEnvelope(m)}\n\n[extra]``. The channel still appends downloaded attachments.
|
|
75
|
+
*/
|
|
76
|
+
export function telegramEnvelope(m) {
|
|
77
|
+
const r = m.reply_to_message;
|
|
78
|
+
const meta = [
|
|
79
|
+
`chat ${m.chat.id} (${m.chat.type})`,
|
|
80
|
+
m.message_thread_id ? `thread ${m.message_thread_id}` : undefined,
|
|
81
|
+
fromLabel(m.from) ? `from ${fromLabel(m.from)}` : undefined,
|
|
82
|
+
]
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.join(", ");
|
|
85
|
+
// In a shared group session the model sees turns from different people (each `from`-tagged); tell it
|
|
86
|
+
// so it addresses participants by name and does not assume one continuous interlocutor. A 1:1 DM is
|
|
87
|
+
// self-evident, so no note there.
|
|
88
|
+
const isGroup = m.chat.type === "group" || m.chat.type === "supergroup";
|
|
89
|
+
const scope = isGroup ? "\n[group chat β multiple people; each message is prefixed with its sender]" : "";
|
|
90
|
+
const replyTo = r
|
|
91
|
+
? `\n[in reply to ${fromLabel(r.from) ?? `msg ${r.message_id}`} (msg ${r.message_id}): ${(bodyOf(r) ?? "(empty)").slice(0, 280)}]`
|
|
92
|
+
: "";
|
|
93
|
+
const parts = [bodyOf(m) ?? ""];
|
|
94
|
+
if (m.location)
|
|
95
|
+
parts.push(`[location: ${m.location.latitude},${m.location.longitude}]`);
|
|
96
|
+
if (m.contact)
|
|
97
|
+
parts.push(`[contact: ${m.contact.first_name} ${m.contact.phone_number ?? ""}]`);
|
|
98
|
+
if (m.poll)
|
|
99
|
+
parts.push(`[poll: ${m.poll.question} β ${(m.poll.options ?? []).map((o) => o.text).join(" / ")}]`);
|
|
100
|
+
return `[telegram: ${meta}]${scope}${replyTo}\n${parts.filter(Boolean).join("\n")}`;
|
|
101
|
+
}
|
|
102
|
+
/** Normalize a configured bot username: drop a leading `@`, trim, lowercase (usernames are case-
|
|
103
|
+
* insensitive). Undefined when unknown. */
|
|
104
|
+
function botName(botUsername) {
|
|
105
|
+
const s = botUsername?.replace(/^@/, "").trim().toLowerCase();
|
|
106
|
+
return s || undefined;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Whether the message @mentions the bot β read from the `mention` ENTITIES Telegram's server already
|
|
110
|
+
* parsed, not a regex over the raw text. The entity type excludes by construction what a text scan
|
|
111
|
+
* false-matches: `@bot` inside a code block or a URL is not a `mention` entity, a glued `/cmd@bot` is a
|
|
112
|
+
* `bot_command` β and slicing the exact offset/length range makes `@fast` vs `@fastagent` confusion
|
|
113
|
+
* impossible. (Offsets are UTF-16 code units = native JS string indexing.) No text fallback: mention
|
|
114
|
+
* entities are produced server-side, so their absence means there is no mention.
|
|
115
|
+
*/
|
|
116
|
+
function mentionsBot(m, botUsername) {
|
|
117
|
+
const name = botName(botUsername);
|
|
118
|
+
if (!name)
|
|
119
|
+
return false;
|
|
120
|
+
const text = m.text ?? m.caption ?? "";
|
|
121
|
+
const entities = (m.text !== undefined ? m.entities : m.caption_entities) ?? [];
|
|
122
|
+
return entities.some((e) => e.type === "mention" && text.slice(e.offset, e.offset + e.length).toLowerCase() === `@${name}`);
|
|
123
|
+
}
|
|
124
|
+
/** Whether the message replies to THIS bot β not just any bot: in a multi-bot group, replying to
|
|
125
|
+
* another bot must not summon ours. Identity is the bot's numeric id (stable; a username is a mutable
|
|
126
|
+
* handle) β telegramChannel parses it synchronously from the token, so there is no resolution race.
|
|
127
|
+
* Without an id, fall back to username; with NEITHER, fail closed (false): answering "is this a reply
|
|
128
|
+
* to me?" with "I don't know who I am, so yes" would mis-summon in every multi-bot group β a caller
|
|
129
|
+
* reusing the route bare must supply an identity to get reply summon. */
|
|
130
|
+
function repliesToBot(m, options) {
|
|
131
|
+
const r = m.reply_to_message?.from;
|
|
132
|
+
if (r?.is_bot !== true)
|
|
133
|
+
return false;
|
|
134
|
+
if (options?.botId !== undefined)
|
|
135
|
+
return r.id === options.botId;
|
|
136
|
+
const name = botName(options?.botUsername);
|
|
137
|
+
return name !== undefined && r.username?.toLowerCase() === name;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The default routing policy (used when `route` is omitted; exported so a custom route can reuse it):
|
|
141
|
+
* answer private chats always; a group only on a reply to THIS bot (by `botId`) or a `mention` entity
|
|
142
|
+
* naming it (when `botUsername` is supplied β telegramChannel parses the id from the token and resolves
|
|
143
|
+
* the username via getMe). A bare or directed slash command does NOT summon in a group (that was noisy;
|
|
144
|
+
* a bot author who wants commands adds a custom route). Returns `{}` (act; the channel fills
|
|
145
|
+
* session/target/prompt from the message) or `null` (ignore).
|
|
146
|
+
*/
|
|
147
|
+
export function defaultTelegramRoute(update, options) {
|
|
148
|
+
const m = pickMessage(update);
|
|
149
|
+
if (!m)
|
|
150
|
+
return null;
|
|
151
|
+
const summoned = m.chat.type === "private" || repliesToBot(m, options) || mentionsBot(m, options?.botUsername);
|
|
152
|
+
return summoned ? {} : null;
|
|
153
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-preview rendering: consume one turn's event stream into a Telegram chat. A single real message
|
|
3
|
+
* is sent once ("π Thinkingβ¦", or an already-sent "β³ queued" notice is taken over), then
|
|
4
|
+
* editMessageText'd in place with the latest tool calls + partial text (PLAIN β a partial answer may
|
|
5
|
+
* carry unbalanced HTML); on completion the same message is edited into the final answer as HTML. One
|
|
6
|
+
* message, works in groups and private (unlike sendMessageDraft, which is private/forum-topic only).
|
|
7
|
+
*/
|
|
8
|
+
import type { AgentEvent } from "../../agent.ts";
|
|
9
|
+
import { type Target } from "./telegram-api.ts";
|
|
10
|
+
/** A terminal failure, as the channel hands it to `onError`. */
|
|
11
|
+
export interface TelegramFailure {
|
|
12
|
+
details: string;
|
|
13
|
+
retryable: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** The customer-facing default: neutral, no leaked internals; differentiate only on whether to retry. */
|
|
16
|
+
export declare function defaultErrorMessage(failed: TelegramFailure): string;
|
|
17
|
+
/**
|
|
18
|
+
* Consume one turn's event stream into a Telegram chat, live (see the module header for the preview
|
|
19
|
+
* model). Preview edits are best-effort (logged once if they fail); the final write is authoritative
|
|
20
|
+
* and surfaces a real failure (bad token, etc.).
|
|
21
|
+
*/
|
|
22
|
+
export declare function streamReply(events: AsyncIterable<AgentEvent>, api: string, botToken: string, target: Target, formatError: (failed: TelegramFailure) => string | undefined, previewId?: number): Promise<void>;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { log } from "../../log.js";
|
|
2
|
+
import { TELEGRAM_MAX_TEXT, callApi, editMessageText, sendMessage } from "./telegram-api.js";
|
|
3
|
+
/** The customer-facing default: neutral, no leaked internals; differentiate only on whether to retry. */
|
|
4
|
+
export function defaultErrorMessage(failed) {
|
|
5
|
+
return failed.retryable ? "β οΈ Temporary problem β please try again." : "β οΈ Sorry, something went wrong.";
|
|
6
|
+
}
|
|
7
|
+
/** How often (ms) to edit the live-preview message; tool events still flush on the next loop. Edits to
|
|
8
|
+
* one message are rate-limited tighter than sends, so pace them ~1.5s (vs every token). Doubles as the
|
|
9
|
+
* answer-preview aging window (see answerView): partial answer text stays hidden until it has existed
|
|
10
|
+
* this long β one knob, same order of magnitude. */
|
|
11
|
+
const EDIT_THROTTLE_MS = 1500;
|
|
12
|
+
/** Max length of a tool's arg preview in the live view. */
|
|
13
|
+
const TOOL_ARG_MAX = 48;
|
|
14
|
+
/** How much of the (growing) reasoning to peek at in the live view β the most recent tail. */
|
|
15
|
+
const THINKING_PREVIEW = 280;
|
|
16
|
+
/** One-line, truncated: collapse whitespace so a multi-line command/arg stays on one line. */
|
|
17
|
+
function clip(s) {
|
|
18
|
+
const one = s.replace(/\s+/g, " ").trim();
|
|
19
|
+
return one.length > TOOL_ARG_MAX ? `${one.slice(0, TOOL_ARG_MAX - 1)}β¦` : one;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A compact, human-readable preview of a tool call's args so the live view reads `π§ read AGENTS.md`
|
|
23
|
+
* rather than just `π§ read`. Generic (the channel knows no tool schemas): show the salient value β the
|
|
24
|
+
* first primitive field, conventionally the subject (path / command / query / url) β else compact JSON.
|
|
25
|
+
*/
|
|
26
|
+
function summarizeArgs(args) {
|
|
27
|
+
if (args === null || typeof args !== "object" || Array.isArray(args))
|
|
28
|
+
return clip(String(args));
|
|
29
|
+
const values = Object.values(args);
|
|
30
|
+
const primary = values.find((v) => typeof v === "string" || typeof v === "number");
|
|
31
|
+
if (primary !== undefined)
|
|
32
|
+
return clip(String(primary));
|
|
33
|
+
return values.length > 0 ? clip(JSON.stringify(args)) : "";
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The terminal-write POLICY: resolve the single preview message into `text`. streamReply owns the
|
|
37
|
+
* preview lifecycle, so this composition of transport primitives lives here, not in telegram-api. One
|
|
38
|
+
* message β edit the preview in place; if the edit fails (preview gone, or a persistent 429/5xx) fall
|
|
39
|
+
* back to deleting the placeholder and sending fresh, so no "Thinkingβ¦" is left pinned above the answer.
|
|
40
|
+
* Many messages β delete the preview and send the whole answer as consecutive fresh messages (editing
|
|
41
|
+
* would pin the first chunk where an active group has scrolled past). No preview β fresh send. EMPTY text
|
|
42
|
+
* = "say nothing" β just delete the preview.
|
|
43
|
+
*/
|
|
44
|
+
async function finalize(api, botToken, target, messageId, text, opts = {}) {
|
|
45
|
+
const html = opts.html ?? true;
|
|
46
|
+
if (text.trim() === "") {
|
|
47
|
+
if (messageId !== undefined)
|
|
48
|
+
await callApi(api, botToken, "deleteMessage", { chat_id: target.chatId, message_id: messageId });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (messageId !== undefined) {
|
|
52
|
+
// "Fits in one message" is a plain length check against Telegram's limit β not chunkText, whose html
|
|
53
|
+
// mode exists for SPLIT chunks and is irrelevant to an un-split text.
|
|
54
|
+
if (text.length <= TELEGRAM_MAX_TEXT) {
|
|
55
|
+
try {
|
|
56
|
+
await editMessageText(api, botToken, target, messageId, text, { html });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Edit failed β the preview may be gone, or still there (429 retries exhausted / 5xx). Fall through
|
|
61
|
+
// to delete + fresh send below so a still-present "Thinkingβ¦" is not left pinned above the answer.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Too long for one message, OR a failed single-message edit: remove the placeholder best-effort (a lingering one above
|
|
65
|
+
// the answer is worse than the extra call), then send the whole reply as fresh, consecutive messages.
|
|
66
|
+
await callApi(api, botToken, "deleteMessage", { chat_id: target.chatId, message_id: messageId }).catch(() => { });
|
|
67
|
+
}
|
|
68
|
+
await sendMessage(api, botToken, target, text, { html });
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Consume one turn's event stream into a Telegram chat, live (see the module header for the preview
|
|
72
|
+
* model). Preview edits are best-effort (logged once if they fail); the final write is authoritative
|
|
73
|
+
* and surfaces a real failure (bad token, etc.).
|
|
74
|
+
*/
|
|
75
|
+
export async function streamReply(events, api, botToken, target, formatError, previewId) {
|
|
76
|
+
const tools = [];
|
|
77
|
+
const toolIndexById = new Map();
|
|
78
|
+
let thinking = "";
|
|
79
|
+
let answer = "";
|
|
80
|
+
let answerPreviewSince;
|
|
81
|
+
const mark = { running: "β¦", ok: "β", error: "β" };
|
|
82
|
+
const toolView = () => tools.map((t) => `π§ ${t.label} ${mark[t.status]}`).join("\n");
|
|
83
|
+
// Reasoning is process, not the answer: shown (capped to its tail) in the live preview only, never
|
|
84
|
+
// in the persisted final message (which is `answer` alone).
|
|
85
|
+
const thinkingView = () => {
|
|
86
|
+
const t = thinking.replace(/\s+/g, " ").trim();
|
|
87
|
+
if (t === "")
|
|
88
|
+
return "";
|
|
89
|
+
return `π ${t.length > THINKING_PREVIEW ? `β¦${t.slice(t.length - THINKING_PREVIEW + 1)}` : t}`;
|
|
90
|
+
};
|
|
91
|
+
// The answer is hidden until its first delta has aged one EDIT_THROTTLE_MS: the pump's leading-edge
|
|
92
|
+
// flush would otherwise turn the very first content delta (often a lone character or unbalanced markup)
|
|
93
|
+
// into its own Telegram edit β the short-reply flicker (placeholder β "O" β "OK."). Aging is anchored
|
|
94
|
+
// at delta ARRIVAL (set in the event loop, not here) so an in-flight edit can't skew the clock, and
|
|
95
|
+
// there is deliberately NO timer at the boundary: a young answer surfaces on the next content-driven
|
|
96
|
+
// preview pass, so a turn completing within the window sends the final answer edit only.
|
|
97
|
+
const answerView = () => {
|
|
98
|
+
if (answer.trim() === "" || answerPreviewSince === undefined)
|
|
99
|
+
return "";
|
|
100
|
+
return Date.now() - answerPreviewSince >= EDIT_THROTTLE_MS ? answer : "";
|
|
101
|
+
};
|
|
102
|
+
const view = () => {
|
|
103
|
+
const v = [thinkingView(), toolView(), answerView()]
|
|
104
|
+
.filter((s) => s.trim() !== "")
|
|
105
|
+
.join("\n\n")
|
|
106
|
+
.trim();
|
|
107
|
+
// Before any reasoning/tool/text arrives, show an explicit placeholder rather than an empty edit.
|
|
108
|
+
return v === "" ? "π Thinkingβ¦" : v;
|
|
109
|
+
};
|
|
110
|
+
// The live preview is ONE real message: sent once (capturing its id + threading under the asker),
|
|
111
|
+
// then edited in place. messageId/lastSent are shared with the final write on completion.
|
|
112
|
+
// `previewId`: an already-sent message to take over as the preview (the "β³ queued" notice) β the
|
|
113
|
+
// pump edits it in place, so the queue notice morphs into the live view instead of leaving an orphan.
|
|
114
|
+
let messageId = previewId;
|
|
115
|
+
let previewSent = messageId !== undefined; // a placeholder send was attempted β guards against re-sending when no id came back
|
|
116
|
+
let finalized = false; // a terminal write (completed/failed) ran β the finally skips its orphan cleanup
|
|
117
|
+
let lastSent = "";
|
|
118
|
+
const flushPreview = async () => {
|
|
119
|
+
const text = view();
|
|
120
|
+
if (text === lastSent)
|
|
121
|
+
return; // skip an unchanged edit (Telegram rejects "message is not modified")
|
|
122
|
+
lastSent = text;
|
|
123
|
+
if (messageId !== undefined) {
|
|
124
|
+
await editMessageText(api, botToken, target, messageId, text); // plain β a partial answer may carry unbalanced HTML
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// No preview message yet. Send the placeholder ONCE; never re-send (that would spam a new message per
|
|
128
|
+
// frame). If Telegram returns ok WITHOUT a message_id (proxy / odd API base / unparseable body) we
|
|
129
|
+
// cannot edit β fail visibly and stop previewing (the final write still lands via finalize).
|
|
130
|
+
if (previewSent)
|
|
131
|
+
return;
|
|
132
|
+
previewSent = true;
|
|
133
|
+
messageId = await sendMessage(api, botToken, target, text, { html: false });
|
|
134
|
+
if (messageId === undefined)
|
|
135
|
+
throw new Error("telegram sendMessage returned ok without a message_id β live preview disabled for this turn");
|
|
136
|
+
};
|
|
137
|
+
// ββ Live-preview pump: a SINGLE serialized writer. ββββββββββββββββββββββββββββββββββββββββββ
|
|
138
|
+
// Events mutate state (thinking / tools / answer) and mark the preview dirty; the pump edits the
|
|
139
|
+
// message to the LATEST view() with at most ONE edit in flight, paced by a throttle. One-in-flight is
|
|
140
|
+
// the whole point: concurrent edits can reach Telegram out of order β an older frame landing over a
|
|
141
|
+
// newer one is the "shows 3-4 steps, blanks, re-fills" flicker. Serializing keeps frames monotonic.
|
|
142
|
+
// (No keepalive: a real message does not expire, unlike a Bot API `sendMessageDraft` (30s window).)
|
|
143
|
+
let dirty = false;
|
|
144
|
+
let pumping = false;
|
|
145
|
+
let stopped = false;
|
|
146
|
+
let previewErrLogged = false;
|
|
147
|
+
let pumpDone;
|
|
148
|
+
let wakeThrottle; // set while the pump is mid-throttle; finish() cuts it short
|
|
149
|
+
const runPump = async () => {
|
|
150
|
+
pumping = true;
|
|
151
|
+
try {
|
|
152
|
+
while (dirty && !stopped) {
|
|
153
|
+
dirty = false;
|
|
154
|
+
try {
|
|
155
|
+
await flushPreview();
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
// Best-effort preview (the final write is authoritative), but a failing edit must be visible β
|
|
159
|
+
// log once per turn so a never-rendering preview is diagnosable, not silent.
|
|
160
|
+
if (!previewErrLogged) {
|
|
161
|
+
previewErrLogged = true;
|
|
162
|
+
log.warn(`[telegram] live preview failed (final reply still sends): ${String(e)}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (dirty && !stopped) {
|
|
166
|
+
// Pace + coalesce a burst into one edit. Interruptible: finish() cuts this short so the final
|
|
167
|
+
// write is not delayed by up to EDIT_THROTTLE_MS after the turn completes.
|
|
168
|
+
await new Promise((resolve) => {
|
|
169
|
+
const t = setTimeout(resolve, EDIT_THROTTLE_MS);
|
|
170
|
+
wakeThrottle = () => {
|
|
171
|
+
clearTimeout(t);
|
|
172
|
+
resolve();
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
wakeThrottle = undefined;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
pumping = false;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
// Mark the preview dirty and ensure the single writer is running (an edit already in flight picks up
|
|
184
|
+
// the new state on its next loop). Synchronous β callers never await a network write.
|
|
185
|
+
const touch = () => {
|
|
186
|
+
dirty = true;
|
|
187
|
+
if (!pumping)
|
|
188
|
+
pumpDone = runPump();
|
|
189
|
+
};
|
|
190
|
+
touch(); // send the "π Thinkingβ¦" placeholder immediately
|
|
191
|
+
// Stop the pump and await any in-flight edit, so the final write below is strictly the LAST one to the
|
|
192
|
+
// preview message (no stale frame landing after the answer).
|
|
193
|
+
const finish = async () => {
|
|
194
|
+
stopped = true;
|
|
195
|
+
wakeThrottle?.(); // cut an in-flight throttle so the final write is not delayed up to EDIT_THROTTLE_MS
|
|
196
|
+
await pumpDone?.catch(() => { });
|
|
197
|
+
};
|
|
198
|
+
try {
|
|
199
|
+
for await (const e of events) {
|
|
200
|
+
if (e.type === "text") {
|
|
201
|
+
answer += e.delta;
|
|
202
|
+
if (answerPreviewSince === undefined && answer.trim() !== "")
|
|
203
|
+
answerPreviewSince = Date.now();
|
|
204
|
+
touch();
|
|
205
|
+
}
|
|
206
|
+
else if (e.type === "thinking") {
|
|
207
|
+
thinking += e.delta;
|
|
208
|
+
touch();
|
|
209
|
+
}
|
|
210
|
+
else if (e.type === "tool_started") {
|
|
211
|
+
const arg = summarizeArgs(e.args);
|
|
212
|
+
toolIndexById.set(e.id, tools.length);
|
|
213
|
+
tools.push({ label: arg ? `${e.name} ${arg}` : e.name, status: "running" });
|
|
214
|
+
touch();
|
|
215
|
+
}
|
|
216
|
+
else if (e.type === "tool_ended") {
|
|
217
|
+
const i = toolIndexById.get(e.id);
|
|
218
|
+
const t = i === undefined ? undefined : tools[i];
|
|
219
|
+
if (t)
|
|
220
|
+
t.status = e.isError ? "error" : "ok";
|
|
221
|
+
touch();
|
|
222
|
+
}
|
|
223
|
+
else if (e.type === "completed") {
|
|
224
|
+
await finish();
|
|
225
|
+
// Edit the preview into the final answer (HTML, plain fallback); the persisted message is the
|
|
226
|
+
// answer alone β the process (thinking/tools) was preview-only. Mark finalized BEFORE delivering:
|
|
227
|
+
// the terminal was reached, so a delivery failure here is a plain failure, not an "abnormal exit"
|
|
228
|
+
// (which would wrongly fire the finally's neutral-notice fallback = double delivery + wrong text).
|
|
229
|
+
finalized = true;
|
|
230
|
+
await finalize(api, botToken, target, messageId, answer.trim() !== "" ? answer : "(no reply)");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
else if (e.type === "failed") {
|
|
234
|
+
await finish();
|
|
235
|
+
// Two audiences: the chat (customer-facing β formatError, neutral by default) and the operator
|
|
236
|
+
// log (dev-facing β the full details, via the throw below + the handler's catch). Same terminal
|
|
237
|
+
// write as completed: edit β fresh-send if the preview is gone; an empty notice deletes the
|
|
238
|
+
// placeholder (suppress = no residue). HTML like the answer β symmetric, and finalize already
|
|
239
|
+
// falls back to plain if a custom onError returns markup Telegram rejects. Best-effort β we throw
|
|
240
|
+
// below regardless.
|
|
241
|
+
finalized = true;
|
|
242
|
+
{
|
|
243
|
+
const msg = formatError({ details: e.details, retryable: e.retryable }) ?? "";
|
|
244
|
+
await finalize(api, botToken, target, messageId, msg).catch(() => { });
|
|
245
|
+
}
|
|
246
|
+
throw new Error(`agent failed: ${e.details} (retryable=${e.retryable})`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
throw new Error("stream ended without a terminal event"); // violates SPEC MUST 1
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
await finish();
|
|
253
|
+
// Abnormal exit (stream ended without a terminal, the generator threw, or the consumer abandoned): no
|
|
254
|
+
// terminal write ran. Show the SAME neutral notice a `failed` event would β the preview may show real
|
|
255
|
+
// partial work, so don't delete it silently, and don't leave the user in silence. A suppressing
|
|
256
|
+
// onError still collapses to a delete (finalize on empty text).
|
|
257
|
+
if (!finalized) {
|
|
258
|
+
// retryable:false β an abnormal end (no terminal / a throw) is of UNKNOWN retryability, so use the
|
|
259
|
+
// neutral "something went wrong" default rather than promising "try again" that may not help.
|
|
260
|
+
const notice = formatError({ details: "the turn ended without completing", retryable: false }) ?? "";
|
|
261
|
+
// finalize handles messageId===undefined (no preview reached) with a fresh send β so the user is
|
|
262
|
+
// told even when the turn died before any message.
|
|
263
|
+
await finalize(api, botToken, target, messageId, notice).catch(() => { });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Register `<baseUrl>/telegram` as the bot's webhook (with the .env secret). Waits for the server to be
|
|
3
|
+
* REACHABLE first β polling `<baseUrl>/health` β because Telegram VERIFIES the URL when you set it, and a
|
|
4
|
+
* fresh deploy's container (healthcheck + routing) or a fresh tunnel's DNS is not routable for some
|
|
5
|
+
* seconds after the deploy/tunnel command returns. Tracking real readiness (not a fixed timer) is what
|
|
6
|
+
* fixes the race that made the first real deploy need a manual `setWebhook`. Missing tokens print the
|
|
7
|
+
* manual instruction instead of failing. `opts` (timeouts) exist for tests; production uses the defaults.
|
|
8
|
+
*/
|
|
9
|
+
export declare function registerTelegramWebhook(baseUrl: string, opts?: {
|
|
10
|
+
readyTimeoutMs?: number;
|
|
11
|
+
readyIntervalMs?: number;
|
|
12
|
+
retryMs?: number;
|
|
13
|
+
}): Promise<void>;
|