@bytesbrains/pi-telegram-bridge 1.1.6 → 1.2.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/README.md +121 -34
- package/package.json +2 -2
- package/src/__tests__/helpers.test.ts +4 -4
- package/src/helpers.ts +30 -19
- package/src/index.ts +200 -25
package/README.md
CHANGED
|
@@ -1,45 +1,153 @@
|
|
|
1
1
|
# pi-telegram-bridge
|
|
2
2
|
|
|
3
|
-
Telegram bot bridge for pi agents — send messages, ask questions, and
|
|
3
|
+
Telegram bot bridge for pi agents — send messages, ask questions, receive photos, and get a live project dashboard on "hi".
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
pi install npm:@bytesbrains/pi-telegram-bridge
|
|
8
|
+
pi install npm:@bytesbrains/pi-telegram-bridge@1.1.6
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Quick Setup
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
### 1. Create a bot with @BotFather
|
|
14
|
+
|
|
15
|
+
Open Telegram and chat with [@BotFather](https://t.me/BotFather):
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
/newbot
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Follow the prompts. You'll get a token like:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
123456:ABC-DEF1234ghikl-zyx57W2v1u123ew11
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### 2. Get your Chat ID
|
|
28
|
+
|
|
29
|
+
Send ANY message to your new bot on Telegram, then visit this URL in your browser:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Look for `"chat":{"id":123456789}` in the response. That's your chat ID.
|
|
36
|
+
|
|
37
|
+
> **For group chats:** Add the bot to a group, send a message mentioning the bot, then check the same URL. Use the group chat ID.
|
|
38
|
+
|
|
39
|
+
### 3. Set environment variables
|
|
14
40
|
|
|
15
41
|
```bash
|
|
16
42
|
export TELEGRAM_BOT_TOKEN="123456:ABC-DEF1234ghikl-zyx57W2v1u123ew11"
|
|
17
43
|
export TELEGRAM_CHAT_ID="123456789"
|
|
18
44
|
```
|
|
19
45
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
46
|
+
Add them to your shell profile (`~/.zshrc`, `~/.bashrc`) to persist across sessions.
|
|
47
|
+
|
|
48
|
+
### 4. Verify
|
|
49
|
+
|
|
50
|
+
Start pi and run `telegram_status()`. You should see:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
Active. Bot: @your_bot_name (listener: 🟢 running)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Environment Variables
|
|
57
|
+
|
|
58
|
+
| Variable | Required | Default | Description |
|
|
59
|
+
| -------------------- | -------- | ----------------------- | --------------------------------------------------------------- |
|
|
60
|
+
| `TELEGRAM_BOT_TOKEN` | ✅ | — | Bot token from @BotFather |
|
|
61
|
+
| `TELEGRAM_CHAT_ID` | ✅ | — | Chat ID to send/receive messages |
|
|
62
|
+
| `TELEGRAM_MENTION` | ❌ | `@pi` (or bot username) | Only respond to messages containing this mention in group chats |
|
|
63
|
+
|
|
64
|
+
### TELEGRAM_MENTION
|
|
65
|
+
|
|
66
|
+
Controls which messages the agent responds to in **group chats**:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# Use a custom mention trigger
|
|
70
|
+
export TELEGRAM_MENTION="@mybot"
|
|
71
|
+
|
|
72
|
+
# Or let it auto-detect from the bot username
|
|
73
|
+
export TELEGRAM_MENTION="" # defaults to @bot_username
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- **Private chat** (direct message to bot): All messages are treated as directed — no mention needed
|
|
77
|
+
- **Group chat**: Only messages containing `@botname` are forwarded to the agent. Everything else is ignored.
|
|
78
|
+
|
|
79
|
+
## Features
|
|
80
|
+
|
|
81
|
+
### Status Dashboard
|
|
82
|
+
|
|
83
|
+
Send `hi`, `/status`, or `status` to your bot on Telegram. It replies with a live project summary:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
📊 pi-ext Status
|
|
87
|
+
|
|
88
|
+
📋 Open Issues: 3
|
|
89
|
+
#1 telegram-bridge HTML parse crash
|
|
90
|
+
#5 status dashboard on hi message
|
|
91
|
+
|
|
92
|
+
🔀 Open PRs: 2
|
|
93
|
+
✅ #2 fix(telegram-bridge): escape HTML entities
|
|
94
|
+
|
|
95
|
+
🔧 Last CI: ⏳ queued (5 recent runs)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Photo Support
|
|
99
|
+
|
|
100
|
+
**Send photos to the agent:** Send any photo to the bot — the agent downloads it and can use `read()` to view it. Add a caption for context:
|
|
101
|
+
|
|
102
|
+
> 📸 Photo: `login page broken, 404 in console`
|
|
103
|
+
|
|
104
|
+
**Send photos from the agent:** Use `telegram_send_photo()` to share screenshots or diagrams:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
telegram_send_photo(photoPath="/tmp/screenshot.png", caption="Build output")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### @Mention Support
|
|
111
|
+
|
|
112
|
+
In group chats, the bot only responds to messages that mention it. This lets humans talk freely without triggering the agent.
|
|
113
|
+
|
|
114
|
+
| Chat type | Message | Bot responds? |
|
|
115
|
+
| --------- | ------------------------ | ---------------------------------------- |
|
|
116
|
+
| Private | `hi` | ✅ |
|
|
117
|
+
| Private | `@pi what's the status?` | ✅ (mention stripped) |
|
|
118
|
+
| Group | `hi` | ❌ |
|
|
119
|
+
| Group | `@pi what's the status?` | ✅ (agent receives `what's the status?`) |
|
|
24
120
|
|
|
25
121
|
## Tools
|
|
26
122
|
|
|
27
123
|
### telegram_listen
|
|
28
124
|
|
|
29
|
-
Check for new inbound messages from the human.
|
|
125
|
+
Check for new inbound messages (text and photos) from the human.
|
|
30
126
|
|
|
31
127
|
```
|
|
32
128
|
telegram_listen()
|
|
33
129
|
```
|
|
34
130
|
|
|
131
|
+
Returns photo paths and captions if a photo was received.
|
|
132
|
+
|
|
35
133
|
### telegram_send
|
|
36
134
|
|
|
37
|
-
Send a one-way message.
|
|
135
|
+
Send a one-way text message.
|
|
38
136
|
|
|
39
137
|
```
|
|
40
138
|
telegram_send(message="Build completed successfully ✅")
|
|
41
139
|
```
|
|
42
140
|
|
|
141
|
+
### telegram_send_photo
|
|
142
|
+
|
|
143
|
+
Send a photo — supports local file paths and remote URLs.
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
telegram_send_photo(photoPath="/tmp/screenshot.png")
|
|
147
|
+
telegram_send_photo(photoPath="/tmp/screenshot.png", caption="Error in console")
|
|
148
|
+
telegram_send_photo(photoPath="https://example.com/image.jpg")
|
|
149
|
+
```
|
|
150
|
+
|
|
43
151
|
### telegram_ask
|
|
44
152
|
|
|
45
153
|
Ask a question with inline keyboard options and **wait** for a human reply. Blocks until answered or timeout (default 30 min).
|
|
@@ -87,6 +195,7 @@ telegram_override(
|
|
|
87
195
|
| `timedOut` | `boolean` | Whether the request timed out |
|
|
88
196
|
|
|
89
197
|
**Agent workflow:**
|
|
198
|
+
|
|
90
199
|
1. Agent gets blocked by supervisor
|
|
91
200
|
2. Calls `telegram_override(command, reason, context)`
|
|
92
201
|
3. Checks `details.action`:
|
|
@@ -103,33 +212,11 @@ Check if the bridge is configured and running.
|
|
|
103
212
|
telegram_status()
|
|
104
213
|
```
|
|
105
214
|
|
|
106
|
-
## Background Listener
|
|
107
|
-
|
|
108
|
-
The extension starts a background listener on session start that polls for incoming Telegram messages. Any non-bot text message in the configured chat is forwarded to the agent as a user message. The agent responds with a confirmation.
|
|
109
|
-
|
|
110
|
-
The listener runs automatically — no manual setup needed.
|
|
111
|
-
|
|
112
|
-
## How It Works
|
|
113
|
-
|
|
114
|
-
1. **Session start**: The bridge restores the last processed update ID from session state and starts polling
|
|
115
|
-
2. **Inbound messages**: Non-bot text messages in the configured chat are forwarded to the agent
|
|
116
|
-
3. **Session end**: The listener stops cleanly on session shutdown
|
|
117
|
-
4. **Update ID is persisted** across sessions to avoid processing duplicate messages
|
|
118
|
-
|
|
119
|
-
## Get a Bot Token
|
|
120
|
-
|
|
121
|
-
1. Open Telegram and chat with [@BotFather](https://t.me/BotFather)
|
|
122
|
-
2. Send `/newbot` and follow the prompts
|
|
123
|
-
3. Copy the token and set `TELEGRAM_BOT_TOKEN`
|
|
124
|
-
4. Send a message to your bot, then visit:
|
|
125
|
-
`https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates`
|
|
126
|
-
5. Copy the `chat.id` from the response and set `TELEGRAM_CHAT_ID`
|
|
127
|
-
|
|
128
215
|
## Requirements
|
|
129
216
|
|
|
130
217
|
- Node.js >= 18
|
|
131
|
-
- A Telegram bot token
|
|
132
|
-
- A chat ID
|
|
218
|
+
- A Telegram bot token (from @BotFather)
|
|
219
|
+
- A chat ID
|
|
133
220
|
|
|
134
221
|
## License
|
|
135
222
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bytesbrains/pi-telegram-bridge",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Telegram bot bridge for pi agents
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Telegram bot bridge for pi agents — send messages, ask questions, and listen for human replies via Telegram.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi-extension",
|
|
@@ -440,7 +440,7 @@ describe("pollUpdates", () => {
|
|
|
440
440
|
controller.signal, onMessage, vi.fn(), onUpdateId,
|
|
441
441
|
);
|
|
442
442
|
|
|
443
|
-
expect(onMessage).toHaveBeenCalledWith(
|
|
443
|
+
expect(onMessage).toHaveBeenCalledWith('Hello agent!', '987654321');
|
|
444
444
|
expect(onUpdateId).toHaveBeenCalledWith(100);
|
|
445
445
|
}, 15000);
|
|
446
446
|
|
|
@@ -618,7 +618,7 @@ describe("pollUpdates", () => {
|
|
|
618
618
|
controller.signal, onMessage, vi.fn(), onUpdateId,
|
|
619
619
|
);
|
|
620
620
|
|
|
621
|
-
expect(onMessage).toHaveBeenCalledWith(
|
|
621
|
+
expect(onMessage).toHaveBeenCalledWith('recovered', '987654321');
|
|
622
622
|
}, 20000);
|
|
623
623
|
|
|
624
624
|
it("handles network errors with retry", async () => {
|
|
@@ -663,7 +663,7 @@ describe("pollUpdates", () => {
|
|
|
663
663
|
controller.signal, onMessage, vi.fn(), onUpdateId,
|
|
664
664
|
);
|
|
665
665
|
|
|
666
|
-
expect(onMessage).toHaveBeenCalledWith(
|
|
666
|
+
expect(onMessage).toHaveBeenCalledWith('eventually', '987654321');
|
|
667
667
|
}, 25000);
|
|
668
668
|
|
|
669
669
|
it("stops gracefully when aborted immediately", async () => {
|
|
@@ -734,7 +734,7 @@ describe("pollUpdates", () => {
|
|
|
734
734
|
controller.signal, onMessage, vi.fn(), onUpdateId,
|
|
735
735
|
);
|
|
736
736
|
|
|
737
|
-
expect(onMessage).toHaveBeenCalledWith(
|
|
737
|
+
expect(onMessage).toHaveBeenCalledWith('after not-ok', '987654321');
|
|
738
738
|
}, 20000);
|
|
739
739
|
|
|
740
740
|
it("ignores messages without text field", async () => {
|
package/src/helpers.ts
CHANGED
|
@@ -57,7 +57,11 @@ export async function sendMsg(
|
|
|
57
57
|
} catch (e: unknown) {
|
|
58
58
|
// Retry without parse_mode if formatting caused a 400 parse error
|
|
59
59
|
const msg = e instanceof Error ? e.message : String(e);
|
|
60
|
-
if (
|
|
60
|
+
if (
|
|
61
|
+
parseMode &&
|
|
62
|
+
msg.includes("400") &&
|
|
63
|
+
(msg.includes("parse") || msg.includes("entity"))
|
|
64
|
+
) {
|
|
61
65
|
delete body.parse_mode;
|
|
62
66
|
const r2 = (await telegramApi("sendMessage", body)) as {
|
|
63
67
|
ok: boolean;
|
|
@@ -66,7 +70,8 @@ export async function sendMsg(
|
|
|
66
70
|
return r2.result.message_id;
|
|
67
71
|
}
|
|
68
72
|
throw e;
|
|
69
|
-
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
70
75
|
|
|
71
76
|
// ── Photo / File Support ────────────────────────────────────────────
|
|
72
77
|
|
|
@@ -77,14 +82,11 @@ export async function sendMsg(
|
|
|
77
82
|
export async function getFileUrl(fileId: string): Promise<string | null> {
|
|
78
83
|
const token = getToken();
|
|
79
84
|
try {
|
|
80
|
-
const res = await fetch(
|
|
81
|
-
|
|
82
|
-
{
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
body: JSON.stringify({ file_id: fileId }),
|
|
86
|
-
},
|
|
87
|
-
);
|
|
85
|
+
const res = await fetch(`${BASE_URL}${token}/getFile`, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "Content-Type": "application/json" },
|
|
88
|
+
body: JSON.stringify({ file_id: fileId }),
|
|
89
|
+
});
|
|
88
90
|
if (!res.ok) return null;
|
|
89
91
|
const data = (await res.json()) as {
|
|
90
92
|
ok: boolean;
|
|
@@ -102,7 +104,10 @@ export async function getFileUrl(fileId: string): Promise<string | null> {
|
|
|
102
104
|
* Returns the file path on success, null on failure.
|
|
103
105
|
* Creates parent directories automatically.
|
|
104
106
|
*/
|
|
105
|
-
export async function downloadFile(
|
|
107
|
+
export async function downloadFile(
|
|
108
|
+
fileId: string,
|
|
109
|
+
destPath: string,
|
|
110
|
+
): Promise<string | null> {
|
|
106
111
|
try {
|
|
107
112
|
const fileUrl = await getFileUrl(fileId);
|
|
108
113
|
if (!fileUrl) return null;
|
|
@@ -162,7 +167,11 @@ export async function sendPhoto(
|
|
|
162
167
|
if (!res.ok) {
|
|
163
168
|
const errText = await res.text();
|
|
164
169
|
// Retry without caption if parse_mode caused a 400
|
|
165
|
-
if (
|
|
170
|
+
if (
|
|
171
|
+
caption &&
|
|
172
|
+
res.status === 400 &&
|
|
173
|
+
(errText.includes("parse") || errText.includes("entity"))
|
|
174
|
+
) {
|
|
166
175
|
return sendPhoto(photoPath);
|
|
167
176
|
}
|
|
168
177
|
throw new Error(`sendPhoto failed: ${res.status} ${errText}`);
|
|
@@ -308,7 +317,7 @@ export async function pollUpdates(
|
|
|
308
317
|
botId: number | null,
|
|
309
318
|
lastUpdateId: number,
|
|
310
319
|
signal: AbortSignal,
|
|
311
|
-
onMessage: (text: string) => void,
|
|
320
|
+
onMessage: (text: string, msgChatId: string) => void,
|
|
312
321
|
onPhoto: (photoId: string, caption?: string) => void,
|
|
313
322
|
onUpdateId: (id: number) => void,
|
|
314
323
|
): Promise<void> {
|
|
@@ -333,7 +342,12 @@ export async function pollUpdates(
|
|
|
333
342
|
chat: { id: number };
|
|
334
343
|
from?: { id: number; is_bot?: boolean };
|
|
335
344
|
text?: string;
|
|
336
|
-
photo?: Array<{
|
|
345
|
+
photo?: Array<{
|
|
346
|
+
file_id: string;
|
|
347
|
+
file_unique_id: string;
|
|
348
|
+
width: number;
|
|
349
|
+
height: number;
|
|
350
|
+
}>;
|
|
337
351
|
caption?: string;
|
|
338
352
|
};
|
|
339
353
|
callback_query?: unknown;
|
|
@@ -349,10 +363,7 @@ export async function pollUpdates(
|
|
|
349
363
|
// Skip callback queries (handled by telegram_ask)
|
|
350
364
|
if (u.callback_query) continue;
|
|
351
365
|
// Process messages from the configured chat, not from our bot
|
|
352
|
-
if (
|
|
353
|
-
u.message &&
|
|
354
|
-
String(u.message.chat.id) === chatId
|
|
355
|
-
) {
|
|
366
|
+
if (u.message && String(u.message.chat.id) === chatId) {
|
|
356
367
|
if (u.message.from?.is_bot || u.message.from?.id === botId) {
|
|
357
368
|
continue;
|
|
358
369
|
}
|
|
@@ -364,7 +375,7 @@ export async function pollUpdates(
|
|
|
364
375
|
}
|
|
365
376
|
// Text message
|
|
366
377
|
if (u.message.text) {
|
|
367
|
-
onMessage(u.message.text);
|
|
378
|
+
onMessage(u.message.text, String(u.message.chat.id));
|
|
368
379
|
}
|
|
369
380
|
}
|
|
370
381
|
}
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,144 @@ export default function telegramBridge(pi: ExtensionAPI) {
|
|
|
37
37
|
let botId: number | null = null;
|
|
38
38
|
let listenerAbort: AbortController | null = null;
|
|
39
39
|
let lastUpdateId = 0;
|
|
40
|
+
let mentionTrigger = "@pi"; // configurable via TELEGRAM_MENTION env var
|
|
41
|
+
const sessionStartTime = Date.now();
|
|
42
|
+
let messageCount = 0;
|
|
43
|
+
|
|
44
|
+
// ── Mention support ────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
async function resolveMentionTrigger(): Promise<string> {
|
|
47
|
+
// 1. Explicit env var overrides everything
|
|
48
|
+
const envMention = process.env.TELEGRAM_MENTION;
|
|
49
|
+
if (envMention)
|
|
50
|
+
return envMention.startsWith("@") ? envMention : "@" + envMention;
|
|
51
|
+
// 2. Try bot username from getMe
|
|
52
|
+
try {
|
|
53
|
+
const r = (await telegramApi("getMe", {})) as {
|
|
54
|
+
result: { username: string };
|
|
55
|
+
};
|
|
56
|
+
if (r.result?.username) return "@" + r.result.username;
|
|
57
|
+
} catch {
|
|
58
|
+
/* fall through */
|
|
59
|
+
}
|
|
60
|
+
return "@pi";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Strip mention from message text and return cleaned text + whether it was mentioned. */
|
|
64
|
+
function checkMention(
|
|
65
|
+
text: string,
|
|
66
|
+
isPrivateChat: boolean,
|
|
67
|
+
): { mentioned: boolean; cleanedText: string } {
|
|
68
|
+
if (isPrivateChat) return { mentioned: true, cleanedText: text };
|
|
69
|
+
const trigger = mentionTrigger.slice(1).toLowerCase(); // strip @
|
|
70
|
+
const patterns = [
|
|
71
|
+
new RegExp(`@${trigger}\\b`, "i"), // @pi at start or middle
|
|
72
|
+
new RegExp(`^${trigger}\\b`, "i"), // pi at start (no @)
|
|
73
|
+
];
|
|
74
|
+
for (const re of patterns) {
|
|
75
|
+
if (re.test(text)) {
|
|
76
|
+
const cleaned = text.replace(re, "").trim();
|
|
77
|
+
return { mentioned: true, cleanedText: cleaned || text };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { mentioned: false, cleanedText: text };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Dashboard helper ────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
async function sendDashboard() {
|
|
86
|
+
const token = getToken();
|
|
87
|
+
const giteaToken = process.env.GITEA_TOKEN || process.env.GIT_TOKEN || "";
|
|
88
|
+
const apiBase = "http://127.0.0.1:3001/api/v1/repos/factory/pi-ext";
|
|
89
|
+
const headers: Record<string, string> = {
|
|
90
|
+
"Content-Type": "application/json",
|
|
91
|
+
};
|
|
92
|
+
if (giteaToken) headers["Authorization"] = `token ${giteaToken}`;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const [issuesR, prsR, runsR] = await Promise.all([
|
|
96
|
+
fetch(`${apiBase}/issues?state=open&limit=10`, { headers })
|
|
97
|
+
.then((r) => r.json())
|
|
98
|
+
.catch(() => []),
|
|
99
|
+
fetch(`${apiBase}/pulls?state=open&limit=10`, { headers })
|
|
100
|
+
.then((r) => r.json())
|
|
101
|
+
.catch(() => []),
|
|
102
|
+
fetch(`${apiBase}/actions/runs?limit=3`, { headers })
|
|
103
|
+
.then((r) => r.json())
|
|
104
|
+
.catch(() => ({ workflow_runs: [] })),
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
const issues = Array.isArray(issuesR) ? issuesR : [];
|
|
108
|
+
const prs = Array.isArray(prsR) ? prsR : [];
|
|
109
|
+
const runs = (runsR as any).workflow_runs ?? [];
|
|
110
|
+
|
|
111
|
+
const lines: string[] = [];
|
|
112
|
+
lines.push("📊 <b>pi-ext Status</b>");
|
|
113
|
+
lines.push("");
|
|
114
|
+
|
|
115
|
+
// Open issues
|
|
116
|
+
lines.push(`📋 <b>Open Issues:</b> ${issues.length}`);
|
|
117
|
+
if (issues.length > 0) {
|
|
118
|
+
for (const i of (issues as any[]).slice(0, 5)) {
|
|
119
|
+
const labels = i.labels?.length
|
|
120
|
+
? ` [${i.labels.map((l: any) => l.name).join(", ")}]`
|
|
121
|
+
: "";
|
|
122
|
+
lines.push(` #${i.number} ${i.title.slice(0, 60)}${labels}`);
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
lines.push(" (none)");
|
|
126
|
+
}
|
|
127
|
+
lines.push("");
|
|
128
|
+
|
|
129
|
+
// Open PRs
|
|
130
|
+
lines.push(`🔀 <b>Open PRs:</b> ${prs.length}`);
|
|
131
|
+
if (prs.length > 0) {
|
|
132
|
+
for (const p of (prs as any[]).slice(0, 5)) {
|
|
133
|
+
const mergeIcon = p.mergeable ? "✅" : "❌";
|
|
134
|
+
lines.push(` ${mergeIcon} #${p.number} ${p.title.slice(0, 55)}`);
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
lines.push(" (none)");
|
|
138
|
+
}
|
|
139
|
+
lines.push("");
|
|
140
|
+
|
|
141
|
+
// CI status
|
|
142
|
+
if (runs.length > 0) {
|
|
143
|
+
const latest = runs[0];
|
|
144
|
+
const statusIcon =
|
|
145
|
+
latest.status === "success"
|
|
146
|
+
? "✅"
|
|
147
|
+
: latest.status === "failure"
|
|
148
|
+
? "❌"
|
|
149
|
+
: latest.status === "running"
|
|
150
|
+
? "🔄"
|
|
151
|
+
: "⏳";
|
|
152
|
+
lines.push(
|
|
153
|
+
`🔧 <b>Last CI:</b> ${statusIcon} ${latest.status} (${runs.length} recent runs)`,
|
|
154
|
+
);
|
|
155
|
+
} else {
|
|
156
|
+
lines.push("🔧 <b>Last CI:</b> no runs");
|
|
157
|
+
}
|
|
158
|
+
lines.push("");
|
|
159
|
+
|
|
160
|
+
// Session metrics
|
|
161
|
+
const uptimeMin = Math.floor((Date.now() - sessionStartTime) / 60000);
|
|
162
|
+
const uptimeStr =
|
|
163
|
+
uptimeMin < 60
|
|
164
|
+
? `${uptimeMin}m`
|
|
165
|
+
: `${Math.floor(uptimeMin / 60)}h ${uptimeMin % 60}m`;
|
|
166
|
+
lines.push("🤖 <b>Session</b>");
|
|
167
|
+
lines.push(` Uptime: ${uptimeStr}`);
|
|
168
|
+
lines.push(` Messages: ${messageCount}`);
|
|
169
|
+
lines.push(` Mention: ${mentionTrigger}`);
|
|
170
|
+
|
|
171
|
+
await sendMsg(lines.join("\n"), undefined, "HTML");
|
|
172
|
+
} catch {
|
|
173
|
+
await sendMsg(
|
|
174
|
+
"⚠️ Could not fetch project dashboard. Gitea may be unreachable.",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
40
178
|
|
|
41
179
|
// ── Session lifecycle ───────────────────────────────────────────
|
|
42
180
|
|
|
@@ -52,6 +190,7 @@ export default function telegramBridge(pi: ExtensionAPI) {
|
|
|
52
190
|
}
|
|
53
191
|
}
|
|
54
192
|
// Start background listener
|
|
193
|
+
mentionTrigger = await resolveMentionTrigger();
|
|
55
194
|
startListener();
|
|
56
195
|
});
|
|
57
196
|
|
|
@@ -89,11 +228,21 @@ export default function telegramBridge(pi: ExtensionAPI) {
|
|
|
89
228
|
botId,
|
|
90
229
|
lastUpdateId,
|
|
91
230
|
signal,
|
|
92
|
-
// onMessage: forward
|
|
93
|
-
async (text: string) => {
|
|
94
|
-
|
|
231
|
+
// onMessage: forward to pi (with mention check in groups), or dashboard on "hi"
|
|
232
|
+
async (text: string, msgChatId: string) => {
|
|
233
|
+
const isPrivate = msgChatId === chatId;
|
|
234
|
+
const { mentioned, cleanedText } = checkMention(text, isPrivate);
|
|
235
|
+
if (!mentioned) return; // ignore unmentioned messages in groups
|
|
236
|
+
|
|
237
|
+
const trimmed = cleanedText.trim().toLowerCase();
|
|
238
|
+
if (trimmed === "hi" || trimmed === "/status" || trimmed === "status") {
|
|
239
|
+
await sendDashboard();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
messageCount++;
|
|
243
|
+
pi.sendUserMessage(cleanedText);
|
|
95
244
|
await sendMsg(
|
|
96
|
-
`👂 Got it! Working on: _${
|
|
245
|
+
`👂 Got it! Working on: _${cleanedText.slice(0, 100)}_`,
|
|
97
246
|
undefined,
|
|
98
247
|
"Markdown",
|
|
99
248
|
);
|
|
@@ -111,8 +260,12 @@ export default function telegramBridge(pi: ExtensionAPI) {
|
|
|
111
260
|
);
|
|
112
261
|
await sendMsg("📸 Photo received! Processing...");
|
|
113
262
|
} else {
|
|
114
|
-
pi.sendUserMessage(
|
|
115
|
-
|
|
263
|
+
pi.sendUserMessage(
|
|
264
|
+
"📸 Photo received from Telegram but could not be downloaded.",
|
|
265
|
+
);
|
|
266
|
+
await sendMsg(
|
|
267
|
+
"⚠️ Could not download your photo. Please check the bot configuration.",
|
|
268
|
+
);
|
|
116
269
|
}
|
|
117
270
|
},
|
|
118
271
|
// onUpdateId: persist offset
|
|
@@ -177,38 +330,53 @@ export default function telegramBridge(pi: ExtensionAPI) {
|
|
|
177
330
|
lastUpdateId = u.update_id;
|
|
178
331
|
pi.appendEntry("tg-last-update", { update_id: lastUpdateId });
|
|
179
332
|
if (u.callback_query) continue;
|
|
180
|
-
if (
|
|
181
|
-
u.message &&
|
|
182
|
-
String(u.message.chat.id) === chatId
|
|
183
|
-
) {
|
|
333
|
+
if (u.message && String(u.message.chat.id) === chatId) {
|
|
184
334
|
if (u.message.from?.is_bot || u.message.from?.id === botId)
|
|
185
335
|
continue;
|
|
186
336
|
// Photo message
|
|
187
337
|
if (u.message.photo && u.message.photo.length > 0) {
|
|
188
|
-
const photoId =
|
|
338
|
+
const photoId =
|
|
339
|
+
u.message.photo[u.message.photo.length - 1].file_id;
|
|
189
340
|
const caption = u.message.caption || "";
|
|
190
341
|
const timestamp = Date.now();
|
|
191
342
|
const fsPromises = await import("node:fs/promises");
|
|
192
343
|
const photoDirLocal = path.join(os.tmpdir(), "telegram-photos");
|
|
193
|
-
await fsPromises
|
|
194
|
-
|
|
344
|
+
await fsPromises
|
|
345
|
+
.mkdir(photoDirLocal, { recursive: true })
|
|
346
|
+
.catch(() => {});
|
|
347
|
+
const destFile = path.join(
|
|
348
|
+
photoDirLocal,
|
|
349
|
+
`photo_${timestamp}.jpg`,
|
|
350
|
+
);
|
|
195
351
|
const result = await downloadFile(photoId, destFile);
|
|
196
352
|
const captionInfo = caption ? `\nCaption: "${caption}"` : "";
|
|
197
353
|
if (result) {
|
|
198
354
|
return {
|
|
199
|
-
content: [
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
355
|
+
content: [
|
|
356
|
+
{
|
|
357
|
+
type: "text",
|
|
358
|
+
text: `📸 Photo received from Telegram\nPath: ${result}${captionInfo}\n\nUse read("${result}") to view the image.`,
|
|
359
|
+
},
|
|
360
|
+
],
|
|
361
|
+
details: {
|
|
362
|
+
message: caption,
|
|
363
|
+
photoPath: result,
|
|
364
|
+
hasPhoto: true,
|
|
365
|
+
},
|
|
204
366
|
};
|
|
205
367
|
}
|
|
206
368
|
return {
|
|
207
|
-
content: [
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
369
|
+
content: [
|
|
370
|
+
{
|
|
371
|
+
type: "text",
|
|
372
|
+
text: `📸 Photo received from Telegram but could not be downloaded.${captionInfo}`,
|
|
373
|
+
},
|
|
374
|
+
],
|
|
375
|
+
details: {
|
|
376
|
+
message: caption,
|
|
377
|
+
hasPhoto: true,
|
|
378
|
+
downloadFailed: true,
|
|
379
|
+
},
|
|
212
380
|
};
|
|
213
381
|
}
|
|
214
382
|
// Text message
|
|
@@ -398,7 +566,11 @@ export default function telegramBridge(pi: ExtensionAPI) {
|
|
|
398
566
|
action = "explain";
|
|
399
567
|
}
|
|
400
568
|
|
|
401
|
-
await sendMsg(
|
|
569
|
+
await sendMsg(
|
|
570
|
+
`✅ Choice received: <i>${choice}</i>`,
|
|
571
|
+
undefined,
|
|
572
|
+
"HTML",
|
|
573
|
+
);
|
|
402
574
|
|
|
403
575
|
return {
|
|
404
576
|
content: [
|
|
@@ -478,7 +650,10 @@ export default function telegramBridge(pi: ExtensionAPI) {
|
|
|
478
650
|
description:
|
|
479
651
|
"Send a photo to the Telegram chat. Supports local file paths and remote URLs.",
|
|
480
652
|
parameters: sendPhotoSchema,
|
|
481
|
-
async execute(
|
|
653
|
+
async execute(
|
|
654
|
+
_id: string,
|
|
655
|
+
params: { photoPath: string; caption?: string },
|
|
656
|
+
) {
|
|
482
657
|
try {
|
|
483
658
|
const mid = await sendPhoto(params.photoPath, params.caption);
|
|
484
659
|
return {
|