@bytesbrains/pi-telegram-bridge 1.1.5 → 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 +1 -1
- package/src/__tests__/helpers.test.ts +13 -13
- package/src/__tests__/index.test.ts +3 -2
- package/src/helpers.ts +152 -10
- package/src/index.ts +284 -18
- package/src/templates.ts +14 -6
- package/src/tools/telegram.ts +5 -0
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
|
@@ -437,10 +437,10 @@ describe("pollUpdates", () => {
|
|
|
437
437
|
// pollUpdates has 5s delay between polls — this test takes ~5s
|
|
438
438
|
await helpers.pollUpdates(
|
|
439
439
|
TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
|
|
440
|
-
controller.signal, onMessage, onUpdateId,
|
|
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
|
|
|
@@ -482,7 +482,7 @@ describe("pollUpdates", () => {
|
|
|
482
482
|
|
|
483
483
|
await helpers.pollUpdates(
|
|
484
484
|
TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
|
|
485
|
-
controller.signal, onMessage, onUpdateId,
|
|
485
|
+
controller.signal, onMessage, vi.fn(), onUpdateId,
|
|
486
486
|
);
|
|
487
487
|
|
|
488
488
|
expect(onMessage).not.toHaveBeenCalled();
|
|
@@ -527,7 +527,7 @@ describe("pollUpdates", () => {
|
|
|
527
527
|
|
|
528
528
|
await helpers.pollUpdates(
|
|
529
529
|
TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
|
|
530
|
-
controller.signal, onMessage, onUpdateId,
|
|
530
|
+
controller.signal, onMessage, vi.fn(), onUpdateId,
|
|
531
531
|
);
|
|
532
532
|
|
|
533
533
|
expect(onMessage).not.toHaveBeenCalled();
|
|
@@ -567,7 +567,7 @@ describe("pollUpdates", () => {
|
|
|
567
567
|
|
|
568
568
|
await helpers.pollUpdates(
|
|
569
569
|
TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
|
|
570
|
-
controller.signal, onMessage, onUpdateId,
|
|
570
|
+
controller.signal, onMessage, vi.fn(), onUpdateId,
|
|
571
571
|
);
|
|
572
572
|
|
|
573
573
|
expect(onMessage).not.toHaveBeenCalled();
|
|
@@ -615,10 +615,10 @@ describe("pollUpdates", () => {
|
|
|
615
615
|
|
|
616
616
|
await helpers.pollUpdates(
|
|
617
617
|
TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
|
|
618
|
-
controller.signal, onMessage, onUpdateId,
|
|
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 () => {
|
|
@@ -660,10 +660,10 @@ describe("pollUpdates", () => {
|
|
|
660
660
|
|
|
661
661
|
await helpers.pollUpdates(
|
|
662
662
|
TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
|
|
663
|
-
controller.signal, onMessage, onUpdateId,
|
|
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 () => {
|
|
@@ -681,7 +681,7 @@ describe("pollUpdates", () => {
|
|
|
681
681
|
|
|
682
682
|
await helpers.pollUpdates(
|
|
683
683
|
TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
|
|
684
|
-
controller.signal, onMessage, onUpdateId,
|
|
684
|
+
controller.signal, onMessage, vi.fn(), onUpdateId,
|
|
685
685
|
);
|
|
686
686
|
|
|
687
687
|
expect(onMessage).not.toHaveBeenCalled();
|
|
@@ -731,10 +731,10 @@ describe("pollUpdates", () => {
|
|
|
731
731
|
|
|
732
732
|
await helpers.pollUpdates(
|
|
733
733
|
TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
|
|
734
|
-
controller.signal, onMessage, onUpdateId,
|
|
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 () => {
|
|
@@ -775,7 +775,7 @@ describe("pollUpdates", () => {
|
|
|
775
775
|
|
|
776
776
|
await helpers.pollUpdates(
|
|
777
777
|
TEST_TOKEN, TEST_CHAT_ID, 12345, 0,
|
|
778
|
-
controller.signal, onMessage, onUpdateId,
|
|
778
|
+
controller.signal, onMessage, vi.fn(), onUpdateId,
|
|
779
779
|
);
|
|
780
780
|
|
|
781
781
|
expect(onMessage).not.toHaveBeenCalled();
|
|
@@ -104,17 +104,18 @@ afterEach(() => {
|
|
|
104
104
|
// ── Tool Registration ─────────────────────────────────────────────
|
|
105
105
|
|
|
106
106
|
describe("extension registration", () => {
|
|
107
|
-
it("registers all
|
|
107
|
+
it("registers all 7 tools", () => {
|
|
108
108
|
const pi = createMockPI();
|
|
109
109
|
telegramBridge(pi as any);
|
|
110
110
|
|
|
111
|
-
expect(pi.registerTool).toHaveBeenCalledTimes(
|
|
111
|
+
expect(pi.registerTool).toHaveBeenCalledTimes(7);
|
|
112
112
|
expect(pi._getTool("telegram_listen")).toBeDefined();
|
|
113
113
|
expect(pi._getTool("telegram_send")).toBeDefined();
|
|
114
114
|
expect(pi._getTool("telegram_ask")).toBeDefined();
|
|
115
115
|
expect(pi._getTool("telegram_override")).toBeDefined();
|
|
116
116
|
expect(pi._getTool("telegram_status")).toBeDefined();
|
|
117
117
|
expect(pi._getTool("telegram_notify")).toBeDefined();
|
|
118
|
+
expect(pi._getTool("telegram_send_photo")).toBeDefined();
|
|
118
119
|
});
|
|
119
120
|
|
|
120
121
|
it("registers session_start and session_shutdown handlers", () => {
|
package/src/helpers.ts
CHANGED
|
@@ -48,11 +48,140 @@ export async function sendMsg(
|
|
|
48
48
|
parse_mode: parseMode,
|
|
49
49
|
};
|
|
50
50
|
if (replyMarkup) body.reply_markup = JSON.stringify(replyMarkup);
|
|
51
|
-
|
|
51
|
+
try {
|
|
52
|
+
const r = (await telegramApi("sendMessage", body)) as {
|
|
53
|
+
ok: boolean;
|
|
54
|
+
result: { message_id: number };
|
|
55
|
+
};
|
|
56
|
+
return r.result.message_id;
|
|
57
|
+
} catch (e: unknown) {
|
|
58
|
+
// Retry without parse_mode if formatting caused a 400 parse error
|
|
59
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
60
|
+
if (
|
|
61
|
+
parseMode &&
|
|
62
|
+
msg.includes("400") &&
|
|
63
|
+
(msg.includes("parse") || msg.includes("entity"))
|
|
64
|
+
) {
|
|
65
|
+
delete body.parse_mode;
|
|
66
|
+
const r2 = (await telegramApi("sendMessage", body)) as {
|
|
67
|
+
ok: boolean;
|
|
68
|
+
result: { message_id: number };
|
|
69
|
+
};
|
|
70
|
+
return r2.result.message_id;
|
|
71
|
+
}
|
|
72
|
+
throw e;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Photo / File Support ────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get a Telegram file URL for downloading.
|
|
80
|
+
* Returns { file_path } from the getFile API.
|
|
81
|
+
*/
|
|
82
|
+
export async function getFileUrl(fileId: string): Promise<string | null> {
|
|
83
|
+
const token = getToken();
|
|
84
|
+
try {
|
|
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
|
+
});
|
|
90
|
+
if (!res.ok) return null;
|
|
91
|
+
const data = (await res.json()) as {
|
|
92
|
+
ok: boolean;
|
|
93
|
+
result: { file_path: string };
|
|
94
|
+
};
|
|
95
|
+
if (!data.ok || !data.result?.file_path) return null;
|
|
96
|
+
return `https://api.telegram.org/file/bot${token}/${data.result.file_path}`;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Download a Telegram file to a local path.
|
|
104
|
+
* Returns the file path on success, null on failure.
|
|
105
|
+
* Creates parent directories automatically.
|
|
106
|
+
*/
|
|
107
|
+
export async function downloadFile(
|
|
108
|
+
fileId: string,
|
|
109
|
+
destPath: string,
|
|
110
|
+
): Promise<string | null> {
|
|
111
|
+
try {
|
|
112
|
+
const fileUrl = await getFileUrl(fileId);
|
|
113
|
+
if (!fileUrl) return null;
|
|
114
|
+
const res = await fetch(fileUrl);
|
|
115
|
+
if (!res.ok) return null;
|
|
116
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
117
|
+
const path = await import("node:path");
|
|
118
|
+
const fsPromises = await import("node:fs/promises");
|
|
119
|
+
const dir = path.dirname(destPath);
|
|
120
|
+
await fsPromises.mkdir(dir, { recursive: true });
|
|
121
|
+
await fsPromises.writeFile(destPath, buffer);
|
|
122
|
+
return destPath;
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Send a photo to the Telegram chat.
|
|
130
|
+
* Supports sending from a local file path or a remote URL.
|
|
131
|
+
*/
|
|
132
|
+
export async function sendPhoto(
|
|
133
|
+
photoPath: string,
|
|
134
|
+
caption?: string,
|
|
135
|
+
): Promise<number> {
|
|
136
|
+
const fs = await import("node:fs/promises");
|
|
137
|
+
let fileData: Buffer;
|
|
138
|
+
let filename: string;
|
|
139
|
+
|
|
140
|
+
// Check if it's a URL
|
|
141
|
+
if (photoPath.startsWith("http://") || photoPath.startsWith("https://")) {
|
|
142
|
+
filename = "photo.jpg";
|
|
143
|
+
const res = await fetch(photoPath);
|
|
144
|
+
if (!res.ok) throw new Error(`Failed to fetch photo: ${res.status}`);
|
|
145
|
+
fileData = Buffer.from(await res.arrayBuffer());
|
|
146
|
+
} else {
|
|
147
|
+
filename = photoPath;
|
|
148
|
+
fileData = await fs.readFile(photoPath);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const token = getToken();
|
|
152
|
+
const formData = new FormData();
|
|
153
|
+
formData.set("chat_id", getChatId());
|
|
154
|
+
const blob = new Blob([fileData] as any);
|
|
155
|
+
formData.set("photo", blob, filename);
|
|
156
|
+
if (caption) {
|
|
157
|
+
formData.set("caption", caption);
|
|
158
|
+
formData.set("parse_mode", "HTML");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const url = `${BASE_URL}${token}/sendPhoto`;
|
|
162
|
+
const res = await fetch(url, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
body: formData,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (!res.ok) {
|
|
168
|
+
const errText = await res.text();
|
|
169
|
+
// Retry without caption if parse_mode caused a 400
|
|
170
|
+
if (
|
|
171
|
+
caption &&
|
|
172
|
+
res.status === 400 &&
|
|
173
|
+
(errText.includes("parse") || errText.includes("entity"))
|
|
174
|
+
) {
|
|
175
|
+
return sendPhoto(photoPath);
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`sendPhoto failed: ${res.status} ${errText}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const data = (await res.json()) as {
|
|
52
181
|
ok: boolean;
|
|
53
182
|
result: { message_id: number };
|
|
54
183
|
};
|
|
55
|
-
return
|
|
184
|
+
return data.result.message_id;
|
|
56
185
|
}
|
|
57
186
|
|
|
58
187
|
// ── Polling ─────────────────────────────────────────────────────────
|
|
@@ -188,7 +317,8 @@ export async function pollUpdates(
|
|
|
188
317
|
botId: number | null,
|
|
189
318
|
lastUpdateId: number,
|
|
190
319
|
signal: AbortSignal,
|
|
191
|
-
onMessage: (text: string) => void,
|
|
320
|
+
onMessage: (text: string, msgChatId: string) => void,
|
|
321
|
+
onPhoto: (photoId: string, caption?: string) => void,
|
|
192
322
|
onUpdateId: (id: number) => void,
|
|
193
323
|
): Promise<void> {
|
|
194
324
|
while (!signal.aborted) {
|
|
@@ -212,6 +342,13 @@ export async function pollUpdates(
|
|
|
212
342
|
chat: { id: number };
|
|
213
343
|
from?: { id: number; is_bot?: boolean };
|
|
214
344
|
text?: string;
|
|
345
|
+
photo?: Array<{
|
|
346
|
+
file_id: string;
|
|
347
|
+
file_unique_id: string;
|
|
348
|
+
width: number;
|
|
349
|
+
height: number;
|
|
350
|
+
}>;
|
|
351
|
+
caption?: string;
|
|
215
352
|
};
|
|
216
353
|
callback_query?: unknown;
|
|
217
354
|
}>;
|
|
@@ -225,16 +362,21 @@ export async function pollUpdates(
|
|
|
225
362
|
onUpdateId(lastUpdateId);
|
|
226
363
|
// Skip callback queries (handled by telegram_ask)
|
|
227
364
|
if (u.callback_query) continue;
|
|
228
|
-
//
|
|
229
|
-
if (
|
|
230
|
-
u.message &&
|
|
231
|
-
u.message.text &&
|
|
232
|
-
String(u.message.chat.id) === chatId
|
|
233
|
-
) {
|
|
365
|
+
// Process messages from the configured chat, not from our bot
|
|
366
|
+
if (u.message && String(u.message.chat.id) === chatId) {
|
|
234
367
|
if (u.message.from?.is_bot || u.message.from?.id === botId) {
|
|
235
368
|
continue;
|
|
236
369
|
}
|
|
237
|
-
|
|
370
|
+
// Photo message
|
|
371
|
+
if (u.message.photo && u.message.photo.length > 0) {
|
|
372
|
+
const largest = u.message.photo[u.message.photo.length - 1];
|
|
373
|
+
onPhoto(largest.file_id, u.message.caption);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
// Text message
|
|
377
|
+
if (u.message.text) {
|
|
378
|
+
onMessage(u.message.text, String(u.message.chat.id));
|
|
379
|
+
}
|
|
238
380
|
}
|
|
239
381
|
}
|
|
240
382
|
} catch (e: unknown) {
|
package/src/index.ts
CHANGED
|
@@ -13,11 +13,16 @@ import {
|
|
|
13
13
|
getChatId,
|
|
14
14
|
telegramApi,
|
|
15
15
|
sendMsg,
|
|
16
|
+
sendPhoto,
|
|
17
|
+
getFileUrl,
|
|
18
|
+
downloadFile,
|
|
16
19
|
pollReply,
|
|
17
20
|
getBotId,
|
|
18
21
|
seedLastUpdateId,
|
|
19
22
|
pollUpdates,
|
|
20
23
|
} from "./helpers";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import * as os from "node:os";
|
|
21
26
|
import {
|
|
22
27
|
listenSchema,
|
|
23
28
|
sendSchema,
|
|
@@ -25,16 +30,159 @@ import {
|
|
|
25
30
|
statusSchema,
|
|
26
31
|
overrideSchema,
|
|
27
32
|
notifySchema,
|
|
33
|
+
sendPhotoSchema,
|
|
28
34
|
} from "./tools/telegram";
|
|
29
35
|
|
|
30
36
|
export default function telegramBridge(pi: ExtensionAPI) {
|
|
31
37
|
let botId: number | null = null;
|
|
32
38
|
let listenerAbort: AbortController | null = null;
|
|
33
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
|
+
}
|
|
34
178
|
|
|
35
179
|
// ── Session lifecycle ───────────────────────────────────────────
|
|
36
180
|
|
|
37
181
|
pi.on("session_start", async (_event, ctx) => {
|
|
182
|
+
// Ensure photo download directory exists
|
|
183
|
+
const { mkdir } = await import("node:fs/promises");
|
|
184
|
+
const photoDir = path.join(os.tmpdir(), "telegram-photos");
|
|
185
|
+
await mkdir(photoDir, { recursive: true }).catch(() => {});
|
|
38
186
|
// Restore lastUpdateId from session state
|
|
39
187
|
for (const entry of ctx.sessionManager.getEntries()) {
|
|
40
188
|
if (entry.type === "custom" && entry.customType === "tg-last-update") {
|
|
@@ -42,6 +190,7 @@ export default function telegramBridge(pi: ExtensionAPI) {
|
|
|
42
190
|
}
|
|
43
191
|
}
|
|
44
192
|
// Start background listener
|
|
193
|
+
mentionTrigger = await resolveMentionTrigger();
|
|
45
194
|
startListener();
|
|
46
195
|
});
|
|
47
196
|
|
|
@@ -67,6 +216,11 @@ export default function telegramBridge(pi: ExtensionAPI) {
|
|
|
67
216
|
// Seed lastUpdateId
|
|
68
217
|
lastUpdateId = await seedLastUpdateId(lastUpdateId, signal);
|
|
69
218
|
|
|
219
|
+
// Create photo download directory
|
|
220
|
+
const fsPromises = await import("node:fs/promises");
|
|
221
|
+
const photoDir = path.join(os.tmpdir(), "telegram-photos");
|
|
222
|
+
await fsPromises.mkdir(photoDir, { recursive: true }).catch(() => {});
|
|
223
|
+
|
|
70
224
|
// Fire and forget — poll in background
|
|
71
225
|
pollUpdates(
|
|
72
226
|
token,
|
|
@@ -74,15 +228,46 @@ export default function telegramBridge(pi: ExtensionAPI) {
|
|
|
74
228
|
botId,
|
|
75
229
|
lastUpdateId,
|
|
76
230
|
signal,
|
|
77
|
-
// onMessage: forward to pi
|
|
78
|
-
async (text: string) => {
|
|
79
|
-
|
|
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);
|
|
80
244
|
await sendMsg(
|
|
81
|
-
`👂 Got it! Working on: _${
|
|
245
|
+
`👂 Got it! Working on: _${cleanedText.slice(0, 100)}_`,
|
|
82
246
|
undefined,
|
|
83
247
|
"Markdown",
|
|
84
248
|
);
|
|
85
249
|
},
|
|
250
|
+
// onPhoto: download and forward photo + caption to pi
|
|
251
|
+
async (photoId: string, caption?: string) => {
|
|
252
|
+
const timestamp = Date.now();
|
|
253
|
+
const filename = `photo_${timestamp}.jpg`;
|
|
254
|
+
const destPath = path.join(photoDir, filename);
|
|
255
|
+
const result = await downloadFile(photoId, destPath);
|
|
256
|
+
if (result) {
|
|
257
|
+
const captionText = caption ? `\n\n📝 Caption: ${caption}` : "";
|
|
258
|
+
pi.sendUserMessage(
|
|
259
|
+
`📸 Photo received from Telegram\nPath: ${result}${captionText}\n\nUse read("${result}") to view the image.`,
|
|
260
|
+
);
|
|
261
|
+
await sendMsg("📸 Photo received! Processing...");
|
|
262
|
+
} else {
|
|
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
|
+
);
|
|
269
|
+
}
|
|
270
|
+
},
|
|
86
271
|
// onUpdateId: persist offset
|
|
87
272
|
(id: number) => {
|
|
88
273
|
lastUpdateId = id;
|
|
@@ -145,22 +330,67 @@ export default function telegramBridge(pi: ExtensionAPI) {
|
|
|
145
330
|
lastUpdateId = u.update_id;
|
|
146
331
|
pi.appendEntry("tg-last-update", { update_id: lastUpdateId });
|
|
147
332
|
if (u.callback_query) continue;
|
|
148
|
-
if (
|
|
149
|
-
u.message &&
|
|
150
|
-
u.message.text &&
|
|
151
|
-
String(u.message.chat.id) === chatId
|
|
152
|
-
) {
|
|
333
|
+
if (u.message && String(u.message.chat.id) === chatId) {
|
|
153
334
|
if (u.message.from?.is_bot || u.message.from?.id === botId)
|
|
154
335
|
continue;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
336
|
+
// Photo message
|
|
337
|
+
if (u.message.photo && u.message.photo.length > 0) {
|
|
338
|
+
const photoId =
|
|
339
|
+
u.message.photo[u.message.photo.length - 1].file_id;
|
|
340
|
+
const caption = u.message.caption || "";
|
|
341
|
+
const timestamp = Date.now();
|
|
342
|
+
const fsPromises = await import("node:fs/promises");
|
|
343
|
+
const photoDirLocal = path.join(os.tmpdir(), "telegram-photos");
|
|
344
|
+
await fsPromises
|
|
345
|
+
.mkdir(photoDirLocal, { recursive: true })
|
|
346
|
+
.catch(() => {});
|
|
347
|
+
const destFile = path.join(
|
|
348
|
+
photoDirLocal,
|
|
349
|
+
`photo_${timestamp}.jpg`,
|
|
350
|
+
);
|
|
351
|
+
const result = await downloadFile(photoId, destFile);
|
|
352
|
+
const captionInfo = caption ? `\nCaption: "${caption}"` : "";
|
|
353
|
+
if (result) {
|
|
354
|
+
return {
|
|
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
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
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,
|
|
160
379
|
},
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
// Text message
|
|
383
|
+
if (u.message.text) {
|
|
384
|
+
return {
|
|
385
|
+
content: [
|
|
386
|
+
{
|
|
387
|
+
type: "text",
|
|
388
|
+
text: `📩 New message: "${u.message.text}"`,
|
|
389
|
+
},
|
|
390
|
+
],
|
|
391
|
+
details: { message: u.message.text },
|
|
392
|
+
};
|
|
393
|
+
}
|
|
164
394
|
}
|
|
165
395
|
}
|
|
166
396
|
return {
|
|
@@ -336,7 +566,11 @@ export default function telegramBridge(pi: ExtensionAPI) {
|
|
|
336
566
|
action = "explain";
|
|
337
567
|
}
|
|
338
568
|
|
|
339
|
-
await sendMsg(
|
|
569
|
+
await sendMsg(
|
|
570
|
+
`✅ Choice received: <i>${choice}</i>`,
|
|
571
|
+
undefined,
|
|
572
|
+
"HTML",
|
|
573
|
+
);
|
|
340
574
|
|
|
341
575
|
return {
|
|
342
576
|
content: [
|
|
@@ -409,6 +643,38 @@ export default function telegramBridge(pi: ExtensionAPI) {
|
|
|
409
643
|
},
|
|
410
644
|
});
|
|
411
645
|
|
|
646
|
+
// telegram_send_photo
|
|
647
|
+
pi.registerTool({
|
|
648
|
+
name: "telegram_send_photo",
|
|
649
|
+
label: "Send Photo",
|
|
650
|
+
description:
|
|
651
|
+
"Send a photo to the Telegram chat. Supports local file paths and remote URLs.",
|
|
652
|
+
parameters: sendPhotoSchema,
|
|
653
|
+
async execute(
|
|
654
|
+
_id: string,
|
|
655
|
+
params: { photoPath: string; caption?: string },
|
|
656
|
+
) {
|
|
657
|
+
try {
|
|
658
|
+
const mid = await sendPhoto(params.photoPath, params.caption);
|
|
659
|
+
return {
|
|
660
|
+
content: [{ type: "text", text: `Photo sent (id:${mid})` }],
|
|
661
|
+
details: { messageId: mid },
|
|
662
|
+
};
|
|
663
|
+
} catch (e: unknown) {
|
|
664
|
+
return {
|
|
665
|
+
content: [
|
|
666
|
+
{
|
|
667
|
+
type: "text",
|
|
668
|
+
text: `Failed to send photo: ${e instanceof Error ? e.message : e}`,
|
|
669
|
+
},
|
|
670
|
+
],
|
|
671
|
+
details: {},
|
|
672
|
+
isError: true,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
|
|
412
678
|
// telegram_status
|
|
413
679
|
pi.registerTool({
|
|
414
680
|
name: "telegram_status",
|
package/src/templates.ts
CHANGED
|
@@ -10,23 +10,31 @@
|
|
|
10
10
|
|
|
11
11
|
const BAR = "━".repeat(20);
|
|
12
12
|
|
|
13
|
+
/** Escape user text for safe embedding inside HTML tags. */
|
|
14
|
+
function escapeHtml(s: string): string {
|
|
15
|
+
return s
|
|
16
|
+
.replace(/&/g, "&")
|
|
17
|
+
.replace(/</g, "<")
|
|
18
|
+
.replace(/>/g, ">");
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
function bold(s: string) {
|
|
14
|
-
return `<b>${s}</b>`;
|
|
22
|
+
return `<b>${escapeHtml(s)}</b>`;
|
|
15
23
|
}
|
|
16
24
|
function italic(s: string) {
|
|
17
|
-
return `<i>${s}</i>`;
|
|
25
|
+
return `<i>${escapeHtml(s)}</i>`;
|
|
18
26
|
}
|
|
19
27
|
function code(s: string) {
|
|
20
|
-
return `<code>${s}</code>`;
|
|
28
|
+
return `<code>${escapeHtml(s)}</code>`;
|
|
21
29
|
}
|
|
22
30
|
function strike(s: string) {
|
|
23
|
-
return `<s>${s}</s>`;
|
|
31
|
+
return `<s>${escapeHtml(s)}</s>`;
|
|
24
32
|
}
|
|
25
33
|
function labelValue(label: string, value: string) {
|
|
26
|
-
return `${bold(label + ":")} ${value}`;
|
|
34
|
+
return `${bold(label + ":")} ${escapeHtml(value)}`;
|
|
27
35
|
}
|
|
28
36
|
function link(url: string, text: string) {
|
|
29
|
-
return `<a href="${url}">${text}</a>`;
|
|
37
|
+
return `<a href="${url}">${escapeHtml(text)}</a>`;
|
|
30
38
|
}
|
|
31
39
|
function progressBar(pct: number, width = 12) {
|
|
32
40
|
const filled = Math.round((pct / 100) * width);
|
package/src/tools/telegram.ts
CHANGED
|
@@ -199,6 +199,11 @@ export const notifySchema = Type.Object({
|
|
|
199
199
|
ackEmoji: Type.Optional(Type.String()),
|
|
200
200
|
});
|
|
201
201
|
|
|
202
|
+
export const sendPhotoSchema = Type.Object({
|
|
203
|
+
photoPath: Type.String({ description: "Local file path or remote URL of the photo to send" }),
|
|
204
|
+
caption: Type.Optional(Type.String({ description: "Optional caption text (HTML formatting supported)" })),
|
|
205
|
+
});
|
|
206
|
+
|
|
202
207
|
export const listenSchema = Type.Object({});
|
|
203
208
|
|
|
204
209
|
export const sendSchema = Type.Object({ message: Type.String() });
|