@bytesbrains/pi-telegram-bridge 1.3.0 → 1.4.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 +34 -121
- package/package.json +1 -1
- package/src/__tests__/commands.test.ts +200 -0
- package/src/__tests__/helpers.test.ts +4 -4
- package/src/commands.ts +285 -0
- package/src/helpers.ts +20 -31
- package/src/index.ts +36 -154
- package/src/templates.ts +6 -3
package/README.md
CHANGED
|
@@ -1,153 +1,45 @@
|
|
|
1
1
|
# pi-telegram-bridge
|
|
2
2
|
|
|
3
|
-
Telegram bot bridge for pi agents — send messages, ask questions,
|
|
3
|
+
Telegram bot bridge for pi agents — send messages, ask questions, and listen for human replies via Telegram.
|
|
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
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Configuration
|
|
12
12
|
|
|
13
|
-
|
|
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
|
|
13
|
+
Set the following environment variables:
|
|
40
14
|
|
|
41
15
|
```bash
|
|
42
16
|
export TELEGRAM_BOT_TOKEN="123456:ABC-DEF1234ghikl-zyx57W2v1u123ew11"
|
|
43
17
|
export TELEGRAM_CHAT_ID="123456789"
|
|
44
18
|
```
|
|
45
19
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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?`) |
|
|
20
|
+
| Variable | Required | Description |
|
|
21
|
+
|----------|----------|-------------|
|
|
22
|
+
| `TELEGRAM_BOT_TOKEN` | ✅ | Bot token from [@BotFather](https://t.me/BotFather) |
|
|
23
|
+
| `TELEGRAM_CHAT_ID` | ✅ | Target chat ID (your personal chat or a group) |
|
|
120
24
|
|
|
121
25
|
## Tools
|
|
122
26
|
|
|
123
27
|
### telegram_listen
|
|
124
28
|
|
|
125
|
-
Check for new inbound messages
|
|
29
|
+
Check for new inbound messages from the human.
|
|
126
30
|
|
|
127
31
|
```
|
|
128
32
|
telegram_listen()
|
|
129
33
|
```
|
|
130
34
|
|
|
131
|
-
Returns photo paths and captions if a photo was received.
|
|
132
|
-
|
|
133
35
|
### telegram_send
|
|
134
36
|
|
|
135
|
-
Send a one-way
|
|
37
|
+
Send a one-way message. Use for status updates and progress reports.
|
|
136
38
|
|
|
137
39
|
```
|
|
138
40
|
telegram_send(message="Build completed successfully ✅")
|
|
139
41
|
```
|
|
140
42
|
|
|
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
|
-
|
|
151
43
|
### telegram_ask
|
|
152
44
|
|
|
153
45
|
Ask a question with inline keyboard options and **wait** for a human reply. Blocks until answered or timeout (default 30 min).
|
|
@@ -195,7 +87,6 @@ telegram_override(
|
|
|
195
87
|
| `timedOut` | `boolean` | Whether the request timed out |
|
|
196
88
|
|
|
197
89
|
**Agent workflow:**
|
|
198
|
-
|
|
199
90
|
1. Agent gets blocked by supervisor
|
|
200
91
|
2. Calls `telegram_override(command, reason, context)`
|
|
201
92
|
3. Checks `details.action`:
|
|
@@ -212,11 +103,33 @@ Check if the bridge is configured and running.
|
|
|
212
103
|
telegram_status()
|
|
213
104
|
```
|
|
214
105
|
|
|
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
|
+
|
|
215
128
|
## Requirements
|
|
216
129
|
|
|
217
130
|
- Node.js >= 18
|
|
218
|
-
- A Telegram bot token
|
|
219
|
-
- A chat ID
|
|
131
|
+
- A Telegram bot token
|
|
132
|
+
- A chat ID to send/receive messages
|
|
220
133
|
|
|
221
134
|
## License
|
|
222
135
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-telegram-bridge — Commands Tests
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
5
|
+
|
|
6
|
+
// ── Mock helpers module ───────────────────────────────────────────
|
|
7
|
+
// Must be before any import of the module under test
|
|
8
|
+
let mockSendMsg: any;
|
|
9
|
+
let mockGetChatId: any;
|
|
10
|
+
|
|
11
|
+
vi.mock("../helpers", () => ({
|
|
12
|
+
sendMsg: (...args: any[]) => mockSendMsg(...args),
|
|
13
|
+
getChatId: () => mockGetChatId(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// Import after mocks
|
|
17
|
+
import { isPossibleCommand, handleCommand } from "../commands";
|
|
18
|
+
|
|
19
|
+
function mockPi() {
|
|
20
|
+
const messages: string[] = [];
|
|
21
|
+
return {
|
|
22
|
+
sendUserMessage: vi.fn((msg: string) => messages.push(msg)),
|
|
23
|
+
getMessages: () => messages,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.restoreAllMocks();
|
|
29
|
+
mockSendMsg = vi.fn().mockResolvedValue(12345);
|
|
30
|
+
mockGetChatId = vi.fn().mockReturnValue("987654321");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ── isPossibleCommand ─────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
describe("isPossibleCommand", () => {
|
|
36
|
+
it("detects slash-prefixed messages", () => {
|
|
37
|
+
expect(isPossibleCommand("/stop")).toBe(true);
|
|
38
|
+
expect(isPossibleCommand("/s")).toBe(true);
|
|
39
|
+
expect(isPossibleCommand("/help")).toBe(true);
|
|
40
|
+
expect(isPossibleCommand("/status")).toBe(true);
|
|
41
|
+
expect(isPossibleCommand("/i")).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("rejects non-slash messages", () => {
|
|
45
|
+
expect(isPossibleCommand("hello")).toBe(false);
|
|
46
|
+
expect(isPossibleCommand("stop")).toBe(false);
|
|
47
|
+
expect(isPossibleCommand("")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("detects slash with leading whitespace", () => {
|
|
51
|
+
expect(isPossibleCommand(" /stop")).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("rejects non-slash with leading whitespace", () => {
|
|
55
|
+
expect(isPossibleCommand(" hello")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ── handleCommand — Bridge Commands ───────────────────────────────
|
|
60
|
+
|
|
61
|
+
describe("handleCommand — bridge commands", () => {
|
|
62
|
+
it("handles /help and /status as bridge commands", async () => {
|
|
63
|
+
for (const cmd of ["/help", "/h", "/status", "/st", "/HELP", "/Status"]) {
|
|
64
|
+
const pi = mockPi();
|
|
65
|
+
const result = await handleCommand(cmd, pi as any, false);
|
|
66
|
+
expect(result.handled).toBe(true);
|
|
67
|
+
if (result.handled) expect(result.type).toBe("bridge");
|
|
68
|
+
expect(pi.sendUserMessage).not.toHaveBeenCalled();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ── handleCommand — Agent Commands ────────────────────────────────
|
|
74
|
+
|
|
75
|
+
describe("handleCommand — agent commands", () => {
|
|
76
|
+
const agentCommands = [
|
|
77
|
+
"/stop",
|
|
78
|
+
"/s",
|
|
79
|
+
"/go",
|
|
80
|
+
"/g",
|
|
81
|
+
"/sum",
|
|
82
|
+
"/redo",
|
|
83
|
+
"/r",
|
|
84
|
+
"/commit",
|
|
85
|
+
"/c",
|
|
86
|
+
"/push",
|
|
87
|
+
"/p",
|
|
88
|
+
"/skip",
|
|
89
|
+
"/files",
|
|
90
|
+
"/f",
|
|
91
|
+
"/issue",
|
|
92
|
+
"/i",
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
for (const cmd of agentCommands) {
|
|
96
|
+
it(`handles ${cmd} and injects to agent`, async () => {
|
|
97
|
+
const pi = mockPi();
|
|
98
|
+
const result = await handleCommand(cmd, pi as any, false);
|
|
99
|
+
expect(result.handled).toBe(true);
|
|
100
|
+
if (result.handled) expect(result.type).toBe("agent");
|
|
101
|
+
expect(pi.sendUserMessage).toHaveBeenCalledTimes(1);
|
|
102
|
+
expect(pi.getMessages()[0]).toContain("[COMMAND:");
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
it("handles commands case-insensitively", async () => {
|
|
107
|
+
const pi = mockPi();
|
|
108
|
+
const result = await handleCommand("/STOP", pi as any, false);
|
|
109
|
+
expect(result.handled).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("handles commands with extra text", async () => {
|
|
113
|
+
const pi = mockPi();
|
|
114
|
+
const result = await handleCommand("/stop now please", pi as any, false);
|
|
115
|
+
expect(result.handled).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("/issue injects full behavior spec", async () => {
|
|
119
|
+
const pi = mockPi();
|
|
120
|
+
await handleCommand("/issue", pi as any, false);
|
|
121
|
+
const injected = pi.getMessages()[0];
|
|
122
|
+
expect(injected).toContain("RESEARCH the project");
|
|
123
|
+
expect(injected).toContain("project_create_issue");
|
|
124
|
+
expect(injected).toContain("Do NOT ask permission to create");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ── handleCommand — Unknown Commands ──────────────────────────────
|
|
129
|
+
|
|
130
|
+
describe("handleCommand — unknown commands", () => {
|
|
131
|
+
it("returns not handled for unknown / slash inputs", async () => {
|
|
132
|
+
const pi = mockPi();
|
|
133
|
+
const result = await handleCommand("/foobar", pi as any, false);
|
|
134
|
+
expect(result.handled).toBe(false);
|
|
135
|
+
expect(pi.sendUserMessage).not.toHaveBeenCalled();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("returns not handled for paths like /usr/bin/node", async () => {
|
|
139
|
+
const pi = mockPi();
|
|
140
|
+
const result = await handleCommand("/usr/bin/node", pi as any, false);
|
|
141
|
+
expect(result.handled).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("returns not handled for empty string", async () => {
|
|
145
|
+
const pi = mockPi();
|
|
146
|
+
const result = await handleCommand("", pi as any, false);
|
|
147
|
+
expect(result.handled).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ── handleCommand — Ack Behavior ──────────────────────────────────
|
|
152
|
+
|
|
153
|
+
describe("handleCommand — ack behavior", () => {
|
|
154
|
+
it("sends ack when sendAck is true", async () => {
|
|
155
|
+
const pi = mockPi();
|
|
156
|
+
await handleCommand("/stop", pi as any, true);
|
|
157
|
+
expect(mockSendMsg).toHaveBeenCalledWith(
|
|
158
|
+
expect.stringContaining("Stopping"),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("does not send ack when sendAck is false", async () => {
|
|
163
|
+
const pi = mockPi();
|
|
164
|
+
mockSendMsg.mockClear();
|
|
165
|
+
await handleCommand("/stop", pi as any, false);
|
|
166
|
+
expect(mockSendMsg).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ── Alias Disambiguation ──────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
describe("alias disambiguation", () => {
|
|
173
|
+
it("/st maps to /status, not /stop", async () => {
|
|
174
|
+
const pi = mockPi();
|
|
175
|
+
const result = await handleCommand("/st", pi as any, false);
|
|
176
|
+
expect(result.handled).toBe(true);
|
|
177
|
+
if (result.handled) expect(result.type).toBe("bridge");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("single-letter aliases map correctly", async () => {
|
|
181
|
+
const tests: Array<[string, string]> = [
|
|
182
|
+
["/s", "/stop"],
|
|
183
|
+
["/g", "/go"],
|
|
184
|
+
["/r", "/redo"],
|
|
185
|
+
["/c", "/commit"],
|
|
186
|
+
["/p", "/push"],
|
|
187
|
+
["/f", "/files"],
|
|
188
|
+
["/i", "/issue"],
|
|
189
|
+
["/h", "/help"],
|
|
190
|
+
];
|
|
191
|
+
for (const [_alias, _canonical] of tests) {
|
|
192
|
+
const pi = mockPi();
|
|
193
|
+
const result = await handleCommand(_alias, pi as any, false);
|
|
194
|
+
expect(result.handled).toBe(true);
|
|
195
|
+
if (result.handled) {
|
|
196
|
+
expect(["bridge", "agent"]).toContain(result.type);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -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!");
|
|
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");
|
|
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");
|
|
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");
|
|
738
738
|
}, 20000);
|
|
739
739
|
|
|
740
740
|
it("ignores messages without text field", async () => {
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-telegram-bridge — Slash Command Handler
|
|
3
|
+
*
|
|
4
|
+
* Detects slash-prefixed messages and either:
|
|
5
|
+
* - Handles directly (bridge-only commands: /help, /status)
|
|
6
|
+
* - Injects structured instruction to agent (control commands)
|
|
7
|
+
* - Falls through for unknown commands (could be paths)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { sendMsg, getChatId } from "./helpers";
|
|
12
|
+
import * as cp from "node:child_process";
|
|
13
|
+
import * as util from "node:util";
|
|
14
|
+
const execAsync = util.promisify(cp.exec);
|
|
15
|
+
|
|
16
|
+
// ── Command Definitions ─────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
interface CommandDef {
|
|
19
|
+
name: string;
|
|
20
|
+
aliases: string[];
|
|
21
|
+
type: "bridge" | "agent";
|
|
22
|
+
description: string;
|
|
23
|
+
/** For agent-type commands: structured instruction injected to the agent */
|
|
24
|
+
inject?: string;
|
|
25
|
+
/** Acknowledgment sent back to Telegram */
|
|
26
|
+
ack: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const COMMANDS: CommandDef[] = [
|
|
30
|
+
{
|
|
31
|
+
name: "/stop",
|
|
32
|
+
aliases: ["/s"],
|
|
33
|
+
type: "agent",
|
|
34
|
+
description: "Pause current action at next safe point",
|
|
35
|
+
ack: "⏹ Stopping at next safe point...",
|
|
36
|
+
inject:
|
|
37
|
+
"[COMMAND: /stop]\n" +
|
|
38
|
+
"⚠️ Human requested you pause your current action. Stop at the next safe point and wait for further instructions.",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "/go",
|
|
42
|
+
aliases: ["/g"],
|
|
43
|
+
type: "agent",
|
|
44
|
+
description: "Proceed with current plan",
|
|
45
|
+
ack: "▶️ Proceeding...",
|
|
46
|
+
inject:
|
|
47
|
+
"[COMMAND: /go]\n" +
|
|
48
|
+
"✅ Human confirmed — proceed with your current plan.",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "/sum",
|
|
52
|
+
aliases: [],
|
|
53
|
+
type: "agent",
|
|
54
|
+
description: "Give a 3-bullet summary of current work",
|
|
55
|
+
ack: "📋 Summarizing...",
|
|
56
|
+
inject:
|
|
57
|
+
"[COMMAND: /sum]\n" +
|
|
58
|
+
"📋 SUMMARIZE: Give a quick 3-bullet summary of what you're doing right now — current task, progress, next step.",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "/redo",
|
|
62
|
+
aliases: ["/r"],
|
|
63
|
+
type: "agent",
|
|
64
|
+
description: "Retry last failed action",
|
|
65
|
+
ack: "🔄 Retrying last failed action...",
|
|
66
|
+
inject: "[COMMAND: /redo]\n" + "🔄 REDO: Retry your last failed action.",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "/commit",
|
|
70
|
+
aliases: ["/c"],
|
|
71
|
+
type: "agent",
|
|
72
|
+
description: "Commit current changes (checkpoint)",
|
|
73
|
+
ack: "📦 Committing changes...",
|
|
74
|
+
inject:
|
|
75
|
+
"[COMMAND: /commit]\n" +
|
|
76
|
+
"📦 COMMIT: This is a good checkpoint — commit your current staged/working changes now via contrib_propose.",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: "/push",
|
|
80
|
+
aliases: ["/p"],
|
|
81
|
+
type: "agent",
|
|
82
|
+
description: "Push commits",
|
|
83
|
+
ack: "🚀 Pushing...",
|
|
84
|
+
inject: "[COMMAND: /push]\n" + "🚀 PUSH: Push your commits now.",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "/skip",
|
|
88
|
+
aliases: [],
|
|
89
|
+
type: "agent",
|
|
90
|
+
description: "Skip current step, move to next",
|
|
91
|
+
ack: "⏭ Skipping current step...",
|
|
92
|
+
inject:
|
|
93
|
+
"[COMMAND: /skip]\n" +
|
|
94
|
+
"⏭ SKIP: Skip the current step and move on to what's next.",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "/files",
|
|
98
|
+
aliases: ["/f"],
|
|
99
|
+
type: "agent",
|
|
100
|
+
description: "List modified files",
|
|
101
|
+
ack: "📁 Listing modified files...",
|
|
102
|
+
inject:
|
|
103
|
+
"[COMMAND: /files]\n" +
|
|
104
|
+
"📁 FILES: List what files you've modified so far in this session.",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "/issue",
|
|
108
|
+
aliases: ["/i"],
|
|
109
|
+
type: "agent",
|
|
110
|
+
description: "Create an issue from session context",
|
|
111
|
+
ack: "🎫 Creating issue from session context...",
|
|
112
|
+
inject:
|
|
113
|
+
"[COMMAND: /issue]\n" +
|
|
114
|
+
"🎫 Create a well-structured issue from the current session context. Follow this workflow:\n" +
|
|
115
|
+
"\n" +
|
|
116
|
+
"1. READ the session context — review conversation history, current branch, modified files.\n" +
|
|
117
|
+
"2. RESEARCH the project — read README, AGENTS.md, check for duplicate issues via project_list_issues.\n" +
|
|
118
|
+
"3. DECIDE if clarification is genuinely needed:\n" +
|
|
119
|
+
" - YES only if: scope is ambiguous, multiple valid approaches exist, or critical info is missing.\n" +
|
|
120
|
+
" - NO if: problem is clear from context, you can infer a good title and body.\n" +
|
|
121
|
+
"4. If NO clarification needed: draft the issue with a clear conventional title and structured body, create it via project_create_issue, share the ID and URL.\n" +
|
|
122
|
+
"5. If YES clarification needed: ask 1-2 focused questions via telegram_ask, then create the issue.\n" +
|
|
123
|
+
"6. NOTIFY the human via telegram_notify(kind='issue') with the created issue details.\n" +
|
|
124
|
+
"\n" +
|
|
125
|
+
"CRITICAL: Do NOT ask permission to create — /issue IS permission. Limit clarification to 1 round max. If no context found, say so and ask for a title.",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: "/help",
|
|
129
|
+
aliases: ["/h"],
|
|
130
|
+
type: "bridge",
|
|
131
|
+
description: "Show available commands",
|
|
132
|
+
ack: "📖 Sending command list...",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "/status",
|
|
136
|
+
aliases: ["/st"],
|
|
137
|
+
type: "bridge",
|
|
138
|
+
description: "Show session status",
|
|
139
|
+
ack: "📊 Checking session status...",
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
// ── Lookup ──────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/** Case-insensitive, aliases included. Returns the canonical CommandDef. */
|
|
146
|
+
function findCommand(input: string): CommandDef | null {
|
|
147
|
+
const normalized = input.trim().toLowerCase();
|
|
148
|
+
// Exact match on name or any alias
|
|
149
|
+
for (const cmd of COMMANDS) {
|
|
150
|
+
if (normalized === cmd.name || cmd.aliases.includes(normalized)) {
|
|
151
|
+
return cmd;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Also match prefix if it starts with the command name followed by space or end
|
|
155
|
+
// e.g. "/stop now please" should match /stop
|
|
156
|
+
const firstWord = normalized.split(/\s+/)[0];
|
|
157
|
+
for (const cmd of COMMANDS) {
|
|
158
|
+
if (firstWord === cmd.name || cmd.aliases.includes(firstWord)) {
|
|
159
|
+
return cmd;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Bridge-Handled Commands ────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
async function handleHelp(): Promise<string> {
|
|
168
|
+
const lines: string[] = ["<b>⌨️ Available Commands</b>\n"];
|
|
169
|
+
lines.push("<b>━━━━━━━━━━━━━━━━━━━━</b>\n");
|
|
170
|
+
|
|
171
|
+
lines.push("<b>🎛 Control</b>");
|
|
172
|
+
for (const cmd of COMMANDS.filter((c) => c.type === "agent")) {
|
|
173
|
+
const aliases = cmd.aliases.length > 0 ? cmd.aliases.join(" ") + " " : "";
|
|
174
|
+
lines.push(` <code>${aliases}${cmd.name}</code> — ${cmd.description}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
lines.push("\n<b>ℹ️ Info</b>");
|
|
178
|
+
for (const cmd of COMMANDS.filter((c) => c.type === "bridge")) {
|
|
179
|
+
const aliases = cmd.aliases.length > 0 ? cmd.aliases.join(" ") + " " : "";
|
|
180
|
+
lines.push(` <code>${aliases}${cmd.name}</code> — ${cmd.description}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return lines.join("\n");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function handleStatus(): Promise<string> {
|
|
187
|
+
let branch = "unknown";
|
|
188
|
+
try {
|
|
189
|
+
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
|
|
190
|
+
timeout: 3000,
|
|
191
|
+
});
|
|
192
|
+
branch = stdout.trim();
|
|
193
|
+
} catch {
|
|
194
|
+
/* ignore */
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let nodeVersion = "unknown";
|
|
198
|
+
try {
|
|
199
|
+
nodeVersion = process.version;
|
|
200
|
+
} catch {
|
|
201
|
+
/* ignore */
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const lines: string[] = [];
|
|
205
|
+
lines.push("<b>📊 Session Status</b>");
|
|
206
|
+
lines.push("<b>━━━━━━━━━━━━━━━━━━━━</b>");
|
|
207
|
+
lines.push(`<b>Branch:</b> <code>${escapeHtmlCmd(branch)}</code>`);
|
|
208
|
+
lines.push(`<b>Node:</b> <code>${escapeHtmlCmd(nodeVersion)}</code>`);
|
|
209
|
+
lines.push(`<b>Chat ID:</b> <code>${escapeHtmlCmd(getChatId())}</code>`);
|
|
210
|
+
|
|
211
|
+
// Uptime from process
|
|
212
|
+
const uptime = Math.floor(process.uptime());
|
|
213
|
+
const mins = Math.floor(uptime / 60);
|
|
214
|
+
const hrs = Math.floor(mins / 60);
|
|
215
|
+
const uptimeStr =
|
|
216
|
+
hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m ${uptime % 60}s`;
|
|
217
|
+
lines.push(`<b>Process uptime:</b> <code>${uptimeStr}</code>`);
|
|
218
|
+
|
|
219
|
+
return lines.join("\n");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function escapeHtmlCmd(s: string): string {
|
|
223
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Main Handler ────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
export type CommandResult =
|
|
229
|
+
| { handled: true; type: "bridge" }
|
|
230
|
+
| { handled: true; type: "agent" }
|
|
231
|
+
| { handled: false };
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Process a slash command from a Telegram message.
|
|
235
|
+
*
|
|
236
|
+
* @param text - The raw message text (e.g., "/stop" or "/status")
|
|
237
|
+
* @param pi - ExtensionAPI for injecting agent messages
|
|
238
|
+
* @param sendAck - Whether to send an acknowledgment to Telegram
|
|
239
|
+
* @returns Result indicating how the command was handled
|
|
240
|
+
*/
|
|
241
|
+
export async function handleCommand(
|
|
242
|
+
text: string,
|
|
243
|
+
pi: ExtensionAPI,
|
|
244
|
+
sendAck = true,
|
|
245
|
+
): Promise<CommandResult> {
|
|
246
|
+
const cmd = findCommand(text);
|
|
247
|
+
if (!cmd) return { handled: false };
|
|
248
|
+
|
|
249
|
+
// Send acknowledgment
|
|
250
|
+
if (sendAck) {
|
|
251
|
+
await sendMsg(cmd.ack).catch(() => {});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (cmd.type === "bridge") {
|
|
255
|
+
if (cmd.name === "/help") {
|
|
256
|
+
const helpText = await handleHelp();
|
|
257
|
+
await sendMsg(helpText, undefined, "HTML").catch(() => {});
|
|
258
|
+
} else if (cmd.name === "/status") {
|
|
259
|
+
const statusText = await handleStatus();
|
|
260
|
+
await sendMsg(statusText, undefined, "HTML").catch(() => {});
|
|
261
|
+
}
|
|
262
|
+
return { handled: true, type: "bridge" };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Agent-directed: inject structured instruction
|
|
266
|
+
if (cmd.inject) {
|
|
267
|
+
pi.sendUserMessage(cmd.inject);
|
|
268
|
+
}
|
|
269
|
+
return { handled: true, type: "agent" };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Check if a message starts with a slash (potential command).
|
|
274
|
+
* Used as a quick pre-filter before calling handleCommand.
|
|
275
|
+
*/
|
|
276
|
+
export function isPossibleCommand(text: string): boolean {
|
|
277
|
+
return text.trimStart().startsWith("/");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Get all command definitions (for /help formatting).
|
|
282
|
+
*/
|
|
283
|
+
export function getCommands(): CommandDef[] {
|
|
284
|
+
return COMMANDS;
|
|
285
|
+
}
|
package/src/helpers.ts
CHANGED
|
@@ -57,11 +57,7 @@ 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 (
|
|
61
|
-
parseMode &&
|
|
62
|
-
msg.includes("400") &&
|
|
63
|
-
(msg.includes("parse") || msg.includes("entity"))
|
|
64
|
-
) {
|
|
60
|
+
if (parseMode && msg.includes("400") && (msg.includes("parse") || msg.includes("entity"))) {
|
|
65
61
|
delete body.parse_mode;
|
|
66
62
|
const r2 = (await telegramApi("sendMessage", body)) as {
|
|
67
63
|
ok: boolean;
|
|
@@ -70,8 +66,7 @@ export async function sendMsg(
|
|
|
70
66
|
return r2.result.message_id;
|
|
71
67
|
}
|
|
72
68
|
throw e;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
69
|
+
}}
|
|
75
70
|
|
|
76
71
|
// ── Photo / File Support ────────────────────────────────────────────
|
|
77
72
|
|
|
@@ -82,11 +77,14 @@ export async function sendMsg(
|
|
|
82
77
|
export async function getFileUrl(fileId: string): Promise<string | null> {
|
|
83
78
|
const token = getToken();
|
|
84
79
|
try {
|
|
85
|
-
const res = await fetch(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
80
|
+
const res = await fetch(
|
|
81
|
+
`${BASE_URL}${token}/getFile`,
|
|
82
|
+
{
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "Content-Type": "application/json" },
|
|
85
|
+
body: JSON.stringify({ file_id: fileId }),
|
|
86
|
+
},
|
|
87
|
+
);
|
|
90
88
|
if (!res.ok) return null;
|
|
91
89
|
const data = (await res.json()) as {
|
|
92
90
|
ok: boolean;
|
|
@@ -104,10 +102,7 @@ export async function getFileUrl(fileId: string): Promise<string | null> {
|
|
|
104
102
|
* Returns the file path on success, null on failure.
|
|
105
103
|
* Creates parent directories automatically.
|
|
106
104
|
*/
|
|
107
|
-
export async function downloadFile(
|
|
108
|
-
fileId: string,
|
|
109
|
-
destPath: string,
|
|
110
|
-
): Promise<string | null> {
|
|
105
|
+
export async function downloadFile(fileId: string, destPath: string): Promise<string | null> {
|
|
111
106
|
try {
|
|
112
107
|
const fileUrl = await getFileUrl(fileId);
|
|
113
108
|
if (!fileUrl) return null;
|
|
@@ -151,7 +146,7 @@ export async function sendPhoto(
|
|
|
151
146
|
const token = getToken();
|
|
152
147
|
const formData = new FormData();
|
|
153
148
|
formData.set("chat_id", getChatId());
|
|
154
|
-
const blob = new Blob([fileData]);
|
|
149
|
+
const blob = new Blob([fileData] as any);
|
|
155
150
|
formData.set("photo", blob, filename);
|
|
156
151
|
if (caption) {
|
|
157
152
|
formData.set("caption", caption);
|
|
@@ -167,11 +162,7 @@ export async function sendPhoto(
|
|
|
167
162
|
if (!res.ok) {
|
|
168
163
|
const errText = await res.text();
|
|
169
164
|
// 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
|
-
) {
|
|
165
|
+
if (caption && res.status === 400 && (errText.includes("parse") || errText.includes("entity"))) {
|
|
175
166
|
return sendPhoto(photoPath);
|
|
176
167
|
}
|
|
177
168
|
throw new Error(`sendPhoto failed: ${res.status} ${errText}`);
|
|
@@ -317,7 +308,7 @@ export async function pollUpdates(
|
|
|
317
308
|
botId: number | null,
|
|
318
309
|
lastUpdateId: number,
|
|
319
310
|
signal: AbortSignal,
|
|
320
|
-
onMessage: (text: string
|
|
311
|
+
onMessage: (text: string) => void,
|
|
321
312
|
onPhoto: (photoId: string, caption?: string) => void,
|
|
322
313
|
onUpdateId: (id: number) => void,
|
|
323
314
|
): Promise<void> {
|
|
@@ -342,12 +333,7 @@ export async function pollUpdates(
|
|
|
342
333
|
chat: { id: number };
|
|
343
334
|
from?: { id: number; is_bot?: boolean };
|
|
344
335
|
text?: string;
|
|
345
|
-
photo?: Array<{
|
|
346
|
-
file_id: string;
|
|
347
|
-
file_unique_id: string;
|
|
348
|
-
width: number;
|
|
349
|
-
height: number;
|
|
350
|
-
}>;
|
|
336
|
+
photo?: Array<{ file_id: string; file_unique_id: string; width: number; height: number }>;
|
|
351
337
|
caption?: string;
|
|
352
338
|
};
|
|
353
339
|
callback_query?: unknown;
|
|
@@ -363,7 +349,10 @@ export async function pollUpdates(
|
|
|
363
349
|
// Skip callback queries (handled by telegram_ask)
|
|
364
350
|
if (u.callback_query) continue;
|
|
365
351
|
// Process messages from the configured chat, not from our bot
|
|
366
|
-
if (
|
|
352
|
+
if (
|
|
353
|
+
u.message &&
|
|
354
|
+
String(u.message.chat.id) === chatId
|
|
355
|
+
) {
|
|
367
356
|
if (u.message.from?.is_bot || u.message.from?.id === botId) {
|
|
368
357
|
continue;
|
|
369
358
|
}
|
|
@@ -375,7 +364,7 @@ export async function pollUpdates(
|
|
|
375
364
|
}
|
|
376
365
|
// Text message
|
|
377
366
|
if (u.message.text) {
|
|
378
|
-
onMessage(u.message.text
|
|
367
|
+
onMessage(u.message.text);
|
|
379
368
|
}
|
|
380
369
|
}
|
|
381
370
|
}
|
package/src/index.ts
CHANGED
|
@@ -32,149 +32,12 @@ import {
|
|
|
32
32
|
notifySchema,
|
|
33
33
|
sendPhotoSchema,
|
|
34
34
|
} from "./tools/telegram";
|
|
35
|
+
import { handleCommand, isPossibleCommand } from "./commands";
|
|
35
36
|
|
|
36
37
|
export default function telegramBridge(pi: ExtensionAPI) {
|
|
37
38
|
let botId: number | null = null;
|
|
38
39
|
let listenerAbort: AbortController | null = null;
|
|
39
40
|
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
|
-
}
|
|
178
41
|
|
|
179
42
|
// ── Session lifecycle ───────────────────────────────────────────
|
|
180
43
|
|
|
@@ -190,7 +53,6 @@ export default function telegramBridge(pi: ExtensionAPI) {
|
|
|
190
53
|
}
|
|
191
54
|
}
|
|
192
55
|
// Start background listener
|
|
193
|
-
mentionTrigger = await resolveMentionTrigger();
|
|
194
56
|
startListener();
|
|
195
57
|
});
|
|
196
58
|
|
|
@@ -228,21 +90,16 @@ export default function telegramBridge(pi: ExtensionAPI) {
|
|
|
228
90
|
botId,
|
|
229
91
|
lastUpdateId,
|
|
230
92
|
signal,
|
|
231
|
-
// onMessage:
|
|
232
|
-
async (text: string
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const trimmed = cleanedText.trim().toLowerCase();
|
|
238
|
-
if (trimmed === "hi" || trimmed === "/status" || trimmed === "status") {
|
|
239
|
-
await sendDashboard();
|
|
240
|
-
return;
|
|
93
|
+
// onMessage: check for slash commands first, otherwise forward to pi
|
|
94
|
+
async (text: string) => {
|
|
95
|
+
if (isPossibleCommand(text)) {
|
|
96
|
+
const result = await handleCommand(text, pi, true);
|
|
97
|
+
if (result.handled) return; // bridge or agent handled it
|
|
98
|
+
// Unknown command — fall through to forward as regular message
|
|
241
99
|
}
|
|
242
|
-
|
|
243
|
-
pi.sendUserMessage(cleanedText);
|
|
100
|
+
pi.sendUserMessage(text);
|
|
244
101
|
await sendMsg(
|
|
245
|
-
`👂 Got it! Working on: _${
|
|
102
|
+
`👂 Got it! Working on: _${text.slice(0, 100)}_`,
|
|
246
103
|
undefined,
|
|
247
104
|
"Markdown",
|
|
248
105
|
);
|
|
@@ -381,14 +238,39 @@ export default function telegramBridge(pi: ExtensionAPI) {
|
|
|
381
238
|
}
|
|
382
239
|
// Text message
|
|
383
240
|
if (u.message.text) {
|
|
241
|
+
const text = u.message.text;
|
|
242
|
+
// Check for slash commands
|
|
243
|
+
if (isPossibleCommand(text)) {
|
|
244
|
+
const result = await handleCommand(text, pi, false);
|
|
245
|
+
if (result.handled) {
|
|
246
|
+
const label =
|
|
247
|
+
result.type === "bridge"
|
|
248
|
+
? "(handled by bridge)"
|
|
249
|
+
: "(injected to agent)";
|
|
250
|
+
return {
|
|
251
|
+
content: [
|
|
252
|
+
{
|
|
253
|
+
type: "text",
|
|
254
|
+
text: `🔧 Command processed: "${text}" ${label}`,
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
details: {
|
|
258
|
+
message: text,
|
|
259
|
+
command: true,
|
|
260
|
+
commandType: result.type,
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
// Unknown command — fall through to return as regular message
|
|
265
|
+
}
|
|
384
266
|
return {
|
|
385
267
|
content: [
|
|
386
268
|
{
|
|
387
269
|
type: "text",
|
|
388
|
-
text: `📩 New message: "${
|
|
270
|
+
text: `📩 New message: "${text}"`,
|
|
389
271
|
},
|
|
390
272
|
],
|
|
391
|
-
details: { message:
|
|
273
|
+
details: { message: text },
|
|
392
274
|
};
|
|
393
275
|
}
|
|
394
276
|
}
|
package/src/templates.ts
CHANGED
|
@@ -12,7 +12,10 @@ const BAR = "━".repeat(20);
|
|
|
12
12
|
|
|
13
13
|
/** Escape user text for safe embedding inside HTML tags. */
|
|
14
14
|
function escapeHtml(s: string): string {
|
|
15
|
-
return s
|
|
15
|
+
return s
|
|
16
|
+
.replace(/&/g, "&")
|
|
17
|
+
.replace(/</g, "<")
|
|
18
|
+
.replace(/>/g, ">");
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
function bold(s: string) {
|
|
@@ -28,7 +31,7 @@ function strike(s: string) {
|
|
|
28
31
|
return `<s>${escapeHtml(s)}</s>`;
|
|
29
32
|
}
|
|
30
33
|
function labelValue(label: string, value: string) {
|
|
31
|
-
return `${bold(label + ":")} ${value}`;
|
|
34
|
+
return `${bold(label + ":")} ${escapeHtml(value)}`;
|
|
32
35
|
}
|
|
33
36
|
function link(url: string, text: string) {
|
|
34
37
|
return `<a href="${url}">${escapeHtml(text)}</a>`;
|
|
@@ -254,7 +257,7 @@ export function ciPipeline(p: CIPipeline): string {
|
|
|
254
257
|
msg += `${BAR}\n`;
|
|
255
258
|
msg += labelValue("Run", `#${p.runNumber}`) + "\n";
|
|
256
259
|
msg += labelValue("Branch", code(p.branch)) + "\n";
|
|
257
|
-
msg += labelValue("Trigger",
|
|
260
|
+
msg += labelValue("Trigger", p.event) + "\n";
|
|
258
261
|
if (p.triggeredBy) msg += labelValue("By", code(p.triggeredBy)) + "\n";
|
|
259
262
|
msg += `${BAR}\n`;
|
|
260
263
|
|