pi-telebridge 1.0.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 +55 -0
- package/package.json +37 -0
- package/src/bot.ts +141 -0
- package/src/config.ts +55 -0
- package/src/formatter.ts +155 -0
- package/src/index.ts +246 -0
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# pi-telebridge
|
|
2
|
+
|
|
3
|
+
A [pi](https://github.com/badlogic/pi-mono) extension that creates a two-way relay between your active pi coding agent session and a Telegram bot. Enable it per-session with `/telegram`, then interact with your session from your phone.
|
|
4
|
+
|
|
5
|
+
- **Agent → Phone**: Every final assistant response is forwarded to your Telegram chat
|
|
6
|
+
- **Phone → Agent**: Your Telegram replies are injected as user messages into the session
|
|
7
|
+
|
|
8
|
+
Both the pi TUI and Telegram inputs coexist — you can use either at any time.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pi install npm:pi-telebridge
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Setup
|
|
17
|
+
|
|
18
|
+
1. Create a bot with [@BotFather](https://t.me/BotFather) on Telegram and copy the token
|
|
19
|
+
2. In pi, run `/telegram setup`
|
|
20
|
+
3. Enter your bot token when prompted
|
|
21
|
+
4. Send any message to your bot on Telegram — this links your chat ID
|
|
22
|
+
5. Done! Config is saved to `~/.pi/agent/telebridge.json`
|
|
23
|
+
|
|
24
|
+
### Environment Variables (optional)
|
|
25
|
+
|
|
26
|
+
You can skip the interactive setup by setting these beforehand:
|
|
27
|
+
|
|
28
|
+
| Variable | Purpose |
|
|
29
|
+
|----------|---------|
|
|
30
|
+
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather |
|
|
31
|
+
| `TELEGRAM_CHAT_ID` | Your Telegram chat ID |
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
| Command | Description |
|
|
36
|
+
|---------|-------------|
|
|
37
|
+
| `/telegram` | Toggle relay on/off for this session |
|
|
38
|
+
| `/telegram setup` | Guided setup: enter bot token, discover chat ID |
|
|
39
|
+
| `/telegram status` | Show connection state, chat ID, relay status |
|
|
40
|
+
|
|
41
|
+
When the relay is enabled:
|
|
42
|
+
- A **📡 TG** indicator appears in the footer
|
|
43
|
+
- Every assistant response is forwarded to your Telegram chat
|
|
44
|
+
- Messages you send to the bot are injected into the pi session
|
|
45
|
+
- If the agent is idle, your message starts a new turn; if busy, it's queued as a follow-up
|
|
46
|
+
|
|
47
|
+
## Security
|
|
48
|
+
|
|
49
|
+
- Only messages from your configured `chat_id` are accepted
|
|
50
|
+
- All other Telegram messages are silently ignored
|
|
51
|
+
- Bot token and chat ID are stored locally in `~/.pi/agent/telebridge.json`
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-telebridge",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A pi extension that creates a two-way relay between your active pi coding agent session and a Telegram bot.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"pi",
|
|
10
|
+
"telegram",
|
|
11
|
+
"telegram-bot"
|
|
12
|
+
],
|
|
13
|
+
"author": "acarerdinc",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/acarerdinc/pi-telebridge.git"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/acarerdinc/pi-telebridge#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/acarerdinc/pi-telebridge/issues"
|
|
22
|
+
},
|
|
23
|
+
"pi": {
|
|
24
|
+
"extensions": ["./src/index.ts"]
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"clean": "echo 'nothing to clean'",
|
|
28
|
+
"build": "echo 'nothing to build'",
|
|
29
|
+
"check": "echo 'nothing to check'"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"grammy": "^1.35.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@mariozechner/pi-coding-agent": "*"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/bot.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Bot } from "grammy";
|
|
2
|
+
|
|
3
|
+
let botInstance: Bot | null = null;
|
|
4
|
+
let currentToken: string | null = null;
|
|
5
|
+
|
|
6
|
+
export type IncomingMessageHandler = (chatId: number, text: string) => void;
|
|
7
|
+
|
|
8
|
+
let onIncomingMessage: IncomingMessageHandler | null = null;
|
|
9
|
+
let allowedChatId: number | null = null;
|
|
10
|
+
let chatIdDiscoveryResolve: ((chatId: number) => void) | null = null;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get or create the grammy Bot singleton.
|
|
14
|
+
* If token changes, the old bot is stopped and a new one created.
|
|
15
|
+
*/
|
|
16
|
+
export async function startBot(token: string): Promise<Bot> {
|
|
17
|
+
if (botInstance && currentToken === token) {
|
|
18
|
+
return botInstance;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Stop old bot if token changed
|
|
22
|
+
if (botInstance) {
|
|
23
|
+
await stopBot();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const bot = new Bot(token);
|
|
27
|
+
|
|
28
|
+
bot.on("message:text", (ctx) => {
|
|
29
|
+
const chatId = ctx.chat.id;
|
|
30
|
+
const text = ctx.message.text;
|
|
31
|
+
|
|
32
|
+
// Chat ID discovery mode
|
|
33
|
+
if (chatIdDiscoveryResolve) {
|
|
34
|
+
chatIdDiscoveryResolve(chatId);
|
|
35
|
+
chatIdDiscoveryResolve = null;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Security: only accept messages from allowed chat
|
|
40
|
+
if (allowedChatId !== null && chatId !== allowedChatId) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Forward to handler
|
|
45
|
+
if (onIncomingMessage) {
|
|
46
|
+
onIncomingMessage(chatId, text);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Catch errors so they don't crash the process
|
|
51
|
+
bot.catch((err) => {
|
|
52
|
+
console.error("[telebridge] Bot error:", err.message);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Start long polling (non-blocking)
|
|
56
|
+
bot.start({
|
|
57
|
+
onStart: () => {
|
|
58
|
+
// Bot is polling
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
botInstance = bot;
|
|
63
|
+
currentToken = token;
|
|
64
|
+
|
|
65
|
+
return bot;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function stopBot(): Promise<void> {
|
|
69
|
+
if (botInstance) {
|
|
70
|
+
try {
|
|
71
|
+
await botInstance.stop();
|
|
72
|
+
} catch {
|
|
73
|
+
// Ignore errors during shutdown
|
|
74
|
+
}
|
|
75
|
+
botInstance = null;
|
|
76
|
+
currentToken = null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getBot(): Bot | null {
|
|
81
|
+
return botInstance;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function setAllowedChatId(chatId: number | null): void {
|
|
85
|
+
allowedChatId = chatId;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function setIncomingMessageHandler(handler: IncomingMessageHandler | null): void {
|
|
89
|
+
onIncomingMessage = handler;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Wait for the first message to arrive from any chat.
|
|
94
|
+
* Used during setup to discover the user's chat ID.
|
|
95
|
+
*/
|
|
96
|
+
export function waitForChatId(): Promise<number> {
|
|
97
|
+
return new Promise<number>((resolve) => {
|
|
98
|
+
chatIdDiscoveryResolve = resolve;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Send a text message. Falls back silently on error.
|
|
104
|
+
*/
|
|
105
|
+
export async function sendText(chatId: number, text: string, parseMode?: "HTML"): Promise<void> {
|
|
106
|
+
if (!botInstance) return;
|
|
107
|
+
try {
|
|
108
|
+
await botInstance.api.sendMessage(chatId, text, {
|
|
109
|
+
parse_mode: parseMode,
|
|
110
|
+
});
|
|
111
|
+
} catch (err: any) {
|
|
112
|
+
console.error("[telebridge] Send error:", err.message);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Send a photo. Falls back silently on error.
|
|
118
|
+
*/
|
|
119
|
+
export async function sendPhoto(chatId: number, url: string, caption?: string): Promise<void> {
|
|
120
|
+
if (!botInstance) return;
|
|
121
|
+
try {
|
|
122
|
+
await botInstance.api.sendPhoto(chatId, url, {
|
|
123
|
+
caption,
|
|
124
|
+
parse_mode: "HTML",
|
|
125
|
+
});
|
|
126
|
+
} catch (err: any) {
|
|
127
|
+
console.error("[telebridge] Photo send error:", err.message);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Send a typing indicator.
|
|
133
|
+
*/
|
|
134
|
+
export async function sendTyping(chatId: number): Promise<void> {
|
|
135
|
+
if (!botInstance) return;
|
|
136
|
+
try {
|
|
137
|
+
await botInstance.api.sendChatAction(chatId, "typing");
|
|
138
|
+
} catch {
|
|
139
|
+
// Ignore
|
|
140
|
+
}
|
|
141
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
|
|
5
|
+
export interface TelebridgeConfig {
|
|
6
|
+
botToken: string;
|
|
7
|
+
chatId: number | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const CONFIG_DIR = path.join(os.homedir(), ".pi", "agent");
|
|
11
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "telebridge.json");
|
|
12
|
+
|
|
13
|
+
export function loadConfig(): TelebridgeConfig | null {
|
|
14
|
+
try {
|
|
15
|
+
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
16
|
+
const data = JSON.parse(raw);
|
|
17
|
+
if (typeof data.botToken === "string") {
|
|
18
|
+
return {
|
|
19
|
+
botToken: data.botToken,
|
|
20
|
+
chatId: typeof data.chatId === "number" ? data.chatId : null,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function saveConfig(config: TelebridgeConfig): void {
|
|
30
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
31
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveToken(): string | null {
|
|
35
|
+
// 1. Environment variable takes priority
|
|
36
|
+
const envToken = process.env.TELEGRAM_BOT_TOKEN;
|
|
37
|
+
if (envToken) return envToken;
|
|
38
|
+
|
|
39
|
+
// 2. Fall back to config file
|
|
40
|
+
const config = loadConfig();
|
|
41
|
+
return config?.botToken ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveChatId(): number | null {
|
|
45
|
+
// 1. Environment variable takes priority
|
|
46
|
+
const envChatId = process.env.TELEGRAM_CHAT_ID;
|
|
47
|
+
if (envChatId) {
|
|
48
|
+
const parsed = parseInt(envChatId, 10);
|
|
49
|
+
if (!isNaN(parsed)) return parsed;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. Fall back to config file
|
|
53
|
+
const config = loadConfig();
|
|
54
|
+
return config?.chatId ?? null;
|
|
55
|
+
}
|
package/src/formatter.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts markdown text to Telegram-compatible HTML.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Fenced code blocks → <pre><code>...</code></pre>
|
|
6
|
+
* - Inline code → <code>...</code>
|
|
7
|
+
* - Bold **text** → <b>text</b>
|
|
8
|
+
* - Italic *text* → <i>text</i>
|
|
9
|
+
* - Strikethrough ~~text~~ → <s>text</s>
|
|
10
|
+
* - Links [text](url) → <a href="url">text</a>
|
|
11
|
+
* - HTML entity escaping inside code
|
|
12
|
+
*
|
|
13
|
+
* Telegram limit: 4096 characters per message.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const TELEGRAM_MAX_LENGTH = 4096;
|
|
17
|
+
const TRUNCATION_NOTICE = "\n\n<i>[truncated — see pi terminal]</i>";
|
|
18
|
+
|
|
19
|
+
function escapeHtml(text: string): string {
|
|
20
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function markdownToTelegramHtml(md: string): string {
|
|
24
|
+
let html = "";
|
|
25
|
+
const lines = md.split("\n");
|
|
26
|
+
let inCodeBlock = false;
|
|
27
|
+
let codeLang = "";
|
|
28
|
+
let codeContent = "";
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < lines.length; i++) {
|
|
31
|
+
const line = lines[i];
|
|
32
|
+
|
|
33
|
+
// Fenced code block start/end
|
|
34
|
+
if (line.trimStart().startsWith("```")) {
|
|
35
|
+
if (!inCodeBlock) {
|
|
36
|
+
inCodeBlock = true;
|
|
37
|
+
codeLang = line.trimStart().slice(3).trim();
|
|
38
|
+
codeContent = "";
|
|
39
|
+
} else {
|
|
40
|
+
// Close code block
|
|
41
|
+
inCodeBlock = false;
|
|
42
|
+
if (codeLang) {
|
|
43
|
+
html += `<pre><code class="language-${escapeHtml(codeLang)}">`;
|
|
44
|
+
} else {
|
|
45
|
+
html += "<pre><code>";
|
|
46
|
+
}
|
|
47
|
+
html += escapeHtml(codeContent);
|
|
48
|
+
html += "</code></pre>\n";
|
|
49
|
+
codeLang = "";
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (inCodeBlock) {
|
|
55
|
+
if (codeContent) codeContent += "\n";
|
|
56
|
+
codeContent += line;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Normal line — convert inline markdown
|
|
61
|
+
html += convertInlineMarkdown(line) + "\n";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// If code block was never closed, dump it anyway
|
|
65
|
+
if (inCodeBlock) {
|
|
66
|
+
html += "<pre><code>";
|
|
67
|
+
html += escapeHtml(codeContent);
|
|
68
|
+
html += "</code></pre>\n";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return html.trimEnd();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function convertInlineMarkdown(line: string): string {
|
|
75
|
+
// Escape HTML first in non-code parts
|
|
76
|
+
// We need to be careful: process inline code first, then escape the rest
|
|
77
|
+
|
|
78
|
+
// Extract inline code spans to protect them
|
|
79
|
+
const codeSpans: string[] = [];
|
|
80
|
+
let processed = line.replace(/`([^`]+)`/g, (_match, code) => {
|
|
81
|
+
const idx = codeSpans.length;
|
|
82
|
+
codeSpans.push(`<code>${escapeHtml(code)}</code>`);
|
|
83
|
+
return `\x00CODE${idx}\x00`;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Now escape HTML in the remaining text
|
|
87
|
+
processed = escapeHtml(processed);
|
|
88
|
+
|
|
89
|
+
// Bold **text** or __text__
|
|
90
|
+
processed = processed.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
|
|
91
|
+
processed = processed.replace(/__(.+?)__/g, "<b>$1</b>");
|
|
92
|
+
|
|
93
|
+
// Italic *text* or _text_ (but not inside words for underscore)
|
|
94
|
+
processed = processed.replace(/\*(.+?)\*/g, "<i>$1</i>");
|
|
95
|
+
|
|
96
|
+
// Strikethrough ~~text~~
|
|
97
|
+
processed = processed.replace(/~~(.+?)~~/g, "<s>$1</s>");
|
|
98
|
+
|
|
99
|
+
// Links [text](url)
|
|
100
|
+
processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
101
|
+
|
|
102
|
+
// Restore inline code spans
|
|
103
|
+
processed = processed.replace(/\x00CODE(\d+)\x00/g, (_match, idx) => {
|
|
104
|
+
return codeSpans[parseInt(idx, 10)];
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return processed;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Split a message into chunks that fit Telegram's 4096 char limit.
|
|
112
|
+
* Tries to split at newlines when possible.
|
|
113
|
+
*/
|
|
114
|
+
export function splitForTelegram(html: string): string[] {
|
|
115
|
+
if (html.length <= TELEGRAM_MAX_LENGTH) {
|
|
116
|
+
return [html];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const chunks: string[] = [];
|
|
120
|
+
let remaining = html;
|
|
121
|
+
|
|
122
|
+
while (remaining.length > 0) {
|
|
123
|
+
if (remaining.length <= TELEGRAM_MAX_LENGTH) {
|
|
124
|
+
chunks.push(remaining);
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Reserve space for truncation notice on the first chunk if needed
|
|
129
|
+
const maxChunk = TELEGRAM_MAX_LENGTH - TRUNCATION_NOTICE.length;
|
|
130
|
+
|
|
131
|
+
// Try to find a newline to split at
|
|
132
|
+
let splitAt = remaining.lastIndexOf("\n", maxChunk);
|
|
133
|
+
if (splitAt <= 0) {
|
|
134
|
+
// No good newline, just split at max
|
|
135
|
+
splitAt = maxChunk;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const chunk = remaining.slice(0, splitAt);
|
|
139
|
+
chunks.push(chunk);
|
|
140
|
+
remaining = remaining.slice(splitAt).trimStart();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Add truncation notice to last chunk if we split
|
|
144
|
+
if (chunks.length > 1) {
|
|
145
|
+
const last = chunks[chunks.length - 1];
|
|
146
|
+
if (last.length + TRUNCATION_NOTICE.length > TELEGRAM_MAX_LENGTH) {
|
|
147
|
+
// Trim last chunk to fit the notice
|
|
148
|
+
chunks[chunks.length - 1] = last.slice(0, TELEGRAM_MAX_LENGTH - TRUNCATION_NOTICE.length) + TRUNCATION_NOTICE;
|
|
149
|
+
} else {
|
|
150
|
+
chunks[chunks.length - 1] = last + TRUNCATION_NOTICE;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return chunks;
|
|
155
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { loadConfig, saveConfig, resolveToken, resolveChatId } from "./config.js";
|
|
3
|
+
import { startBot, stopBot, getBot, setAllowedChatId, setIncomingMessageHandler, waitForChatId, sendText, sendTyping } from "./bot.js";
|
|
4
|
+
import { markdownToTelegramHtml, splitForTelegram } from "./formatter.js";
|
|
5
|
+
|
|
6
|
+
export default function (pi: ExtensionAPI) {
|
|
7
|
+
let relayEnabled = false;
|
|
8
|
+
let chatId: number | null = null;
|
|
9
|
+
let botToken: string | null = null;
|
|
10
|
+
|
|
11
|
+
// ── Setup Flow ──────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
async function runSetup(ctx: ExtensionCommandContext): Promise<boolean> {
|
|
14
|
+
// 1. Resolve bot token
|
|
15
|
+
botToken = resolveToken();
|
|
16
|
+
if (!botToken) {
|
|
17
|
+
const input = await ctx.ui.input("Enter your Telegram bot token (from @BotFather):");
|
|
18
|
+
if (!input || !input.trim()) {
|
|
19
|
+
ctx.ui.notify("Setup cancelled — no token provided", "warning");
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
botToken = input.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 2. Start bot
|
|
26
|
+
ctx.ui.notify("Starting Telegram bot...", "info");
|
|
27
|
+
try {
|
|
28
|
+
await startBot(botToken);
|
|
29
|
+
} catch (err: any) {
|
|
30
|
+
ctx.ui.notify(`Failed to start bot: ${err.message}`, "error");
|
|
31
|
+
botToken = null;
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 3. Resolve chat ID
|
|
36
|
+
chatId = resolveChatId();
|
|
37
|
+
if (!chatId) {
|
|
38
|
+
ctx.ui.notify("Send any message to your bot on Telegram to link your chat...", "info");
|
|
39
|
+
chatId = await waitForChatId();
|
|
40
|
+
ctx.ui.notify(`Chat ID discovered: ${chatId}`, "info");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 4. Persist config
|
|
44
|
+
saveConfig({ botToken, chatId });
|
|
45
|
+
setAllowedChatId(chatId);
|
|
46
|
+
|
|
47
|
+
// 5. Wire up incoming message handler
|
|
48
|
+
wireIncomingHandler(ctx);
|
|
49
|
+
|
|
50
|
+
ctx.ui.notify(`✅ Telegram connected! Chat ID: ${chatId}`, "info");
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isSetUp(): boolean {
|
|
55
|
+
return getBot() !== null && chatId !== null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Incoming Message Handler ────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function wireIncomingHandler(ctx: ExtensionContext) {
|
|
61
|
+
setIncomingMessageHandler((_incomingChatId, text) => {
|
|
62
|
+
if (!relayEnabled) {
|
|
63
|
+
sendText(_incomingChatId, "⚠️ Relay is disabled. Enable with /telegram in pi.");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Notify in TUI
|
|
68
|
+
if (ctx.hasUI) {
|
|
69
|
+
ctx.ui.notify(`📱 Telegram: ${text.length > 60 ? text.slice(0, 60) + "…" : text}`, "info");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Send to agent
|
|
73
|
+
if (ctx.isIdle()) {
|
|
74
|
+
pi.sendUserMessage(text);
|
|
75
|
+
} else {
|
|
76
|
+
pi.sendUserMessage(text, { deliverAs: "followUp" });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Relay Toggle ────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
async function enableRelay(ctx: ExtensionContext) {
|
|
84
|
+
relayEnabled = true;
|
|
85
|
+
pi.appendEntry("telebridge-state", { enabled: true });
|
|
86
|
+
|
|
87
|
+
if (ctx.hasUI) {
|
|
88
|
+
const theme = ctx.ui.theme;
|
|
89
|
+
ctx.ui.setStatus("telebridge", theme.fg("success", "📡 TG"));
|
|
90
|
+
ctx.ui.notify("🟢 Telegram relay enabled", "info");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (chatId) {
|
|
94
|
+
await sendText(chatId, "📡 Connected to pi session");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function disableRelay(ctx: ExtensionContext) {
|
|
99
|
+
relayEnabled = false;
|
|
100
|
+
pi.appendEntry("telebridge-state", { enabled: false });
|
|
101
|
+
|
|
102
|
+
if (ctx.hasUI) {
|
|
103
|
+
ctx.ui.setStatus("telebridge", undefined);
|
|
104
|
+
ctx.ui.notify("🔴 Telegram relay disabled", "info");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (chatId) {
|
|
108
|
+
await sendText(chatId, "📴 Disconnected from pi session");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Commands ────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
pi.registerCommand("telegram", {
|
|
115
|
+
description: "Toggle Telegram relay (setup | status | on/off)",
|
|
116
|
+
handler: async (args, ctx) => {
|
|
117
|
+
const subcommand = args?.trim().toLowerCase();
|
|
118
|
+
|
|
119
|
+
if (subcommand === "setup") {
|
|
120
|
+
await runSetup(ctx);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (subcommand === "status") {
|
|
125
|
+
const botRunning = getBot() !== null;
|
|
126
|
+
const lines = [
|
|
127
|
+
`Bot: ${botRunning ? "✅ running" : "❌ stopped"}`,
|
|
128
|
+
`Chat ID: ${chatId ?? "not set"}`,
|
|
129
|
+
`Relay: ${relayEnabled ? "🟢 enabled" : "🔴 disabled"}`,
|
|
130
|
+
];
|
|
131
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Toggle: set up first if needed
|
|
136
|
+
if (!isSetUp()) {
|
|
137
|
+
const ok = await runSetup(ctx);
|
|
138
|
+
if (!ok) return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Toggle relay
|
|
142
|
+
if (relayEnabled) {
|
|
143
|
+
await disableRelay(ctx);
|
|
144
|
+
} else {
|
|
145
|
+
await enableRelay(ctx);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── Session Events ──────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
153
|
+
// Restore relay state from session entries
|
|
154
|
+
relayEnabled = false;
|
|
155
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
156
|
+
if (entry.type === "custom" && entry.customType === "telebridge-state") {
|
|
157
|
+
const data = (entry as { data?: { enabled?: boolean } }).data;
|
|
158
|
+
relayEnabled = data?.enabled ?? false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// If relay was enabled, try to reconnect the bot
|
|
163
|
+
if (relayEnabled) {
|
|
164
|
+
botToken = resolveToken();
|
|
165
|
+
chatId = resolveChatId();
|
|
166
|
+
|
|
167
|
+
if (botToken && chatId) {
|
|
168
|
+
try {
|
|
169
|
+
await startBot(botToken);
|
|
170
|
+
setAllowedChatId(chatId);
|
|
171
|
+
wireIncomingHandler(ctx);
|
|
172
|
+
|
|
173
|
+
if (ctx.hasUI) {
|
|
174
|
+
const theme = ctx.ui.theme;
|
|
175
|
+
ctx.ui.setStatus("telebridge", theme.fg("success", "📡 TG"));
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
relayEnabled = false;
|
|
179
|
+
if (ctx.hasUI) {
|
|
180
|
+
ctx.ui.notify("⚠️ Telebridge: failed to reconnect bot", "warning");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
relayEnabled = false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
pi.on("session_shutdown", async () => {
|
|
190
|
+
if (relayEnabled && chatId) {
|
|
191
|
+
await sendText(chatId, "📴 pi session ended");
|
|
192
|
+
}
|
|
193
|
+
await stopBot();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ── Agent → Telegram (outgoing) ─────────────────────────────
|
|
197
|
+
|
|
198
|
+
pi.on("agent_start", async () => {
|
|
199
|
+
if (relayEnabled && chatId) {
|
|
200
|
+
await sendTyping(chatId);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
pi.on("agent_end", async (event) => {
|
|
205
|
+
if (!relayEnabled || !chatId) return;
|
|
206
|
+
|
|
207
|
+
// Extract the last assistant message text
|
|
208
|
+
const messages = event.messages ?? [];
|
|
209
|
+
let assistantText = "";
|
|
210
|
+
|
|
211
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
212
|
+
const msg = messages[i];
|
|
213
|
+
if (msg.role === "assistant") {
|
|
214
|
+
// Extract text content blocks
|
|
215
|
+
if (typeof msg.content === "string") {
|
|
216
|
+
assistantText = msg.content;
|
|
217
|
+
} else if (Array.isArray(msg.content)) {
|
|
218
|
+
assistantText = msg.content
|
|
219
|
+
.filter((block: any) => block.type === "text")
|
|
220
|
+
.map((block: any) => block.text)
|
|
221
|
+
.join("\n");
|
|
222
|
+
}
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!assistantText.trim()) return;
|
|
228
|
+
|
|
229
|
+
// Convert markdown to Telegram HTML and split if needed
|
|
230
|
+
const html = markdownToTelegramHtml(assistantText);
|
|
231
|
+
const chunks = splitForTelegram(html);
|
|
232
|
+
|
|
233
|
+
for (const chunk of chunks) {
|
|
234
|
+
try {
|
|
235
|
+
await sendText(chatId!, chunk, "HTML");
|
|
236
|
+
} catch {
|
|
237
|
+
// If HTML parsing fails, try plain text
|
|
238
|
+
try {
|
|
239
|
+
await sendText(chatId!, assistantText.slice(0, 4096));
|
|
240
|
+
} catch {
|
|
241
|
+
// Give up silently
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|