pi-ui-extend 0.1.64 → 0.1.66
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 +5 -4
- package/dist/app/cli/update.d.ts +12 -0
- package/dist/app/cli/update.js +151 -19
- package/dist/app/commands/command-registry.js +1 -1
- package/dist/app/commands/command-session-actions.js +11 -6
- package/docs/release.md +6 -4
- package/external/pi-tools-suite/src/config.ts +0 -43
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +0 -5
- package/external/pi-tools-suite/src/index.ts +0 -1
- package/package.json +1 -1
- package/external/pi-tools-suite/src/telegram-mirror/README.md +0 -203
- package/external/pi-tools-suite/src/telegram-mirror/bot.ts +0 -299
- package/external/pi-tools-suite/src/telegram-mirror/events.ts +0 -67
- package/external/pi-tools-suite/src/telegram-mirror/format.ts +0 -120
- package/external/pi-tools-suite/src/telegram-mirror/index.ts +0 -669
- package/external/pi-tools-suite/src/telegram-mirror/ipc.ts +0 -440
- package/external/pi-tools-suite/src/telegram-mirror/multiplexer.ts +0 -638
- package/external/pi-tools-suite/src/telegram-mirror/renderer.ts +0 -211
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
# telegram-mirror
|
|
2
|
-
|
|
3
|
-
A pi-tools-suite module that exposes one or more running pi sessions as a
|
|
4
|
-
single Telegram chat. Pi stays as the source of truth; Telegram is a remote
|
|
5
|
-
second screen.
|
|
6
|
-
|
|
7
|
-
## Opt-in
|
|
8
|
-
|
|
9
|
-
The module is a no-op until you add a `telegramMirror` block to
|
|
10
|
-
`~/.config/pi/pi-tools-suite.jsonc`:
|
|
11
|
-
|
|
12
|
-
```jsonc
|
|
13
|
-
{
|
|
14
|
-
// …other pi-tools-suite settings…
|
|
15
|
-
"telegramMirror": {
|
|
16
|
-
"enabled": true,
|
|
17
|
-
"botToken": "123456789:ABCdef…", // from @BotFather
|
|
18
|
-
"chatId": 123456789 // numeric chat id of your private chat
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
- `enabled` (boolean, optional): defaults to `true` when the block is
|
|
24
|
-
present and `botToken` + `chatId` are valid.
|
|
25
|
-
- `botToken` (string, required): Telegram Bot API token from @BotFather.
|
|
26
|
-
Empty string disables the mirror.
|
|
27
|
-
- `chatId` (number or string, required): numeric private chat id allowed to
|
|
28
|
-
control the bot. Non-integer disables the mirror.
|
|
29
|
-
|
|
30
|
-
When the block is present and valid, the module registers the local
|
|
31
|
-
`/telegram-mirror` and `/tg` slash commands. The bot does not connect until
|
|
32
|
-
you run one of those commands in a pi session.
|
|
33
|
-
|
|
34
|
-
## Activation
|
|
35
|
-
|
|
36
|
-
Run this inside each pi session you want to expose to Telegram:
|
|
37
|
-
|
|
38
|
-
```text
|
|
39
|
-
/telegram-mirror
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
Short alias:
|
|
43
|
-
|
|
44
|
-
```text
|
|
45
|
-
/tg
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
Useful local variants:
|
|
49
|
-
|
|
50
|
-
- `/telegram-mirror` or `/tg`: connect this pi session to Telegram mirror.
|
|
51
|
-
- `/telegram-mirror status`: show local mirror role and session label.
|
|
52
|
-
- `/telegram-mirror stop`: stop the mirror cluster.
|
|
53
|
-
- `/tg-off`: stop the mirror cluster.
|
|
54
|
-
|
|
55
|
-
After activation, the leader sends a Telegram message with buttons. Use
|
|
56
|
-
`/menu` or `/list` in Telegram any time to reopen the project/session picker.
|
|
57
|
-
|
|
58
|
-
## How to get your chat id
|
|
59
|
-
|
|
60
|
-
Open this URL in a browser (replace `<TOKEN>` with your bot token):
|
|
61
|
-
|
|
62
|
-
```text
|
|
63
|
-
https://api.telegram.org/bot<TOKEN>/getUpdates
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
Send any message to your bot in Telegram, then refresh the URL. The JSON
|
|
67
|
-
response contains `"chat": { "id": 123456789, … }` — that number is your
|
|
68
|
-
`chatId`.
|
|
69
|
-
|
|
70
|
-
Alternative: message [@userinfobot](https://t.me/userinfobot).
|
|
71
|
-
|
|
72
|
-
The bot silently ignores every message from any other chat.
|
|
73
|
-
|
|
74
|
-
## Multi-instance setup
|
|
75
|
-
|
|
76
|
-
Telegram allows exactly one concurrent `getUpdates` call per bot token,
|
|
77
|
-
so this module elects a **leader** when N pi processes share one bot:
|
|
78
|
-
|
|
79
|
-
1. The first pi to start binds the unix socket at
|
|
80
|
-
`~/.pi/agent/extensions/pi-tools-suite/.run/telegram-mirror.sock`,
|
|
81
|
-
connects the bot, and starts polling.
|
|
82
|
-
2. Subsequent pi processes connect to that socket as **followers**. They
|
|
83
|
-
forward their pix events to the leader over IPC and execute commands
|
|
84
|
-
received from the leader.
|
|
85
|
-
3. If the leader dies (process exit, socket close, or heartbeat timeout),
|
|
86
|
-
followers race to bind the socket; the first to win becomes the new
|
|
87
|
-
leader. `activeId` resets on failover — run `/use N` again.
|
|
88
|
-
|
|
89
|
-
Run `/telegram-mirror` in every `pi` process you want available in the
|
|
90
|
-
Telegram picker. Only one process polls Telegram; the rest register as
|
|
91
|
-
followers over IPC.
|
|
92
|
-
|
|
93
|
-
When you start a new pi, it logs `[telegram-mirror] registered with
|
|
94
|
-
leader <label>` on stderr. The leader logs `[telegram-mirror] connected
|
|
95
|
-
as @<botname> (leader)`.
|
|
96
|
-
|
|
97
|
-
### Selecting the followed project/session
|
|
98
|
-
|
|
99
|
-
In Telegram, use `/menu`, `/list`, or the inline buttons:
|
|
100
|
-
|
|
101
|
-
```text
|
|
102
|
-
/list
|
|
103
|
-
→
|
|
104
|
-
1. pi-ui-extend (#12345) (leader) [following] — idle
|
|
105
|
-
2. opencode (#67890) — streaming
|
|
106
|
-
3. other-repo (#99999)
|
|
107
|
-
|
|
108
|
-
Tap a button below, or use /use N.
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
```text
|
|
112
|
-
/use 2
|
|
113
|
-
→ ✅ Following: opencode (#67890)
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
`/use` accepts a 1-based index from `/list` or a substring of the id/label.
|
|
117
|
-
Assistant messages are streamed only from the followed session. Status changes
|
|
118
|
-
from other sessions still produce Telegram signals, so you can see when a
|
|
119
|
-
different session starts or finishes work without switching to it.
|
|
120
|
-
|
|
121
|
-
### Cleanup
|
|
122
|
-
|
|
123
|
-
Socket file: `~/.pi/agent/extensions/pi-tools-suite/.run/telegram-mirror.sock`.
|
|
124
|
-
|
|
125
|
-
If a pi crashes hard and leaves a stale socket, the next pi to start will
|
|
126
|
-
unlink it automatically (bind fails → connect fails → unlink → retry).
|
|
127
|
-
|
|
128
|
-
## Telegram → pix
|
|
129
|
-
|
|
130
|
-
- Free text: forwarded to the followed pi session as a user message.
|
|
131
|
-
- `/menu`: show inline project/session picker buttons.
|
|
132
|
-
- `/list`: show all known pi sessions and mark followed.
|
|
133
|
-
- `/use N` or `/use X`: follow by index, id, or label substring.
|
|
134
|
-
- `/abort` or `/stop`: cancel current turn on followed session.
|
|
135
|
-
- `/compact`: trigger context compaction on followed session.
|
|
136
|
-
- `/status`: show idle / streaming state of followed session.
|
|
137
|
-
- `/clear`: best-effort delete known bot messages from the chat.
|
|
138
|
-
- `/say <msg>`: explicit send, for `/`-prefixed text.
|
|
139
|
-
- `/disconnect`: stop the bot cluster-wide.
|
|
140
|
-
- `/new`: not supported via extension API; run `/new` in pi.
|
|
141
|
-
- `/help`: show command list.
|
|
142
|
-
|
|
143
|
-
## Pix → Telegram
|
|
144
|
-
|
|
145
|
-
The leader subscribes to pix streaming events (its own + followers' via IPC)
|
|
146
|
-
and renders one Telegram message per agent turn — but only assistant-visible
|
|
147
|
-
text from the followed session is streamed:
|
|
148
|
-
|
|
149
|
-
- `message_update` (`text_delta`) → appended to the active message, edited
|
|
150
|
-
in place at ~1.2 s throttle (Telegram rate-limit friendly).
|
|
151
|
-
- `agent_end` → final flush + `— done —` trailer.
|
|
152
|
-
- `agent_start` / `agent_end` from any known session → compact status signal
|
|
153
|
-
such as `🟡 repo (#pid) is streaming` or `🟢 repo (#pid) is idle`.
|
|
154
|
-
|
|
155
|
-
Tool calls, tool results, and thinking deltas are intentionally not mirrored
|
|
156
|
-
to Telegram.
|
|
157
|
-
|
|
158
|
-
Telegram does not expose a full private-chat history wipe API to bots. The
|
|
159
|
-
`/clear` command therefore deletes the messages the bot knows about in this
|
|
160
|
-
process, plus the `/clear` command message when Telegram allows it. Older
|
|
161
|
-
messages from previous bot runs may remain.
|
|
162
|
-
|
|
163
|
-
Messages are paginated at 4096 chars (Telegram's per-message limit).
|
|
164
|
-
Markdown is converted to Telegram HTML with `**bold**`, `*italic*`,
|
|
165
|
-
`` `code` ``, and fenced blocks.
|
|
166
|
-
|
|
167
|
-
## Disable
|
|
168
|
-
|
|
169
|
-
Either set `"enabled": false` in the `telegramMirror` block, remove the
|
|
170
|
-
block entirely, or add `telegram-mirror` to the `disabledModules` array
|
|
171
|
-
in the same config file, then `/reload` pi.
|
|
172
|
-
|
|
173
|
-
## Known limitations
|
|
174
|
-
|
|
175
|
-
- `/new` cannot start a fresh session from Telegram. The ExtensionAPI
|
|
176
|
-
exposes `newSession()` only on slash-command handler contexts, not on
|
|
177
|
-
event-handler contexts. Workaround: type `/new` in the pi TUI.
|
|
178
|
-
- `pi.sendUserMessage` does not expand pi's own slash commands (calls
|
|
179
|
-
`prompt(..., { expandPromptTemplates: false })` internally), so text
|
|
180
|
-
starting with `/` is sent verbatim to the LLM. The module's own
|
|
181
|
-
`/abort`, `/compact`, `/list`, `/use`, etc. are intercepted before
|
|
182
|
-
`sendUserMessage` is called, so they work.
|
|
183
|
-
- The leader uses long polling (35 s timeout) and keeps one outbound
|
|
184
|
-
request open. If your network blocks Telegram, you'll see repeating
|
|
185
|
-
`[telegram-mirror] polling: …` errors in stderr and the bot will back
|
|
186
|
-
off up to 60 s between retries.
|
|
187
|
-
- On leader failover, the in-flight streaming output for the followed turn
|
|
188
|
-
is lost (the new leader's renderer starts empty). The followed session also
|
|
189
|
-
resets to the new leader; run `/use N` to switch back to a follower.
|
|
190
|
-
- The cluster is single-host only (unix socket). To mirror across
|
|
191
|
-
machines, use separate bot tokens.
|
|
192
|
-
- IPC events between session_start and leader-registration can be lost
|
|
193
|
-
for a brief window. Mid-stream output may be cut off.
|
|
194
|
-
|
|
195
|
-
## Files
|
|
196
|
-
|
|
197
|
-
- `index.ts`: module factory, activation command, role selection, lifecycle.
|
|
198
|
-
- `bot.ts`: Telegram Bot API fetch client and long-poll loop.
|
|
199
|
-
- `ipc.ts`: unix socket JSON-lines IPC and leader election.
|
|
200
|
-
- `multiplexer.ts`: leader-side registry and active-instance routing.
|
|
201
|
-
- `events.ts`: pix event to sink adapters and context capture.
|
|
202
|
-
- `renderer.ts`: per-turn buffer, throttled edit, pagination.
|
|
203
|
-
- `format.ts`: markdown to Telegram HTML and chunking.
|
|
@@ -1,299 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal Telegram Bot API client.
|
|
3
|
-
*
|
|
4
|
-
* Uses native fetch (Node 18+ / Bun) and long polling via getUpdates.
|
|
5
|
-
* No dependencies on grammY or node-telegram-bot-api.
|
|
6
|
-
*
|
|
7
|
-
* Provides:
|
|
8
|
-
* - sendMessage / editMessageText (HTML parse mode)
|
|
9
|
-
* - getUpdates long-poll loop with backoff
|
|
10
|
-
* - Auth gate by chat_id (single whitelist)
|
|
11
|
-
* - Graceful shutdown via AbortController
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
export interface TelegramUpdate {
|
|
15
|
-
update_id: number;
|
|
16
|
-
message?: TelegramIncomingMessage;
|
|
17
|
-
callback_query?: {
|
|
18
|
-
id: string;
|
|
19
|
-
from?: { id: number; first_name?: string; username?: string };
|
|
20
|
-
message?: TelegramIncomingMessage;
|
|
21
|
-
data?: string;
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface TelegramIncomingMessage {
|
|
26
|
-
message_id: number;
|
|
27
|
-
date: number;
|
|
28
|
-
chat: { id: number; type: string };
|
|
29
|
-
from?: { id: number; first_name?: string; username?: string };
|
|
30
|
-
text?: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface TelegramMessage {
|
|
34
|
-
message_id: number;
|
|
35
|
-
chat: { id: number };
|
|
36
|
-
date: number;
|
|
37
|
-
text?: string;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface BotConfig {
|
|
41
|
-
token: string;
|
|
42
|
-
allowedChatId: number;
|
|
43
|
-
timeoutMs?: number;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface TelegramInlineKeyboardButton {
|
|
47
|
-
text: string;
|
|
48
|
-
callback_data?: string;
|
|
49
|
-
url?: string;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface TelegramReplyMarkup {
|
|
53
|
-
inline_keyboard: TelegramInlineKeyboardButton[][];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface TelegramBotCommand {
|
|
57
|
-
command: string;
|
|
58
|
-
description: string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
interface SendOptions {
|
|
62
|
-
parseMode?: "HTML" | "MarkdownV2" | "Markdown";
|
|
63
|
-
disablePreview?: boolean;
|
|
64
|
-
silent?: boolean;
|
|
65
|
-
replyToMessageId?: number;
|
|
66
|
-
replyMarkup?: TelegramReplyMarkup;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
interface EditOptions {
|
|
70
|
-
parseMode?: "HTML" | "MarkdownV2" | "Markdown";
|
|
71
|
-
disablePreview?: boolean;
|
|
72
|
-
replyMarkup?: TelegramReplyMarkup;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export class TelegramBot {
|
|
76
|
-
private readonly baseUrl: string;
|
|
77
|
-
private readonly allowedChatId: number;
|
|
78
|
-
private readonly timeoutMs: number;
|
|
79
|
-
private readonly controller = new AbortController();
|
|
80
|
-
private readonly sentMessageIds = new Set<number>();
|
|
81
|
-
private polling = false;
|
|
82
|
-
private lastUpdateId = 0;
|
|
83
|
-
private consecutiveErrors = 0;
|
|
84
|
-
|
|
85
|
-
constructor(config: BotConfig) {
|
|
86
|
-
if (!config.token) throw new Error("TelegramBot: token is required");
|
|
87
|
-
this.baseUrl = `https://api.telegram.org/bot${config.token}`;
|
|
88
|
-
this.allowedChatId = config.allowedChatId;
|
|
89
|
-
this.timeoutMs = config.timeoutMs ?? 35_000;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
get signal(): AbortSignal {
|
|
93
|
-
return this.controller.signal;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
get chatId(): number {
|
|
97
|
-
return this.allowedChatId;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
get sentIds(): readonly number[] {
|
|
101
|
-
return [...this.sentMessageIds];
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
forgetSentId(messageId: number): void {
|
|
105
|
-
this.sentMessageIds.delete(messageId);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
isAllowedChat(chatId: number): boolean {
|
|
109
|
-
return chatId === this.allowedChatId;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async getMe(): Promise<{ ok: boolean; result?: { username: string; first_name: string } }> {
|
|
113
|
-
return this.requestJson("GET", "getMe", undefined);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async setMyCommands(commands: TelegramBotCommand[]): Promise<void> {
|
|
117
|
-
await this.requestJson("POST", "setMyCommands", { commands });
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async sendMessage(text: string, options: SendOptions = {}): Promise<TelegramMessage | undefined> {
|
|
121
|
-
const payload = await this.requestJson<{ ok: boolean; result?: TelegramMessage }>("POST", "sendMessage", {
|
|
122
|
-
chat_id: this.allowedChatId,
|
|
123
|
-
text,
|
|
124
|
-
parse_mode: options.parseMode ?? "HTML",
|
|
125
|
-
disable_web_page_preview: options.disablePreview ?? true,
|
|
126
|
-
disable_notification: options.silent ?? false,
|
|
127
|
-
reply_markup: options.replyMarkup,
|
|
128
|
-
...(options.replyToMessageId ? { reply_to_message_id: options.replyToMessageId } : {}),
|
|
129
|
-
});
|
|
130
|
-
if (payload.result?.message_id) this.sentMessageIds.add(payload.result.message_id);
|
|
131
|
-
return payload.result;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
async editMessageText(messageId: number, text: string, options: EditOptions = {}): Promise<TelegramMessage | undefined> {
|
|
135
|
-
try {
|
|
136
|
-
const payload = await this.requestJson<{ ok: boolean; result?: TelegramMessage }>("POST", "editMessageText", {
|
|
137
|
-
chat_id: this.allowedChatId,
|
|
138
|
-
message_id: messageId,
|
|
139
|
-
text,
|
|
140
|
-
parse_mode: options.parseMode ?? "HTML",
|
|
141
|
-
disable_web_page_preview: options.disablePreview ?? true,
|
|
142
|
-
reply_markup: options.replyMarkup,
|
|
143
|
-
});
|
|
144
|
-
return payload.result;
|
|
145
|
-
} catch (error) {
|
|
146
|
-
// Telegram returns 400 "message is not modified" if content is identical.
|
|
147
|
-
// Treat that as success; surface everything else.
|
|
148
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
149
|
-
if (/not modified/i.test(message)) return undefined;
|
|
150
|
-
throw error;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
async deleteMessage(messageId: number): Promise<void> {
|
|
155
|
-
try {
|
|
156
|
-
await this.requestJson("POST", "deleteMessage", {
|
|
157
|
-
chat_id: this.allowedChatId,
|
|
158
|
-
message_id: messageId,
|
|
159
|
-
});
|
|
160
|
-
this.sentMessageIds.delete(messageId);
|
|
161
|
-
} catch {
|
|
162
|
-
// best-effort
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async deleteKnownMessages(extraMessageIds: readonly number[] = []): Promise<{ attempted: number; deleted: number }> {
|
|
167
|
-
const ids = [...new Set([...this.sentMessageIds, ...extraMessageIds])].sort((a, b) => b - a);
|
|
168
|
-
let deleted = 0;
|
|
169
|
-
for (const id of ids) {
|
|
170
|
-
const before = this.sentMessageIds.has(id);
|
|
171
|
-
await this.deleteMessage(id);
|
|
172
|
-
if (before && !this.sentMessageIds.has(id)) deleted += 1;
|
|
173
|
-
}
|
|
174
|
-
return { attempted: ids.length, deleted };
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
async answerCallbackQuery(callbackQueryId: string, text?: string): Promise<void> {
|
|
178
|
-
await this.requestJson("POST", "answerCallbackQuery", {
|
|
179
|
-
callback_query_id: callbackQueryId,
|
|
180
|
-
text,
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Start long-polling loop. The callback receives every update from the
|
|
186
|
-
* allowed chat; updates from other chats are dropped silently.
|
|
187
|
-
*
|
|
188
|
-
* The loop exits cleanly when abort() is called.
|
|
189
|
-
*/
|
|
190
|
-
startPolling(onUpdate: (update: TelegramUpdate) => void | Promise<void>): void {
|
|
191
|
-
if (this.polling) return;
|
|
192
|
-
this.polling = true;
|
|
193
|
-
void this.pollLoop(onUpdate);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
abort(): void {
|
|
197
|
-
if (this.controller.signal.aborted) return;
|
|
198
|
-
this.controller.abort();
|
|
199
|
-
this.polling = false;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
private async pollLoop(onUpdate: (update: TelegramUpdate) => void | Promise<void>): Promise<void> {
|
|
203
|
-
while (this.polling && !this.controller.signal.aborted) {
|
|
204
|
-
try {
|
|
205
|
-
const payload = await this.requestJson<{
|
|
206
|
-
ok: boolean;
|
|
207
|
-
result?: TelegramUpdate[];
|
|
208
|
-
}>("POST", "getUpdates", {
|
|
209
|
-
offset: this.lastUpdateId > 0 ? this.lastUpdateId + 1 : undefined,
|
|
210
|
-
timeout: Math.floor(this.timeoutMs / 1000),
|
|
211
|
-
allowed_updates: ["message", "callback_query"],
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
this.consecutiveErrors = 0;
|
|
215
|
-
|
|
216
|
-
if (!payload?.ok || !Array.isArray(payload.result)) {
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
for (const update of payload.result) {
|
|
221
|
-
if (update.update_id > this.lastUpdateId) this.lastUpdateId = update.update_id;
|
|
222
|
-
const chatId = getUpdateChatId(update);
|
|
223
|
-
if (chatId === undefined) continue;
|
|
224
|
-
if (!this.isAllowedChat(chatId)) continue;
|
|
225
|
-
try {
|
|
226
|
-
await onUpdate(update);
|
|
227
|
-
} catch (handlerError) {
|
|
228
|
-
this.logError("onUpdate handler", handlerError);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
} catch (error) {
|
|
232
|
-
if (this.controller.signal.aborted) break;
|
|
233
|
-
this.consecutiveErrors += 1;
|
|
234
|
-
this.logError("polling", error);
|
|
235
|
-
// Backoff: 1s → 60s capped
|
|
236
|
-
const backoff = Math.min(60_000, 1000 * 2 ** Math.min(5, this.consecutiveErrors - 1));
|
|
237
|
-
await sleep(backoff).catch(() => undefined);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
private async requestJson<T>(method: "GET" | "POST", endpoint: string, body?: Record<string, unknown>): Promise<T> {
|
|
243
|
-
const url = `${this.baseUrl}/${endpoint}`;
|
|
244
|
-
const init: RequestInit = {
|
|
245
|
-
method,
|
|
246
|
-
signal: this.controller.signal,
|
|
247
|
-
headers: body ? { "Content-Type": "application/json" } : undefined,
|
|
248
|
-
body: body ? JSON.stringify(removeUndefined(body)) : undefined,
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
const response = await fetch(url, init);
|
|
252
|
-
const text = await response.text();
|
|
253
|
-
let parsed: unknown;
|
|
254
|
-
try {
|
|
255
|
-
parsed = text ? JSON.parse(text) : {};
|
|
256
|
-
} catch {
|
|
257
|
-
throw new Error(`Telegram API ${endpoint} returned invalid JSON: ${text.slice(0, 200)}`);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
if (!response.ok || (isRecord(parsed) && parsed.ok === false)) {
|
|
261
|
-
const desc = isRecord(parsed) && typeof parsed.description === "string" ? parsed.description : text.slice(0, 200);
|
|
262
|
-
const errorCode = isRecord(parsed) && typeof parsed.error_code === "number" ? parsed.error_code : response.status;
|
|
263
|
-
throw new Error(`Telegram API ${endpoint} failed (${errorCode}): ${desc}`);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
return parsed as T;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
private logError(label: string, error: unknown): void {
|
|
270
|
-
const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
271
|
-
// Use stderr so we don't pollute the TUI. Pi redirects stderr to its log.
|
|
272
|
-
// eslint-disable-next-line no-console
|
|
273
|
-
console.error(`[telegram-mirror] ${label}: ${message}`);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
278
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function getUpdateChatId(update: TelegramUpdate): number | undefined {
|
|
282
|
-
// For callback_query Telegram may omit `message` or return an
|
|
283
|
-
// inaccessible message for older inline keyboards. In private chats the
|
|
284
|
-
// callback sender id is the chat id, so use it as a fallback; otherwise the
|
|
285
|
-
// auth gate silently drops button presses.
|
|
286
|
-
return update.message?.chat.id ?? update.callback_query?.message?.chat.id ?? update.callback_query?.from?.id;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function removeUndefined(value: Record<string, unknown>): Record<string, unknown> {
|
|
290
|
-
const out: Record<string, unknown> = {};
|
|
291
|
-
for (const [k, v] of Object.entries(value)) {
|
|
292
|
-
if (v !== undefined) out[k] = v;
|
|
293
|
-
}
|
|
294
|
-
return out;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function sleep(ms: number): Promise<void> {
|
|
298
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
299
|
-
}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pix event → TurnRenderer adapters.
|
|
3
|
-
*
|
|
4
|
-
* Keeps a per-turn renderer (one TG message chain per agent turn) and exposes
|
|
5
|
-
* ExtensionAPI event handlers that append to it.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import type { RendererEvent, RendererInstance } from "./renderer.js";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Minimal sink for rendering events. The leader wires this to a Multiplexer
|
|
13
|
-
* (which routes to a TurnRenderer for the active instance); a follower wires
|
|
14
|
-
* it to an IPC socket so events travel to the leader.
|
|
15
|
-
*/
|
|
16
|
-
export interface RendererSink {
|
|
17
|
-
push(event: RendererEvent): void;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function registerPixEventHandlers(pi: ExtensionAPI, hooks: PixMirrorHooks): void {
|
|
21
|
-
let turnActive = false;
|
|
22
|
-
|
|
23
|
-
pi.on("agent_start", (_event, ctx) => {
|
|
24
|
-
if (turnActive) return;
|
|
25
|
-
turnActive = true;
|
|
26
|
-
hooks.getRenderer()?.push({ kind: "turn_start", instance: hooks.describeInstance(ctx as ExtensionContext | undefined) });
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
pi.on("message_update", (event) => {
|
|
30
|
-
const type = event?.assistantMessageEvent?.type;
|
|
31
|
-
if (type === "text_delta") {
|
|
32
|
-
const delta = (event.assistantMessageEvent as { delta?: string }).delta ?? "";
|
|
33
|
-
if (delta) hooks.getRenderer()?.push({ kind: "assistant_text", delta });
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
// Ignore thinking and toolcall events. Telegram mirrors only the
|
|
37
|
-
// user-visible assistant answer, not internal reasoning/tools.
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
pi.on("agent_settled", () => {
|
|
41
|
-
if (!turnActive) return;
|
|
42
|
-
turnActive = false;
|
|
43
|
-
hooks.getRenderer()?.push({ kind: "turn_end", reason: "end" });
|
|
44
|
-
hooks.notifyAgentSettled();
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface PixMirrorHooks {
|
|
49
|
-
getRenderer(): RendererSink | undefined;
|
|
50
|
-
describeInstance(ctx: ExtensionContext | undefined): RendererInstance | undefined;
|
|
51
|
-
notifyAgentSettled(): void;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function captureAbortableContext(ctx: ExtensionContext | undefined, hooks: ContextCapture): void {
|
|
55
|
-
if (!ctx) return;
|
|
56
|
-
hooks.captureAbort(() => ctx.abort());
|
|
57
|
-
hooks.captureIdle(() => ctx.isIdle());
|
|
58
|
-
hooks.capturePending(() => ctx.hasPendingMessages());
|
|
59
|
-
hooks.captureCompact(() => ctx.compact());
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export interface ContextCapture {
|
|
63
|
-
captureAbort(fn: () => void): void;
|
|
64
|
-
captureIdle(fn: () => boolean): void;
|
|
65
|
-
capturePending(fn: () => boolean): void;
|
|
66
|
-
captureCompact(fn: () => void): void;
|
|
67
|
-
}
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal markdown → Telegram HTML conversion.
|
|
3
|
-
*
|
|
4
|
-
* Telegram supports a small subset of HTML: <b>, <i>, <code>, <pre>, <a>, <s>, <u>.
|
|
5
|
-
* See https://core.telegram.org/bots/api#html-style
|
|
6
|
-
*
|
|
7
|
-
* We escape < > & first, then re-introduce supported tags from common markdown
|
|
8
|
-
* patterns. This is intentionally lossy — blockquotes, headings, lists render
|
|
9
|
-
* as plain text. The goal is to keep streaming output legible on a phone screen.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
const ENTITY_RE = /[<>&]/g;
|
|
13
|
-
|
|
14
|
-
export function escapeHtml(value: string): string {
|
|
15
|
-
return value.replace(ENTITY_RE, (ch) => {
|
|
16
|
-
switch (ch) {
|
|
17
|
-
case "<":
|
|
18
|
-
return "<";
|
|
19
|
-
case ">":
|
|
20
|
-
return ">";
|
|
21
|
-
case "&":
|
|
22
|
-
return "&";
|
|
23
|
-
default:
|
|
24
|
-
return ch;
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Convert a chunk of assistant-flavoured markdown to Telegram HTML.
|
|
31
|
-
*
|
|
32
|
-
* Handles:
|
|
33
|
-
* ```lang\nfenced\n``` → <pre><code class="language-lang">…</code></pre>
|
|
34
|
-
* `inline code` → <code>…</code>
|
|
35
|
-
* **bold** → <b>…</b>
|
|
36
|
-
* __bold__ / *italic* → <b>…</b> / <i>…</i>
|
|
37
|
-
*
|
|
38
|
-
* Everything else is HTML-escaped.
|
|
39
|
-
*/
|
|
40
|
-
export function markdownToTelegram(value: string): string {
|
|
41
|
-
if (!value) return "";
|
|
42
|
-
|
|
43
|
-
// Pull out fenced code blocks first so their contents aren't mangled
|
|
44
|
-
// by inline replacements.
|
|
45
|
-
const fences: string[] = [];
|
|
46
|
-
const fenced = value.replace(/```([a-zA-Z0-9_+-]*)\n?([\s\S]*?)```/g, (_m, lang, body) => {
|
|
47
|
-
const langAttr = typeof lang === "string" && lang.trim() ? ` class="language-${escapeHtml(lang.trim())}"` : "";
|
|
48
|
-
const rendered = `<pre><code${langAttr}>${escapeHtml(body)}</code></pre>`;
|
|
49
|
-
fences.push(rendered);
|
|
50
|
-
return `\u0000FENCE${fences.length - 1}\u0000`;
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
let out = escapeHtml(fenced);
|
|
54
|
-
|
|
55
|
-
// Inline code (do before bold/italic so backticks inside ** stay escaped)
|
|
56
|
-
out = out.replace(/`([^`\n]+?)`/g, (_m, code) => `<code>${code}</code>`);
|
|
57
|
-
|
|
58
|
-
// Bold
|
|
59
|
-
out = out.replace(/\*\*([^*\n]+?)\*\*/g, "<b>$1</b>");
|
|
60
|
-
out = out.replace(/__([^_\n]+?)__/g, "<b>$1</b>");
|
|
61
|
-
|
|
62
|
-
// Italic (single * or _)
|
|
63
|
-
out = out.replace(/(^|[^*])\*([^*\n]+?)\*(?!\*)/g, "$1<i>$2</i>");
|
|
64
|
-
out = out.replace(/(^|[^_])_([^_\n]+?)_(?!_)/g, "$1<i>$2</i>");
|
|
65
|
-
|
|
66
|
-
// Restore fenced blocks
|
|
67
|
-
out = out.replace(/\u0000FENCE(\d+)\u0000/g, (_m, idx) => fences[Number(idx)] ?? "");
|
|
68
|
-
|
|
69
|
-
return out;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Telegram message size limit. */
|
|
73
|
-
export const TELEGRAM_MESSAGE_MAX = 4096;
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Split a rendered HTML payload into chunks that fit a single Telegram message.
|
|
77
|
-
* Tries to break on blank lines first, then on newlines, then hard-splits.
|
|
78
|
-
*
|
|
79
|
-
* Each chunk is guaranteed to be <= maxChars characters.
|
|
80
|
-
*/
|
|
81
|
-
export function chunkForTelegram(html: string, maxChars = TELEGRAM_MESSAGE_MAX): string[] {
|
|
82
|
-
if (html.length <= maxChars) return [html];
|
|
83
|
-
|
|
84
|
-
const chunks: string[] = [];
|
|
85
|
-
let cursor = 0;
|
|
86
|
-
while (cursor < html.length) {
|
|
87
|
-
const remaining = html.length - cursor;
|
|
88
|
-
if (remaining <= maxChars) {
|
|
89
|
-
chunks.push(html.slice(cursor));
|
|
90
|
-
break;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const window = html.slice(cursor, cursor + maxChars);
|
|
94
|
-
let cut = -1;
|
|
95
|
-
|
|
96
|
-
// Prefer blank line break
|
|
97
|
-
const blank = window.lastIndexOf("\n\n");
|
|
98
|
-
if (blank >= maxChars * 0.5) cut = blank + 1;
|
|
99
|
-
|
|
100
|
-
// Then any newline
|
|
101
|
-
if (cut < 0) {
|
|
102
|
-
const nl = window.lastIndexOf("\n");
|
|
103
|
-
if (nl >= maxChars * 0.5) cut = nl + 1;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Then a space
|
|
107
|
-
if (cut < 0) {
|
|
108
|
-
const sp = window.lastIndexOf(" ");
|
|
109
|
-
if (sp >= maxChars * 0.5) cut = sp + 1;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Hard cut
|
|
113
|
-
if (cut < 0) cut = maxChars;
|
|
114
|
-
|
|
115
|
-
chunks.push(html.slice(cursor, cursor + cut));
|
|
116
|
-
cursor += cut;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return chunks.filter((chunk) => chunk.length > 0);
|
|
120
|
-
}
|