@llblab/pi-telegram 0.5.1 → 0.5.2
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/README.md +1 -0
- package/docs/architecture.md +28 -27
- package/lib/media.ts +54 -0
- package/lib/turns.ts +32 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -102,6 +102,7 @@ Run these inside pi, not Telegram:
|
|
|
102
102
|
- `👎` removes a waiting turn from the queue. Telegram Bot API does not expose ordinary DM message-deletion events through the polling path used here, so queue removal is bound to the dislike reaction.
|
|
103
103
|
- Reactions apply to any waiting Telegram turn, including text, voice, files, images, and media groups. For media groups, a reaction on any message in the group applies to the whole queued turn.
|
|
104
104
|
- If you edit a Telegram message while it is still waiting in the queue, the queued turn is updated instead of creating a duplicate prompt. Edits after a turn has already started may not affect the active run.
|
|
105
|
+
- Telegram replies to earlier text or caption messages are forwarded as `[reply]` context for normal prompts, while slash commands still parse from the new message text only.
|
|
105
106
|
- Inbound images, albums, and files are saved to `~/.pi/agent/tmp/telegram`. Unhandled local file paths are included in the prompt, handled attachment output is injected into the prompt text, and inbound images are forwarded to pi as image inputs. Inbound downloads default to a 50 MiB limit and can be adjusted with `PI_TELEGRAM_INBOUND_FILE_MAX_BYTES` or `TELEGRAM_MAX_FILE_SIZE_BYTES`.
|
|
106
107
|
- Queue reactions depend on Telegram delivering `message_reaction` updates for your bot and chat type.
|
|
107
108
|
|
package/docs/architecture.md
CHANGED
|
@@ -23,21 +23,21 @@ Naming rule: because the repository already scopes this codebase to Telegram, ex
|
|
|
23
23
|
|
|
24
24
|
Current runtime areas use these ownership boundaries:
|
|
25
25
|
|
|
26
|
-
| Domain
|
|
27
|
-
|
|
|
28
|
-
| `index.ts`
|
|
29
|
-
| `api`
|
|
30
|
-
| `config` / `setup`
|
|
31
|
-
| `locks` / `polling`
|
|
32
|
-
| `updates` / `routing`
|
|
33
|
-
| `media` / `turns` / `handlers`
|
|
34
|
-
| `queue`
|
|
35
|
-
| `runtime`
|
|
36
|
-
| `model` / `menu` / `commands`
|
|
37
|
-
| `preview` / `replies` / `rendering` | Preview lifecycle/transports, final reply delivery and reply parameters, Telegram HTML Markdown rendering, chunking, stable-preview snapshots
|
|
38
|
-
| `attachments`
|
|
39
|
-
| `status`
|
|
40
|
-
| `lifecycle` / `prompts` / `pi`
|
|
26
|
+
| Domain | Owns |
|
|
27
|
+
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
28
|
+
| `index.ts` | Single composition root for live pi/Telegram ports, session state, API-bound transport adapters, and status updates |
|
|
29
|
+
| `api` | Bot API transport shapes/helpers, retries, file download, temp-dir lifecycle, inbound limits, chat actions, lazy bot-token clients, runtime error recording |
|
|
30
|
+
| `config` / `setup` | Persisted bot/session pairing state, authorization, first-user pairing, token prompting, env fallback, validation, config persistence |
|
|
31
|
+
| `locks` / `polling` | Singleton `locks.json` ownership, takeover/restart semantics, long-poll controller state, update offset persistence, poll-loop runtime wiring |
|
|
32
|
+
| `updates` / `routing` | Update classification/execution planning, paired authorization, reactions, edits, callbacks, and inbound route composition |
|
|
33
|
+
| `media` / `turns` / `handlers` | Text/media extraction, media-group debounce, inbound downloads, turn building/editing, image reads, attachment-handler matching/execution/fallback output |
|
|
34
|
+
| `queue` | Queue item contracts, lane admission/order, stores, mutations, dispatch readiness/runtime, prompt/control enqueueing, session and agent/tool lifecycle sequencing |
|
|
35
|
+
| `runtime` | Session-local coordination primitives: counters, lifecycle flags, setup guard, abort handler, typing-loop timers, prompt-dispatch flags, agent-end reset binding |
|
|
36
|
+
| `model` / `menu` / `commands` | Model identity/thinking levels, scoped model resolution, in-flight switching, inline status/model/thinking UI, slash commands, bot command registration |
|
|
37
|
+
| `preview` / `replies` / `rendering` | Preview lifecycle/transports, final reply delivery and reply parameters, Telegram HTML Markdown rendering, chunking, stable-preview snapshots |
|
|
38
|
+
| `attachments` | `telegram_attach` registration, outbound attachment queueing, stat/limit checks, photo/document delivery classification |
|
|
39
|
+
| `status` | Status-bar/status-message rendering, queue-lane status views, redacted runtime event ring, grouped pi diagnostics |
|
|
40
|
+
| `lifecycle` / `prompts` / `pi` | pi hook registration, Telegram-specific before-agent prompt injection, centralized direct pi SDK imports and context adapters |
|
|
41
41
|
|
|
42
42
|
Boundary invariants:
|
|
43
43
|
|
|
@@ -71,13 +71,14 @@ Telegram bot configuration stays in `~/.pi/agent/telegram.json`; singleton runti
|
|
|
71
71
|
2. Each update offset is persisted only after the update handler succeeds; repeated handler failures are bounded so one poisoned update cannot stall polling forever
|
|
72
72
|
3. The bridge filters to the paired private user
|
|
73
73
|
4. Media groups are coalesced into a single Telegram turn when needed
|
|
74
|
-
5.
|
|
75
|
-
6.
|
|
76
|
-
7.
|
|
77
|
-
8.
|
|
78
|
-
9.
|
|
79
|
-
10.
|
|
80
|
-
11.
|
|
74
|
+
5. Slash command parsing uses only the new message text/caption, while Telegram `reply_to_message` text/caption is injected later as prompt-only `[reply]` context for normal queued turns
|
|
75
|
+
6. Files are streamed into `~/.pi/agent/tmp/telegram` with a default 50 MiB size limit, partial-download cleanup on failures, and stale temp cleanup on session start; operators can tune the limit with `PI_TELEGRAM_INBOUND_FILE_MAX_BYTES` or `TELEGRAM_MAX_FILE_SIZE_BYTES`
|
|
76
|
+
7. Configured inbound attachment handlers may run on downloaded files by MIME wildcard, Telegram attachment type, or generic match selector; command templates receive safe command-arg substitution for `{file}`/`{mime}`/`{type}`
|
|
77
|
+
8. Matching handlers are tried in config order: a non-zero exit records diagnostics and falls back to the next matching handler, while the first successful handler stops the chain
|
|
78
|
+
9. Local attachments stay visible under `[attachments] <directory>` with relative file entries, and handler stdout is appended under `[outputs]` before the agent sees the turn; failed handlers omit output while keeping the attachment entry
|
|
79
|
+
10. A `PendingTelegramTurn` is created and queued locally
|
|
80
|
+
11. Telegram `edited_message` updates are routed separately and update a matching queued turn when the original message has not been dispatched yet
|
|
81
|
+
12. The queue dispatcher sends the turn into pi only when dispatch is safe
|
|
81
82
|
|
|
82
83
|
### Queue Safety Model
|
|
83
84
|
|
|
@@ -90,12 +91,12 @@ Queued items now use two explicit dimensions:
|
|
|
90
91
|
|
|
91
92
|
Admission contract:
|
|
92
93
|
|
|
93
|
-
| Admission | Examples
|
|
94
|
-
| --------------------- |
|
|
95
|
-
| Immediate execution | `/compact`, `/stop`, `/help`, `/start`
|
|
94
|
+
| Admission | Examples | Queue shape | Dispatch rank |
|
|
95
|
+
| --------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------- | ------------- |
|
|
96
|
+
| Immediate execution | `/compact`, `/stop`, `/help`, `/start` | Does not enter the Telegram queue; `/stop` also clears queued items | N/A |
|
|
96
97
|
| Control queue | Model-switch continuation turns and future deferred controls | `queueLane: control`; accepts control items and continuation prompts | 0 |
|
|
97
|
-
| Priority prompt queue | A waiting prompt promoted by `👍`
|
|
98
|
-
| Default prompt queue | Normal Telegram text/media turns
|
|
98
|
+
| Priority prompt queue | A waiting prompt promoted by `👍` | `kind: prompt`, `queueLane: priority` | 1 |
|
|
99
|
+
| Default prompt queue | Normal Telegram text/media turns | `kind: prompt`, `queueLane: default` | 2 |
|
|
99
100
|
|
|
100
101
|
The command action itself carries its execution mode, and the queue domain exposes lane contracts for admission mode, dispatch rank, and allowed item kinds. Queue append and planning paths validate lane admission so a malformed control/default or other invalid lane pairing fails predictably instead of silently changing priority. This lets synthetic control actions and Telegram prompts share one stable ordering model while still rendering distinctly in status output. In the pi status bar, busy labels distinguish `active`, `dispatching`, `queued`, `tool running`, `model`, and `compacting`; priority prompts are marked with `⬆` while control items keep markers such as `⚡`.
|
|
101
102
|
|
package/lib/media.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { basename, dirname } from "node:path";
|
|
7
7
|
|
|
8
8
|
const TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS = 1200;
|
|
9
|
+
const TELEGRAM_REPLY_CONTEXT_MAX_LENGTH = 1000;
|
|
9
10
|
|
|
10
11
|
export interface TelegramPhotoSize {
|
|
11
12
|
file_id: string;
|
|
@@ -27,6 +28,12 @@ export interface TelegramVoice {
|
|
|
27
28
|
mime_type?: string;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
export interface TelegramReplyToMessage {
|
|
32
|
+
message_id?: number;
|
|
33
|
+
text?: string;
|
|
34
|
+
caption?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
export interface TelegramSticker {
|
|
31
38
|
file_id: string;
|
|
32
39
|
}
|
|
@@ -35,6 +42,7 @@ export interface TelegramMediaMessage {
|
|
|
35
42
|
message_id: number;
|
|
36
43
|
text?: string;
|
|
37
44
|
caption?: string;
|
|
45
|
+
reply_to_message?: TelegramReplyToMessage;
|
|
38
46
|
media_group_id?: string;
|
|
39
47
|
photo?: TelegramPhotoSize[];
|
|
40
48
|
document?: TelegramDocument;
|
|
@@ -167,12 +175,58 @@ export function extractTelegramMessageText(
|
|
|
167
175
|
return (message.text || message.caption || "").trim();
|
|
168
176
|
}
|
|
169
177
|
|
|
178
|
+
function truncateTelegramReplyContextText(text: string): string {
|
|
179
|
+
if (text.length <= TELEGRAM_REPLY_CONTEXT_MAX_LENGTH) return text;
|
|
180
|
+
return `${text.slice(0, TELEGRAM_REPLY_CONTEXT_MAX_LENGTH).trimEnd()}…`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function extractTelegramReplyContextText(
|
|
184
|
+
message: TelegramMediaMessage,
|
|
185
|
+
): string {
|
|
186
|
+
const quoted = (
|
|
187
|
+
message.reply_to_message?.text ||
|
|
188
|
+
message.reply_to_message?.caption ||
|
|
189
|
+
""
|
|
190
|
+
).trim();
|
|
191
|
+
return quoted ? truncateTelegramReplyContextText(quoted) : "";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function appendTelegramReplyContext(
|
|
195
|
+
text: string,
|
|
196
|
+
replyContext: string,
|
|
197
|
+
): string {
|
|
198
|
+
if (!replyContext) return text;
|
|
199
|
+
const replyBlock = `[reply] ${replyContext}`;
|
|
200
|
+
return text ? `${text}\n\n${replyBlock}` : `_\n\n${replyBlock}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function extractTelegramMessagePromptText(
|
|
204
|
+
message: TelegramMediaMessage,
|
|
205
|
+
): string {
|
|
206
|
+
return appendTelegramReplyContext(
|
|
207
|
+
extractTelegramMessageText(message),
|
|
208
|
+
extractTelegramReplyContextText(message),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
170
212
|
export function extractTelegramMessagesText(
|
|
171
213
|
messages: TelegramMediaMessage[],
|
|
172
214
|
): string {
|
|
173
215
|
return messages.map(extractTelegramMessageText).filter(Boolean).join("\n\n");
|
|
174
216
|
}
|
|
175
217
|
|
|
218
|
+
export function extractTelegramMessagesPromptText(
|
|
219
|
+
messages: TelegramMediaMessage[],
|
|
220
|
+
): string {
|
|
221
|
+
const text = extractTelegramMessagesText(messages);
|
|
222
|
+
const firstMessage = messages[0];
|
|
223
|
+
if (!firstMessage) return text;
|
|
224
|
+
return appendTelegramReplyContext(
|
|
225
|
+
text,
|
|
226
|
+
extractTelegramReplyContextText(firstMessage),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
176
230
|
export function extractFirstTelegramMessageText(
|
|
177
231
|
messages: TelegramMediaMessage[],
|
|
178
232
|
): string {
|
package/lib/turns.ts
CHANGED
|
@@ -11,7 +11,10 @@ import {
|
|
|
11
11
|
type DownloadedTelegramMessageFile,
|
|
12
12
|
type DownloadTelegramMessageFilesDeps,
|
|
13
13
|
downloadTelegramMessageFiles,
|
|
14
|
+
extractTelegramMessagesPromptText,
|
|
14
15
|
extractTelegramMessagesText,
|
|
16
|
+
appendTelegramReplyContext,
|
|
17
|
+
extractTelegramReplyContextText,
|
|
15
18
|
formatTelegramHistoryText,
|
|
16
19
|
guessMediaType,
|
|
17
20
|
type TelegramMediaMessage,
|
|
@@ -94,6 +97,11 @@ function appendTelegramAttachmentSection(
|
|
|
94
97
|
return `${prefix}${header}\n${items.map((item) => `- ${item}`).join("\n")}`;
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
function appendTelegramPromptText(prompt: string, rawText: string): string {
|
|
101
|
+
if (!rawText) return prompt;
|
|
102
|
+
return `${prompt} ${rawText}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
97
105
|
export function buildTelegramTurnPrompt(options: {
|
|
98
106
|
telegramPrefix: string;
|
|
99
107
|
rawText: string;
|
|
@@ -112,10 +120,10 @@ export function buildTelegramTurnPrompt(options: {
|
|
|
112
120
|
prompt += "\n\nCurrent Telegram message:";
|
|
113
121
|
}
|
|
114
122
|
if (options.rawText.length > 0) {
|
|
115
|
-
prompt
|
|
123
|
+
prompt =
|
|
116
124
|
(options.historyTurns?.length ?? 0) > 0
|
|
117
|
-
?
|
|
118
|
-
:
|
|
125
|
+
? `${prompt}\n${options.rawText}`
|
|
126
|
+
: appendTelegramPromptText(prompt, options.rawText);
|
|
119
127
|
}
|
|
120
128
|
const promptFiles = options.promptFiles ?? options.files;
|
|
121
129
|
prompt = appendTelegramAttachmentSection(prompt, promptFiles);
|
|
@@ -193,12 +201,11 @@ function buildEditedTelegramPromptText(options: {
|
|
|
193
201
|
attachmentFiles,
|
|
194
202
|
};
|
|
195
203
|
}
|
|
196
|
-
const promptText =
|
|
197
|
-
options.rawText.length > 0
|
|
198
|
-
? `${options.telegramPrefix} ${options.rawText}`
|
|
199
|
-
: options.telegramPrefix;
|
|
200
204
|
return {
|
|
201
|
-
text: `${
|
|
205
|
+
text: `${appendTelegramPromptText(
|
|
206
|
+
options.telegramPrefix,
|
|
207
|
+
options.rawText,
|
|
208
|
+
)}${attachmentSuffix}`,
|
|
202
209
|
attachmentFiles,
|
|
203
210
|
};
|
|
204
211
|
}
|
|
@@ -207,6 +214,7 @@ export function updateTelegramPromptTurnText(options: {
|
|
|
207
214
|
turn: PendingTelegramTurn;
|
|
208
215
|
telegramPrefix: string;
|
|
209
216
|
rawText: string;
|
|
217
|
+
statusText?: string;
|
|
210
218
|
}): PendingTelegramTurn {
|
|
211
219
|
let attachmentFiles: DownloadedTelegramTurnFile[] = [];
|
|
212
220
|
const nextContent = options.turn.content.map((block, index) => {
|
|
@@ -227,7 +235,7 @@ export function updateTelegramPromptTurnText(options: {
|
|
|
227
235
|
content: nextContent,
|
|
228
236
|
historyText: formatTelegramHistoryText(options.rawText, attachmentFiles),
|
|
229
237
|
statusSummary: formatTelegramTurnStatusSummary(
|
|
230
|
-
options.rawText,
|
|
238
|
+
options.statusText ?? options.rawText,
|
|
231
239
|
attachmentFiles,
|
|
232
240
|
),
|
|
233
241
|
};
|
|
@@ -240,6 +248,7 @@ export function updateQueuedTelegramPromptTurnText<
|
|
|
240
248
|
sourceMessageId: number | undefined;
|
|
241
249
|
telegramPrefix: string;
|
|
242
250
|
rawText: string;
|
|
251
|
+
statusText?: string;
|
|
243
252
|
}): { items: TelegramQueueItem<TContext>[]; changed: boolean } {
|
|
244
253
|
if (options.sourceMessageId === undefined) {
|
|
245
254
|
return { items: options.items, changed: false };
|
|
@@ -257,6 +266,7 @@ export function updateQueuedTelegramPromptTurnText<
|
|
|
257
266
|
turn: item,
|
|
258
267
|
telegramPrefix: options.telegramPrefix,
|
|
259
268
|
rawText: options.rawText,
|
|
269
|
+
statusText: options.statusText,
|
|
260
270
|
});
|
|
261
271
|
});
|
|
262
272
|
return { items, changed };
|
|
@@ -278,7 +288,8 @@ export function createTelegramQueuedPromptEditRuntime<
|
|
|
278
288
|
items: deps.getQueuedItems(),
|
|
279
289
|
sourceMessageId: message.message_id,
|
|
280
290
|
telegramPrefix: TELEGRAM_PREFIX,
|
|
281
|
-
rawText:
|
|
291
|
+
rawText: extractTelegramMessagesPromptText([message]),
|
|
292
|
+
statusText: extractTelegramMessagesText([message]),
|
|
282
293
|
});
|
|
283
294
|
deps.setQueuedItems(items);
|
|
284
295
|
if (changed) deps.updateStatus(ctx);
|
|
@@ -293,6 +304,7 @@ export interface BuildTelegramPromptTurnOptions {
|
|
|
293
304
|
historyTurns?: PendingTelegramTurn[];
|
|
294
305
|
queueOrder: number;
|
|
295
306
|
rawText: string;
|
|
307
|
+
statusText?: string;
|
|
296
308
|
files: DownloadedTelegramTurnFile[];
|
|
297
309
|
promptFiles?: DownloadedTelegramTurnFile[];
|
|
298
310
|
handlerOutputs?: string[];
|
|
@@ -332,18 +344,26 @@ export function createTelegramPromptTurnRuntimeBuilder<
|
|
|
332
344
|
) => Promise<PendingTelegramTurn> {
|
|
333
345
|
return async (messages, historyTurns = [], ctx) => {
|
|
334
346
|
const rawText = extractTelegramMessagesText(messages);
|
|
347
|
+
const replyContext = messages[0]
|
|
348
|
+
? extractTelegramReplyContextText(messages[0])
|
|
349
|
+
: "";
|
|
335
350
|
const files = await downloadTelegramMessageFiles(messages, {
|
|
336
351
|
downloadFile: deps.downloadFile,
|
|
337
352
|
});
|
|
338
353
|
const processed = deps.processAttachments
|
|
339
354
|
? await deps.processAttachments(files, rawText, ctx as TContext)
|
|
340
355
|
: { rawText, promptFiles: files };
|
|
356
|
+
const promptText = appendTelegramReplyContext(
|
|
357
|
+
processed.rawText,
|
|
358
|
+
replyContext,
|
|
359
|
+
);
|
|
341
360
|
return buildTelegramPromptTurnRuntime({
|
|
342
361
|
telegramPrefix: TELEGRAM_PREFIX,
|
|
343
362
|
messages,
|
|
344
363
|
historyTurns,
|
|
345
364
|
queueOrder: deps.allocateQueueOrder(),
|
|
346
|
-
rawText:
|
|
365
|
+
rawText: promptText,
|
|
366
|
+
statusText: processed.rawText,
|
|
347
367
|
files,
|
|
348
368
|
promptFiles: processed.promptFiles,
|
|
349
369
|
handlerOutputs: processed.handlerOutputs,
|
|
@@ -399,7 +419,7 @@ export async function buildTelegramPromptTurn(
|
|
|
399
419
|
options.handlerOutputs,
|
|
400
420
|
),
|
|
401
421
|
statusSummary: formatTelegramTurnStatusSummary(
|
|
402
|
-
options.rawText,
|
|
422
|
+
options.statusText ?? options.rawText,
|
|
403
423
|
options.promptFiles ?? options.files,
|
|
404
424
|
options.handlerOutputs,
|
|
405
425
|
),
|