@lapage/codex-telegram-bridge 0.1.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/.env.example +17 -0
- package/README.md +204 -0
- package/dist/codex-session.js +76 -0
- package/dist/config.js +52 -0
- package/dist/env.js +64 -0
- package/dist/index.js +34 -0
- package/dist/telegram-bridge.js +334 -0
- package/dist/text.js +272 -0
- package/dist/tmux.js +25 -0
- package/package.json +52 -0
package/.env.example
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
TELEGRAM_BOT_TOKEN=123456:replace_me
|
|
2
|
+
TELEGRAM_ALLOWED_USER_IDS=123456789
|
|
3
|
+
CODEX_CWD=~
|
|
4
|
+
CODEX_COMMAND=codex
|
|
5
|
+
CODEX_ARGS=--search --yolo
|
|
6
|
+
CODEX_SUBMIT_KEY=Enter
|
|
7
|
+
CODEX_SUBMIT_DELAY_MS=800
|
|
8
|
+
TMUX_SESSION=codex-telegram-bridge
|
|
9
|
+
CODEX_COLS=120
|
|
10
|
+
CODEX_ROWS=40
|
|
11
|
+
FLUSH_INTERVAL_MS=1200
|
|
12
|
+
POLL_INTERVAL_MS=500
|
|
13
|
+
CODEX_STARTUP_DELAY_MS=1500
|
|
14
|
+
STREAM_EDIT_INTERVAL_MS=650
|
|
15
|
+
STREAM_MIN_CHANGE_CHARS=24
|
|
16
|
+
TYPING_INTERVAL_MS=4000
|
|
17
|
+
MAX_TELEGRAM_CHARS=3500
|
package/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# LaPage Codex Telegram Bridge
|
|
2
|
+
|
|
3
|
+
Control a local **Codex CLI** session from Telegram without opening any inbound ports to the internet.
|
|
4
|
+
|
|
5
|
+
This package is useful when Codex runs on a home PC, workstation, homelab box, or private server and you want a simple mobile chat interface. The bridge starts Codex inside a detached `tmux` session, receives Telegram messages from an allowlisted user, sends them into Codex, and relays Codex output back to Telegram.
|
|
6
|
+
|
|
7
|
+
## Why Use This
|
|
8
|
+
|
|
9
|
+
- No public SSH, HTTP, webhook, tunnel, or reverse proxy is required.
|
|
10
|
+
- Telegram provides the outbound connection path from your machine.
|
|
11
|
+
- Codex keeps running in `tmux`, so the session can survive terminal disconnects.
|
|
12
|
+
- Access is restricted to the Telegram user IDs you allowlist.
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- Node.js 22 or newer
|
|
17
|
+
- npm
|
|
18
|
+
- `tmux`
|
|
19
|
+
- Codex CLI available on `PATH`
|
|
20
|
+
- A Telegram bot token from [@BotFather](https://t.me/BotFather)
|
|
21
|
+
- Your numeric Telegram user ID, for example from [@userinfobot](https://t.me/userinfobot)
|
|
22
|
+
|
|
23
|
+
Check prerequisites:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
node --version
|
|
27
|
+
npm --version
|
|
28
|
+
tmux -V
|
|
29
|
+
codex --version
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Install `tmux` if it is missing:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
# macOS
|
|
36
|
+
brew install tmux
|
|
37
|
+
|
|
38
|
+
# Ubuntu / Debian
|
|
39
|
+
sudo apt update && sudo apt install -y tmux
|
|
40
|
+
|
|
41
|
+
# Fedora
|
|
42
|
+
sudo dnf install -y tmux
|
|
43
|
+
|
|
44
|
+
# Arch Linux
|
|
45
|
+
sudo pacman -S tmux
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
Install the package globally:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
npm install -g @lapage/codex-telegram-bridge
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Create the default config file:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
codex-telegram-bridge init
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This creates:
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
~/.lapage-codex-telegram-bridge/.env
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Edit the config file:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
$EDITOR ~/.lapage-codex-telegram-bridge/.env
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
At minimum, set these values:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
TELEGRAM_BOT_TOKEN=123456:your_real_bot_token
|
|
78
|
+
TELEGRAM_ALLOWED_USER_IDS=123456789
|
|
79
|
+
CODEX_CWD=~/Code/your-project
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Start the bridge:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
codex-telegram-bridge
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Open your Telegram bot chat and send `/status`. Any normal message after that is sent to Codex as a prompt.
|
|
89
|
+
|
|
90
|
+
## Configuration File
|
|
91
|
+
|
|
92
|
+
By default, the CLI reads:
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
~/.lapage-codex-telegram-bridge/.env
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A local `.env` in the current working directory is also supported, which is useful for development or per-project overrides.
|
|
99
|
+
|
|
100
|
+
Use a custom config file with:
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
CODEX_TELEGRAM_BRIDGE_ENV=/path/to/.env codex-telegram-bridge
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Configuration Options
|
|
107
|
+
|
|
108
|
+
| Variable | Default | Description |
|
|
109
|
+
| --- | --- | --- |
|
|
110
|
+
| `TELEGRAM_BOT_TOKEN` | required | Telegram bot token from BotFather. |
|
|
111
|
+
| `TELEGRAM_ALLOWED_USER_IDS` | required | Comma-separated numeric Telegram user IDs allowed to use the bot. |
|
|
112
|
+
| `CODEX_CWD` | current directory | Working directory where Codex starts. `~` is supported. |
|
|
113
|
+
| `CODEX_COMMAND` | `codex` | Codex command or binary path. |
|
|
114
|
+
| `CODEX_ARGS` | `--search --yolo` | Extra Codex startup arguments. |
|
|
115
|
+
| `CODEX_SUBMIT_KEY` | `Enter` | tmux key used to submit a prompt to Codex. |
|
|
116
|
+
| `CODEX_SUBMIT_DELAY_MS` | `800` | Delay after pasting text before pressing submit. |
|
|
117
|
+
| `CODEX_STARTUP_DELAY_MS` | `1500` | Delay before first prompt if Codex has not produced output yet. |
|
|
118
|
+
| `TMUX_SESSION` | `codex-telegram-bridge` | Detached tmux session name. |
|
|
119
|
+
| `CODEX_COLS` | `120` | tmux pane width. |
|
|
120
|
+
| `CODEX_ROWS` | `40` | tmux pane height. |
|
|
121
|
+
| `POLL_INTERVAL_MS` | `500` | How often the bridge polls tmux output. |
|
|
122
|
+
| `FLUSH_INTERVAL_MS` | `1200` | Delay before flushing non-stream fallback output. |
|
|
123
|
+
| `STREAM_EDIT_INTERVAL_MS` | `650` | Minimum interval between Telegram message edits. |
|
|
124
|
+
| `STREAM_MIN_CHANGE_CHARS` | `24` | Minimum text growth before editing mid-response. |
|
|
125
|
+
| `TYPING_INTERVAL_MS` | `4000` | How often to send Telegram typing action. |
|
|
126
|
+
| `MAX_TELEGRAM_CHARS` | `3500` | Max response chunk size below Telegram's message limit. |
|
|
127
|
+
|
|
128
|
+
## Telegram Commands
|
|
129
|
+
|
|
130
|
+
- `/status` — show bridge state, tmux session, working directory, and Codex command.
|
|
131
|
+
- `/flush` — force-read and relay the latest Codex response.
|
|
132
|
+
- `/interrupt` — send Ctrl-C to Codex.
|
|
133
|
+
- `/restart` — restart the Codex tmux session.
|
|
134
|
+
- `/stop` — stop the Codex tmux session.
|
|
135
|
+
- Any other text is sent directly to Codex as a prompt.
|
|
136
|
+
|
|
137
|
+
## Running as a Background Service
|
|
138
|
+
|
|
139
|
+
For a long-running home server setup, run the bridge with your preferred process manager, for example `systemd`, `pm2`, `launchd`, or a persistent `tmux` session.
|
|
140
|
+
|
|
141
|
+
Example with `tmux`:
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
tmux new -s codex-telegram-bridge-runner 'codex-telegram-bridge'
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Detach from tmux with:
|
|
148
|
+
|
|
149
|
+
```text
|
|
150
|
+
Ctrl-b, then d
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Debugging Codex
|
|
154
|
+
|
|
155
|
+
The bridge itself runs Codex inside another detached `tmux` session. Attach to it with:
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
tmux attach -t codex-telegram-bridge
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
If `TMUX_SESSION` is changed in the config file, use that session name instead.
|
|
162
|
+
|
|
163
|
+
## Install From Source
|
|
164
|
+
|
|
165
|
+
Use source install if you want to modify or contribute to the bridge:
|
|
166
|
+
|
|
167
|
+
```sh
|
|
168
|
+
git clone https://github.com/zhuylanz/lapage-codex-telegram-bridge.git
|
|
169
|
+
cd lapage-codex-telegram-bridge
|
|
170
|
+
npm install
|
|
171
|
+
cp .env.example .env
|
|
172
|
+
npm run dev
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Build and run locally:
|
|
176
|
+
|
|
177
|
+
```sh
|
|
178
|
+
npm run build
|
|
179
|
+
npm start
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Development
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
npm run typecheck
|
|
186
|
+
npm run build
|
|
187
|
+
npm run dev
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Security Notes
|
|
191
|
+
|
|
192
|
+
This bridge exposes a local Codex CLI session through Telegram. Treat the bot as remote control for your machine.
|
|
193
|
+
|
|
194
|
+
Recommended safeguards:
|
|
195
|
+
|
|
196
|
+
- Only allow trusted Telegram user IDs in `TELEGRAM_ALLOWED_USER_IDS`.
|
|
197
|
+
- Do not share your Telegram bot token.
|
|
198
|
+
- Run the bridge under a user account with appropriate file permissions.
|
|
199
|
+
- Point `CODEX_CWD` at a workspace you are comfortable controlling remotely.
|
|
200
|
+
- Understand that `CODEX_ARGS=--yolo` allows Codex to act with fewer confirmations.
|
|
201
|
+
|
|
202
|
+
## Repository
|
|
203
|
+
|
|
204
|
+
GitHub: <https://github.com/zhuylanz/lapage-codex-telegram-bridge>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { capturePane, runTmux, shellCommand, tmuxSessionExists } from './tmux.js';
|
|
2
|
+
export class CodexSession {
|
|
3
|
+
config;
|
|
4
|
+
running = false;
|
|
5
|
+
startPromise = null;
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
}
|
|
9
|
+
get isRunning() {
|
|
10
|
+
return this.running;
|
|
11
|
+
}
|
|
12
|
+
async start() {
|
|
13
|
+
if (this.startPromise) {
|
|
14
|
+
return this.startPromise;
|
|
15
|
+
}
|
|
16
|
+
this.startPromise = this.startSession().finally(() => {
|
|
17
|
+
this.startPromise = null;
|
|
18
|
+
});
|
|
19
|
+
return this.startPromise;
|
|
20
|
+
}
|
|
21
|
+
async stop() {
|
|
22
|
+
if (await this.exists()) {
|
|
23
|
+
await runTmux(['kill-session', '-t', this.config.tmuxSession]).catch(() => undefined);
|
|
24
|
+
}
|
|
25
|
+
this.running = false;
|
|
26
|
+
}
|
|
27
|
+
async restart() {
|
|
28
|
+
await this.stop();
|
|
29
|
+
await this.start();
|
|
30
|
+
}
|
|
31
|
+
async sendText(text) {
|
|
32
|
+
await runTmux(['set-buffer', '-b', 'codex-telegram-input', text]);
|
|
33
|
+
await runTmux(['paste-buffer', '-b', 'codex-telegram-input', '-t', this.config.tmuxSession]);
|
|
34
|
+
await sleep(this.config.codexSubmitDelayMs);
|
|
35
|
+
await runTmux(['send-keys', '-t', this.config.tmuxSession, this.config.codexSubmitKey]);
|
|
36
|
+
}
|
|
37
|
+
async interrupt() {
|
|
38
|
+
if (await this.exists()) {
|
|
39
|
+
await runTmux(['send-keys', '-t', this.config.tmuxSession, 'C-c']);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async waitUntilReady(hasOutput) {
|
|
43
|
+
if (this.config.startupDelayMs > 0 && !hasOutput) {
|
|
44
|
+
await sleep(this.config.startupDelayMs);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async exists() {
|
|
48
|
+
return tmuxSessionExists(this.config.tmuxSession);
|
|
49
|
+
}
|
|
50
|
+
async capturePane() {
|
|
51
|
+
return capturePane(this.config.tmuxSession, this.config.rows);
|
|
52
|
+
}
|
|
53
|
+
async startSession() {
|
|
54
|
+
if (this.running && await this.exists()) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
await runTmux(['kill-session', '-t', this.config.tmuxSession]).catch(() => undefined);
|
|
58
|
+
await runTmux([
|
|
59
|
+
'new-session',
|
|
60
|
+
'-d',
|
|
61
|
+
'-s',
|
|
62
|
+
this.config.tmuxSession,
|
|
63
|
+
'-c',
|
|
64
|
+
this.config.codexCwd,
|
|
65
|
+
'-x',
|
|
66
|
+
String(this.config.cols),
|
|
67
|
+
'-y',
|
|
68
|
+
String(this.config.rows),
|
|
69
|
+
shellCommand([this.config.codexCommand, ...this.config.codexArgs]),
|
|
70
|
+
]);
|
|
71
|
+
this.running = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function sleep(ms) {
|
|
75
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
76
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export function readConfig() {
|
|
2
|
+
const token = requireEnv('TELEGRAM_BOT_TOKEN');
|
|
3
|
+
const allowedUserIds = new Set(requireEnv('TELEGRAM_ALLOWED_USER_IDS')
|
|
4
|
+
.split(',')
|
|
5
|
+
.map((value) => Number(value.trim()))
|
|
6
|
+
.filter((value) => Number.isInteger(value)));
|
|
7
|
+
if (allowedUserIds.size === 0) {
|
|
8
|
+
throw new Error('TELEGRAM_ALLOWED_USER_IDS must contain at least one numeric user ID.');
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
token,
|
|
12
|
+
allowedUserIds,
|
|
13
|
+
codexCommand: process.env.CODEX_COMMAND || 'codex',
|
|
14
|
+
codexArgs: parseArgs(process.env.CODEX_ARGS || '--search --yolo'),
|
|
15
|
+
codexSubmitKey: process.env.CODEX_SUBMIT_KEY || 'Enter',
|
|
16
|
+
codexSubmitDelayMs: readNumber('CODEX_SUBMIT_DELAY_MS', 800),
|
|
17
|
+
codexCwd: expandHome(process.env.CODEX_CWD || process.cwd()),
|
|
18
|
+
tmuxSession: process.env.TMUX_SESSION || 'codex-telegram-bridge',
|
|
19
|
+
cols: readNumber('CODEX_COLS', 120),
|
|
20
|
+
rows: readNumber('CODEX_ROWS', 40),
|
|
21
|
+
flushIntervalMs: readNumber('FLUSH_INTERVAL_MS', 1200),
|
|
22
|
+
pollIntervalMs: readNumber('POLL_INTERVAL_MS', 500),
|
|
23
|
+
startupDelayMs: readNumber('CODEX_STARTUP_DELAY_MS', 1500),
|
|
24
|
+
streamEditIntervalMs: readNumber('STREAM_EDIT_INTERVAL_MS', 650),
|
|
25
|
+
streamMinChangeChars: readNumber('STREAM_MIN_CHANGE_CHARS', 24),
|
|
26
|
+
typingIntervalMs: readNumber('TYPING_INTERVAL_MS', 4000),
|
|
27
|
+
maxTelegramChars: Math.min(readNumber('MAX_TELEGRAM_CHARS', 3500), 3900),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function parseArgs(value) {
|
|
31
|
+
return value.split(' ').map((part) => part.trim()).filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
function readNumber(name, fallback) {
|
|
34
|
+
const value = Number(process.env[name]);
|
|
35
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
36
|
+
}
|
|
37
|
+
function requireEnv(name) {
|
|
38
|
+
const value = process.env[name];
|
|
39
|
+
if (!value) {
|
|
40
|
+
throw new Error(`${name} is required.`);
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
function expandHome(value) {
|
|
45
|
+
if (value === '~') {
|
|
46
|
+
return process.env.HOME || value;
|
|
47
|
+
}
|
|
48
|
+
if (value.startsWith('~/')) {
|
|
49
|
+
return `${process.env.HOME || '~'}${value.slice(1)}`;
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { config as loadDotenv } from 'dotenv';
|
|
6
|
+
const configDirName = '.lapage-codex-telegram-bridge';
|
|
7
|
+
const envFileName = '.env';
|
|
8
|
+
export function defaultConfigDir() {
|
|
9
|
+
return join(homedir(), configDirName);
|
|
10
|
+
}
|
|
11
|
+
export function defaultEnvPath() {
|
|
12
|
+
return join(defaultConfigDir(), envFileName);
|
|
13
|
+
}
|
|
14
|
+
export function loadEnvironment() {
|
|
15
|
+
const loaded = [];
|
|
16
|
+
const explicitEnvPath = process.env.CODEX_TELEGRAM_BRIDGE_ENV;
|
|
17
|
+
const candidates = explicitEnvPath
|
|
18
|
+
? [resolve(explicitEnvPath)]
|
|
19
|
+
: [resolve(process.cwd(), envFileName), defaultEnvPath()];
|
|
20
|
+
for (const path of candidates) {
|
|
21
|
+
if (!existsSync(path)) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
loadDotenv({ path, override: false });
|
|
25
|
+
loaded.push(path);
|
|
26
|
+
}
|
|
27
|
+
return loaded;
|
|
28
|
+
}
|
|
29
|
+
export function createDefaultEnvFile() {
|
|
30
|
+
const targetPath = defaultEnvPath();
|
|
31
|
+
if (existsSync(targetPath)) {
|
|
32
|
+
return targetPath;
|
|
33
|
+
}
|
|
34
|
+
mkdirSync(defaultConfigDir(), { recursive: true, mode: 0o700 });
|
|
35
|
+
writeFileSync(targetPath, readEnvTemplate(), { mode: 0o600 });
|
|
36
|
+
return targetPath;
|
|
37
|
+
}
|
|
38
|
+
function readEnvTemplate() {
|
|
39
|
+
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
40
|
+
const templatePath = join(packageRoot, '.env.example');
|
|
41
|
+
if (existsSync(templatePath)) {
|
|
42
|
+
return readFileSync(templatePath, 'utf8');
|
|
43
|
+
}
|
|
44
|
+
return [
|
|
45
|
+
'TELEGRAM_BOT_TOKEN=123456:replace_me',
|
|
46
|
+
'TELEGRAM_ALLOWED_USER_IDS=123456789',
|
|
47
|
+
'CODEX_CWD=~',
|
|
48
|
+
'CODEX_COMMAND=codex',
|
|
49
|
+
'CODEX_ARGS=--search --yolo',
|
|
50
|
+
'CODEX_SUBMIT_KEY=Enter',
|
|
51
|
+
'CODEX_SUBMIT_DELAY_MS=800',
|
|
52
|
+
'TMUX_SESSION=codex-telegram-bridge',
|
|
53
|
+
'CODEX_COLS=120',
|
|
54
|
+
'CODEX_ROWS=40',
|
|
55
|
+
'FLUSH_INTERVAL_MS=1200',
|
|
56
|
+
'POLL_INTERVAL_MS=500',
|
|
57
|
+
'CODEX_STARTUP_DELAY_MS=1500',
|
|
58
|
+
'STREAM_EDIT_INTERVAL_MS=650',
|
|
59
|
+
'STREAM_MIN_CHANGE_CHARS=24',
|
|
60
|
+
'TYPING_INTERVAL_MS=4000',
|
|
61
|
+
'MAX_TELEGRAM_CHARS=3500',
|
|
62
|
+
'',
|
|
63
|
+
].join('\n');
|
|
64
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readConfig } from './config.js';
|
|
3
|
+
import { createDefaultEnvFile, defaultEnvPath, loadEnvironment } from './env.js';
|
|
4
|
+
import { TelegramCodexBridge } from './telegram-bridge.js';
|
|
5
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
6
|
+
printHelp();
|
|
7
|
+
process.exit(0);
|
|
8
|
+
}
|
|
9
|
+
if (process.argv.includes('init')) {
|
|
10
|
+
const envPath = createDefaultEnvFile();
|
|
11
|
+
console.log(`Config file ready: ${envPath}`);
|
|
12
|
+
console.log('Edit it, then run: codex-telegram-bridge');
|
|
13
|
+
process.exit(0);
|
|
14
|
+
}
|
|
15
|
+
loadEnvironment();
|
|
16
|
+
const bridge = new TelegramCodexBridge(readConfig());
|
|
17
|
+
void bridge.start();
|
|
18
|
+
process.on('SIGINT', shutdown);
|
|
19
|
+
process.on('SIGTERM', shutdown);
|
|
20
|
+
async function shutdown() {
|
|
21
|
+
await bridge.stop();
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
function printHelp() {
|
|
25
|
+
console.log(`LaPage Codex Telegram Bridge
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
codex-telegram-bridge init Create ${defaultEnvPath()}
|
|
29
|
+
codex-telegram-bridge Start the bridge
|
|
30
|
+
|
|
31
|
+
Environment:
|
|
32
|
+
CODEX_TELEGRAM_BRIDGE_ENV=/path/to/.env Use a custom config file
|
|
33
|
+
`);
|
|
34
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { Bot } from 'grammy';
|
|
2
|
+
import { CodexSession } from './codex-session.js';
|
|
3
|
+
import { chunkText, formatTelegramMarkdown, isCodexWorking, latestCodexResponse, latestCompletedCodexResponse, plainTelegramText } from './text.js';
|
|
4
|
+
export class TelegramCodexBridge {
|
|
5
|
+
config;
|
|
6
|
+
bot;
|
|
7
|
+
codex;
|
|
8
|
+
activeChatId = null;
|
|
9
|
+
outputBuffer = '';
|
|
10
|
+
lastOutputAt = null;
|
|
11
|
+
flushTimer = null;
|
|
12
|
+
pollTimer = null;
|
|
13
|
+
lastPaneResponse = '';
|
|
14
|
+
lastSentResponse = '';
|
|
15
|
+
lastSnapshotSentAt = 0;
|
|
16
|
+
streamMessageId = null;
|
|
17
|
+
lastStreamText = '';
|
|
18
|
+
lastStreamEditAt = 0;
|
|
19
|
+
typingTimer = null;
|
|
20
|
+
sendQueue = Promise.resolve();
|
|
21
|
+
constructor(config) {
|
|
22
|
+
this.config = config;
|
|
23
|
+
this.bot = new Bot(config.token);
|
|
24
|
+
this.codex = new CodexSession(config);
|
|
25
|
+
}
|
|
26
|
+
async start() {
|
|
27
|
+
await this.codex.start();
|
|
28
|
+
this.startPollingOutput();
|
|
29
|
+
this.bot.on('message', async (context) => this.handleMessage(context));
|
|
30
|
+
this.bot.catch((error) => {
|
|
31
|
+
console.error('Telegram bot error:', error.message);
|
|
32
|
+
});
|
|
33
|
+
this.bot.start();
|
|
34
|
+
}
|
|
35
|
+
async stop() {
|
|
36
|
+
this.stopPollingOutput();
|
|
37
|
+
this.stopTypingIndicator();
|
|
38
|
+
await this.codex.stop();
|
|
39
|
+
if (this.flushTimer) {
|
|
40
|
+
clearTimeout(this.flushTimer);
|
|
41
|
+
this.flushTimer = null;
|
|
42
|
+
}
|
|
43
|
+
await this.bot.stop();
|
|
44
|
+
}
|
|
45
|
+
async handleMessage(context) {
|
|
46
|
+
const chatId = context.chat?.id;
|
|
47
|
+
const userId = context.from?.id;
|
|
48
|
+
if (!chatId) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!userId || !this.config.allowedUserIds.has(userId)) {
|
|
52
|
+
await context.reply('Not authorized.');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
this.activeChatId = chatId;
|
|
56
|
+
const text = context.message?.text ?? '';
|
|
57
|
+
if (!text.trim()) {
|
|
58
|
+
await context.reply('Send text to forward it to Codex.');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (await this.handleCommand(context, text.trim())) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!this.codex.isRunning) {
|
|
65
|
+
await this.codex.start();
|
|
66
|
+
}
|
|
67
|
+
await this.codex.waitUntilReady(Boolean(this.lastOutputAt));
|
|
68
|
+
await this.codex.sendText(text);
|
|
69
|
+
const streamMessage = await context.reply('Codex is working…');
|
|
70
|
+
this.streamMessageId = streamMessage.message_id;
|
|
71
|
+
this.lastStreamText = 'Codex is working…';
|
|
72
|
+
this.lastStreamEditAt = Date.now();
|
|
73
|
+
this.startTypingIndicator();
|
|
74
|
+
}
|
|
75
|
+
async handleCommand(context, text) {
|
|
76
|
+
switch (text) {
|
|
77
|
+
case '/start':
|
|
78
|
+
case '/help':
|
|
79
|
+
await context.reply(this.helpText());
|
|
80
|
+
return true;
|
|
81
|
+
case '/status':
|
|
82
|
+
await context.reply(this.statusText());
|
|
83
|
+
return true;
|
|
84
|
+
case '/flush':
|
|
85
|
+
await this.readNewOutput(true);
|
|
86
|
+
await this.flushOutput(true);
|
|
87
|
+
return true;
|
|
88
|
+
case '/interrupt':
|
|
89
|
+
await this.codex.interrupt();
|
|
90
|
+
await context.reply('Sent Ctrl-C to Codex.');
|
|
91
|
+
return true;
|
|
92
|
+
case '/restart':
|
|
93
|
+
await this.codex.restart();
|
|
94
|
+
this.resetSnapshots();
|
|
95
|
+
this.stopTypingIndicator();
|
|
96
|
+
await context.reply('Restarted Codex.');
|
|
97
|
+
return true;
|
|
98
|
+
case '/stop':
|
|
99
|
+
await this.codex.stop();
|
|
100
|
+
this.stopTypingIndicator();
|
|
101
|
+
await context.reply('Stopped Codex. Send any message to start it again.');
|
|
102
|
+
return true;
|
|
103
|
+
default:
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
startPollingOutput() {
|
|
108
|
+
this.stopPollingOutput();
|
|
109
|
+
this.pollTimer = setInterval(() => {
|
|
110
|
+
void this.readNewOutput();
|
|
111
|
+
void this.refreshCodexRunningState();
|
|
112
|
+
}, this.config.pollIntervalMs);
|
|
113
|
+
}
|
|
114
|
+
stopPollingOutput() {
|
|
115
|
+
if (this.pollTimer) {
|
|
116
|
+
clearInterval(this.pollTimer);
|
|
117
|
+
this.pollTimer = null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
startTypingIndicator() {
|
|
121
|
+
if (!this.activeChatId) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.stopTypingIndicator();
|
|
125
|
+
void this.sendTypingAction();
|
|
126
|
+
this.typingTimer = setInterval(() => {
|
|
127
|
+
void this.sendTypingAction();
|
|
128
|
+
}, this.config.typingIntervalMs);
|
|
129
|
+
}
|
|
130
|
+
stopTypingIndicator() {
|
|
131
|
+
if (this.typingTimer) {
|
|
132
|
+
clearInterval(this.typingTimer);
|
|
133
|
+
this.typingTimer = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async sendTypingAction() {
|
|
137
|
+
if (!this.activeChatId) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
await this.bot.api.sendChatAction(this.activeChatId, 'typing').catch(() => undefined);
|
|
141
|
+
}
|
|
142
|
+
async readNewOutput(force = false) {
|
|
143
|
+
if (!this.activeChatId || !await this.codex.exists()) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const pane = await this.codex.capturePane();
|
|
147
|
+
const response = force ? latestCompletedCodexResponse(pane) : latestCodexResponse(pane);
|
|
148
|
+
const working = isCodexWorking(pane);
|
|
149
|
+
if (this.streamMessageId && response) {
|
|
150
|
+
await this.editStreamMessage(response, !working || force);
|
|
151
|
+
if (!working || force) {
|
|
152
|
+
this.stopTypingIndicator();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (working && !force) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (!response || (!force && response === this.lastPaneResponse)) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
this.lastPaneResponse = response;
|
|
162
|
+
if (!force && !this.shouldSendResponse(response)) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.lastSentResponse = response;
|
|
166
|
+
this.lastSnapshotSentAt = Date.now();
|
|
167
|
+
this.outputBuffer = response;
|
|
168
|
+
this.lastOutputAt = new Date();
|
|
169
|
+
if (!this.streamMessageId) {
|
|
170
|
+
this.scheduleFlush();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async refreshCodexRunningState() {
|
|
174
|
+
const exists = await this.codex.exists();
|
|
175
|
+
if (!exists && this.codex.isRunning) {
|
|
176
|
+
this.outputBuffer = '[Codex tmux session exited]';
|
|
177
|
+
this.scheduleFlush();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
shouldSendResponse(response) {
|
|
181
|
+
if (response === this.lastSentResponse) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
scheduleFlush() {
|
|
187
|
+
if (this.flushTimer) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
this.flushTimer = setTimeout(async () => {
|
|
191
|
+
this.flushTimer = null;
|
|
192
|
+
await this.flushOutput(false);
|
|
193
|
+
}, this.config.flushIntervalMs);
|
|
194
|
+
}
|
|
195
|
+
async flushOutput(force) {
|
|
196
|
+
if (!this.activeChatId) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const trimmed = this.outputBuffer.trimEnd();
|
|
200
|
+
if (!trimmed) {
|
|
201
|
+
if (force) {
|
|
202
|
+
await this.queueTelegramSend(() => this.bot.api.sendMessage(this.activeChatId, 'No buffered output.'));
|
|
203
|
+
}
|
|
204
|
+
this.outputBuffer = '';
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
this.outputBuffer = '';
|
|
208
|
+
for (const chunk of chunkText(trimmed, this.config.maxTelegramChars)) {
|
|
209
|
+
await this.sendFormattedMessage(chunk);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async editStreamMessage(text, force) {
|
|
213
|
+
if (!this.activeChatId || !this.streamMessageId) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const trimmed = text.trim();
|
|
217
|
+
if (!trimmed || trimmed === this.lastStreamText) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
if (!force && !this.isMeaningfulStreamChange(trimmed, now)) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const [chunk] = chunkText(trimmed, this.config.maxTelegramChars);
|
|
225
|
+
await this.editFormattedMessage(chunk);
|
|
226
|
+
this.lastStreamText = trimmed;
|
|
227
|
+
this.lastStreamEditAt = now;
|
|
228
|
+
}
|
|
229
|
+
async sendFormattedMessage(text) {
|
|
230
|
+
if (!this.activeChatId) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const markdown = formatTelegramMarkdown(text);
|
|
234
|
+
const chatId = this.activeChatId;
|
|
235
|
+
const sent = await this.queueTelegramSend(() => this.bot.api.sendMessage(chatId, markdown, { parse_mode: 'MarkdownV2' }))
|
|
236
|
+
.then(() => true)
|
|
237
|
+
.catch(() => false);
|
|
238
|
+
if (!sent) {
|
|
239
|
+
await this.queueTelegramSend(() => this.bot.api.sendMessage(chatId, plainTelegramText(text)));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async editFormattedMessage(text) {
|
|
243
|
+
if (!this.activeChatId || !this.streamMessageId) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const markdown = formatTelegramMarkdown(text);
|
|
247
|
+
const edited = await this.bot.api.editMessageText(this.activeChatId, this.streamMessageId, markdown, {
|
|
248
|
+
parse_mode: 'MarkdownV2',
|
|
249
|
+
}).then(() => true).catch(() => false);
|
|
250
|
+
if (!edited) {
|
|
251
|
+
await this.bot.api.editMessageText(this.activeChatId, this.streamMessageId, plainTelegramText(text)).catch(() => undefined);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
queueTelegramSend(operation) {
|
|
255
|
+
const run = async () => {
|
|
256
|
+
await sleep(350);
|
|
257
|
+
return retryTelegramOperation(operation);
|
|
258
|
+
};
|
|
259
|
+
const next = this.sendQueue.then(run, run);
|
|
260
|
+
this.sendQueue = next.then(() => undefined, () => undefined);
|
|
261
|
+
return next;
|
|
262
|
+
}
|
|
263
|
+
isMeaningfulStreamChange(nextText, now) {
|
|
264
|
+
if (now - this.lastStreamEditAt < this.config.streamEditIntervalMs) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
if (nextText.length < this.lastStreamText.length) {
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
return nextText.length - this.lastStreamText.length >= this.config.streamMinChangeChars;
|
|
271
|
+
}
|
|
272
|
+
statusText() {
|
|
273
|
+
return [
|
|
274
|
+
`Codex: ${this.codex.isRunning ? 'running' : 'stopped'}`,
|
|
275
|
+
`tmux: ${this.config.tmuxSession}`,
|
|
276
|
+
`CWD: ${this.config.codexCwd}`,
|
|
277
|
+
`Command: ${[this.config.codexCommand, ...this.config.codexArgs].join(' ')}`,
|
|
278
|
+
`Submit key: ${this.config.codexSubmitKey}`,
|
|
279
|
+
`Submit delay: ${this.config.codexSubmitDelayMs}ms`,
|
|
280
|
+
`Buffered chars: ${this.outputBuffer.length}`,
|
|
281
|
+
`Last output: ${this.lastOutputAt?.toISOString() ?? 'none'}`,
|
|
282
|
+
].join('\n');
|
|
283
|
+
}
|
|
284
|
+
helpText() {
|
|
285
|
+
return [
|
|
286
|
+
'Telegram ↔ Codex bridge commands:',
|
|
287
|
+
'/status - show bridge status',
|
|
288
|
+
'/flush - send buffered Codex output now',
|
|
289
|
+
'/interrupt - send Ctrl-C to Codex',
|
|
290
|
+
'/restart - restart Codex session',
|
|
291
|
+
'/stop - stop Codex session',
|
|
292
|
+
'',
|
|
293
|
+
'Any other text is sent directly to the Codex CLI.',
|
|
294
|
+
].join('\n');
|
|
295
|
+
}
|
|
296
|
+
resetSnapshots() {
|
|
297
|
+
this.lastPaneResponse = '';
|
|
298
|
+
this.lastSentResponse = '';
|
|
299
|
+
this.lastSnapshotSentAt = 0;
|
|
300
|
+
this.lastOutputAt = null;
|
|
301
|
+
this.streamMessageId = null;
|
|
302
|
+
this.lastStreamText = '';
|
|
303
|
+
this.lastStreamEditAt = 0;
|
|
304
|
+
this.stopTypingIndicator();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async function retryTelegramOperation(operation) {
|
|
308
|
+
try {
|
|
309
|
+
return await operation();
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
const retryAfter = telegramRetryAfterMs(error);
|
|
313
|
+
if (retryAfter === null) {
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
await sleep(retryAfter);
|
|
317
|
+
return operation();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function telegramRetryAfterMs(error) {
|
|
321
|
+
const parameters = error.parameters;
|
|
322
|
+
if (typeof parameters?.retry_after === 'number') {
|
|
323
|
+
return (parameters.retry_after + 1) * 1000;
|
|
324
|
+
}
|
|
325
|
+
const description = error.description;
|
|
326
|
+
const match = description?.match(/retry after (\d+)/i);
|
|
327
|
+
if (match) {
|
|
328
|
+
return (Number(match[1]) + 1) * 1000;
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
function sleep(ms) {
|
|
333
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
334
|
+
}
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import stripAnsi from 'strip-ansi';
|
|
2
|
+
export function normalizeTerminalOutput(data) {
|
|
3
|
+
return stripAnsi(data)
|
|
4
|
+
.replace(/\r\n/g, '\n')
|
|
5
|
+
.replace(/\r/g, '\n')
|
|
6
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
|
|
7
|
+
}
|
|
8
|
+
function visibleText(data) {
|
|
9
|
+
return normalizeTerminalOutput(data);
|
|
10
|
+
}
|
|
11
|
+
export function cleanPaneSnapshot(data) {
|
|
12
|
+
const lines = paneLines(data);
|
|
13
|
+
return collapseRepeatedLines(lines).join('\n').trim();
|
|
14
|
+
}
|
|
15
|
+
export function latestCompletedCodexResponse(data) {
|
|
16
|
+
const lines = paneLines(data);
|
|
17
|
+
if (isCodexWorking(data)) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return latestCodexResponse(data);
|
|
21
|
+
}
|
|
22
|
+
export function latestCodexResponse(data) {
|
|
23
|
+
const lines = paneLines(data);
|
|
24
|
+
const lastResponseBullet = findLastIndex(lines, (line) => {
|
|
25
|
+
const trimmed = visibleText(line).trimStart();
|
|
26
|
+
return trimmed.startsWith('• ') && !/^•\s*Working\s*\(/i.test(trimmed);
|
|
27
|
+
});
|
|
28
|
+
if (lastResponseBullet === -1) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const promptStart = findLastIndex(lines.slice(0, lastResponseBullet), (line) => isUserPromptLine(visibleText(line).trim()));
|
|
32
|
+
const responseStart = promptStart === -1 ? lastResponseBullet : promptStart + 1;
|
|
33
|
+
const response = [];
|
|
34
|
+
for (const line of lines.slice(responseStart)) {
|
|
35
|
+
const trimmed = visibleText(line).trim();
|
|
36
|
+
if (response.length > 0 && isPromptOrStatusLine(trimmed)) {
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
if (isIgnorableResponseLine(trimmed)) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
response.push(line);
|
|
43
|
+
}
|
|
44
|
+
const cleaned = collapseRepeatedLines(response).join('\n').trim();
|
|
45
|
+
return cleaned || null;
|
|
46
|
+
}
|
|
47
|
+
export function isCodexWorking(data) {
|
|
48
|
+
return paneLines(data).some((line) => /esc to interrupt|^\s*•\s*Working\s*\(/i.test(visibleText(line)));
|
|
49
|
+
}
|
|
50
|
+
export function chunkText(text, maxLength) {
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let remaining = text;
|
|
53
|
+
while (remaining.length > maxLength) {
|
|
54
|
+
const newlineIndex = remaining.lastIndexOf('\n', maxLength);
|
|
55
|
+
const splitIndex = newlineIndex > maxLength * 0.5 ? newlineIndex : maxLength;
|
|
56
|
+
chunks.push(remaining.slice(0, splitIndex));
|
|
57
|
+
remaining = remaining.slice(splitIndex).trimStart();
|
|
58
|
+
}
|
|
59
|
+
if (remaining) {
|
|
60
|
+
chunks.push(remaining);
|
|
61
|
+
}
|
|
62
|
+
return chunks;
|
|
63
|
+
}
|
|
64
|
+
export function wrapCodeBlock(text) {
|
|
65
|
+
return `\`\`\`text\n${escapeMarkdownV2(text)}\n\`\`\``;
|
|
66
|
+
}
|
|
67
|
+
export function formatTelegramMarkdown(text) {
|
|
68
|
+
return formatTelegramMarkdownLines(text)
|
|
69
|
+
.join('\n\n')
|
|
70
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
71
|
+
.trim();
|
|
72
|
+
}
|
|
73
|
+
export function formatTelegramMarkdownLines(text) {
|
|
74
|
+
return normalizeWrappedLines(text)
|
|
75
|
+
.split('\n')
|
|
76
|
+
.map(formatTelegramLine)
|
|
77
|
+
.filter((line) => line.trim().length > 0);
|
|
78
|
+
}
|
|
79
|
+
export function plainTelegramText(text) {
|
|
80
|
+
return plainTelegramLines(text)
|
|
81
|
+
.join('\n\n')
|
|
82
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
83
|
+
.trim();
|
|
84
|
+
}
|
|
85
|
+
export function plainTelegramLines(text) {
|
|
86
|
+
return normalizeWrappedLines(text)
|
|
87
|
+
.split('\n')
|
|
88
|
+
.map((line) => line.trimEnd())
|
|
89
|
+
.filter((line) => line.trim().length > 0);
|
|
90
|
+
}
|
|
91
|
+
function isDecorativeLine(line) {
|
|
92
|
+
const trimmed = visibleText(line).trim();
|
|
93
|
+
if (/^[╭╮╰╯│─┌┐└┘├┤┬┴┼╞╡═\s]+$/.test(trimmed)) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (/^Tip: /.test(trimmed)) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
if (/^model:\s+/.test(trimmed) || /^directory:\s+/.test(trimmed)) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
if (/^>_ OpenAI Codex/.test(trimmed)) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
function paneLines(data) {
|
|
108
|
+
return normalizeTerminalOutput(data)
|
|
109
|
+
.split('\n')
|
|
110
|
+
.map((line) => line.trimEnd())
|
|
111
|
+
.filter((line) => visibleText(line).trim().length > 0)
|
|
112
|
+
.filter((line) => !isDecorativeLine(line))
|
|
113
|
+
.filter((line) => !isIgnorablePaneLine(visibleText(line).trim()));
|
|
114
|
+
}
|
|
115
|
+
function isPromptOrStatusLine(trimmed) {
|
|
116
|
+
return isUserPromptLine(trimmed)
|
|
117
|
+
|| /^agentic\s+/.test(trimmed)
|
|
118
|
+
|| /^•\s*Working\s*\(/i.test(trimmed)
|
|
119
|
+
|| /^─\s*Worked for\b/.test(trimmed);
|
|
120
|
+
}
|
|
121
|
+
function isUserPromptLine(trimmed) {
|
|
122
|
+
return trimmed.startsWith('› ');
|
|
123
|
+
}
|
|
124
|
+
function isIgnorablePaneLine(trimmed) {
|
|
125
|
+
return /^⚠ Model metadata for `agentic` not found/.test(trimmed)
|
|
126
|
+
|| /^can degrade performance and cause issues\.$/.test(trimmed)
|
|
127
|
+
|| /^fallback metadata;/.test(trimmed)
|
|
128
|
+
|| /^issues\.$/.test(trimmed)
|
|
129
|
+
|| /^─\s*Worked for\b/.test(trimmed)
|
|
130
|
+
|| /^agentic\s+/.test(trimmed)
|
|
131
|
+
|| /^› (Write tests for @filename|Summarize recent commits|Implement \{feature\}|Improve documentation in @filename)/.test(trimmed);
|
|
132
|
+
}
|
|
133
|
+
function isIgnorableResponseLine(trimmed) {
|
|
134
|
+
return isIgnorablePaneLine(trimmed) || /^⚠ /.test(trimmed);
|
|
135
|
+
}
|
|
136
|
+
function normalizeWrappedLines(text) {
|
|
137
|
+
const lines = text.split('\n');
|
|
138
|
+
const normalized = [];
|
|
139
|
+
for (const line of lines) {
|
|
140
|
+
const current = line.trimEnd();
|
|
141
|
+
const previous = normalized[normalized.length - 1];
|
|
142
|
+
if (previous && shouldJoinSoftWrap(previous, current)) {
|
|
143
|
+
const separator = previous.trimEnd().endsWith('-') ? '' : ' ';
|
|
144
|
+
normalized[normalized.length - 1] = `${previous.replace(/\s+$/, '')}${separator}${current.trimStart()}`;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
normalized.push(current);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return normalized.join('\n');
|
|
151
|
+
}
|
|
152
|
+
function shouldJoinSoftWrap(previous, current) {
|
|
153
|
+
const trimmedPrevious = visibleText(previous).trim();
|
|
154
|
+
const trimmedCurrent = visibleText(current).trim();
|
|
155
|
+
if (!trimmedCurrent) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
if (/^(•|-|└|↳|›|```)/.test(trimmedCurrent)) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
if (/^(└|↳)/.test(trimmedPrevious)) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
if (/^\//.test(trimmedCurrent)) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
if (/^(•|-|└|↳|›)/.test(trimmedPrevious) && !/[.!?:;)]$/.test(trimmedPrevious)) {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
if (/\b(and|or|with|to|from|by|for|including|containing|credential-)$/i.test(trimmedPrevious)) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
if (trimmedPrevious.endsWith('-')) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
return !/[.!?:;)]$/.test(trimmedPrevious) && /^[a-z0-9(/]/i.test(trimmedCurrent);
|
|
177
|
+
}
|
|
178
|
+
function formatTelegramLine(line) {
|
|
179
|
+
const style = lineStyle(line);
|
|
180
|
+
const trimmed = visibleText(line).trim();
|
|
181
|
+
if (!trimmed) {
|
|
182
|
+
return '';
|
|
183
|
+
}
|
|
184
|
+
if (style.hasBold && /(?:^•\s+)?Ran\b/.test(trimmed)) {
|
|
185
|
+
const command = trimmed.replace(/^•\s+Ran\s+/, '').replace(/^Ran\s+/, '');
|
|
186
|
+
return blockQuote(`🔧 *Ran* ${inlineCode(command)}`);
|
|
187
|
+
}
|
|
188
|
+
if (trimmed.startsWith('• Ran ')) {
|
|
189
|
+
return blockQuote(`🔧 *Ran* ${inlineCode(trimmed.slice('• Ran '.length))}`);
|
|
190
|
+
}
|
|
191
|
+
if (style.hasBold && /background terminal/i.test(trimmed)) {
|
|
192
|
+
return blockQuote(`🔧 _${escapeMarkdownV2(trimmed.replace(/^•\s+/, ''))}_`);
|
|
193
|
+
}
|
|
194
|
+
if (trimmed.startsWith('• Waited for background terminal')) {
|
|
195
|
+
return blockQuote(`🔧 _${escapeMarkdownV2(trimmed.slice(2))}_`);
|
|
196
|
+
}
|
|
197
|
+
if (style.hasDim && isThinkingLine(trimmed)) {
|
|
198
|
+
return blockQuote(`🧠 _${escapeMarkdownV2(trimmed.slice(2))}_`);
|
|
199
|
+
}
|
|
200
|
+
if (trimmed.startsWith('↳ Interacted with background terminal')) {
|
|
201
|
+
return blockQuote(`🔧 _${escapeMarkdownV2(trimmed)}_`);
|
|
202
|
+
}
|
|
203
|
+
if (trimmed.startsWith('└')) {
|
|
204
|
+
return blockQuote(` _${escapeMarkdownV2(trimmed)}_`);
|
|
205
|
+
}
|
|
206
|
+
if (trimmed.startsWith('• Explored')) {
|
|
207
|
+
return '*Explored*';
|
|
208
|
+
}
|
|
209
|
+
if (trimmed.startsWith('• ')) {
|
|
210
|
+
return `• ${escapeMarkdownV2(trimmed.slice(2))}`;
|
|
211
|
+
}
|
|
212
|
+
if (trimmed.startsWith('- ')) {
|
|
213
|
+
return ` ◦ ${escapeMarkdownV2(trimmed.slice(2))}`;
|
|
214
|
+
}
|
|
215
|
+
if (/^\s{2,}\S/.test(visibleText(line))) {
|
|
216
|
+
return ` ${escapeMarkdownV2(trimmed)}`;
|
|
217
|
+
}
|
|
218
|
+
return escapeMarkdownV2(trimmed);
|
|
219
|
+
}
|
|
220
|
+
function inlineCode(text) {
|
|
221
|
+
return `\`${text.replace(/[`\\]/g, '\\$&')}\``;
|
|
222
|
+
}
|
|
223
|
+
function lineStyle(line) {
|
|
224
|
+
const sgrMatches = line.match(/\x1b\[[0-9;]*m/g) ?? [];
|
|
225
|
+
let hasDim = false;
|
|
226
|
+
let hasBold = false;
|
|
227
|
+
for (const match of sgrMatches) {
|
|
228
|
+
const codes = match.slice(2, -1).split(';').map((code) => Number(code || 0));
|
|
229
|
+
if (codes.includes(0)) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (codes.includes(1)) {
|
|
233
|
+
hasBold = true;
|
|
234
|
+
}
|
|
235
|
+
if (codes.includes(2)) {
|
|
236
|
+
hasDim = true;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { hasDim, hasBold };
|
|
240
|
+
}
|
|
241
|
+
function blockQuote(markdown) {
|
|
242
|
+
return markdown
|
|
243
|
+
.split('\n')
|
|
244
|
+
.map((line) => `> ${line}`)
|
|
245
|
+
.join('\n');
|
|
246
|
+
}
|
|
247
|
+
function isThinkingLine(trimmed) {
|
|
248
|
+
return /^•\s+I(?:’|'|`)?m\s+(thinking|considering|checking|looking|trying|wondering|deciding|figuring|reasoning|planning)\b/i.test(trimmed)
|
|
249
|
+
|| /^•\s+I\s+need\s+to\s+think\b/i.test(trimmed)
|
|
250
|
+
|| /^•\s+I\s+need\s+to\s+(provide|answer|decide|figure|check|inspect|verify)\b/i.test(trimmed)
|
|
251
|
+
|| /^•\s+Let\s+me\s+think\b/i.test(trimmed);
|
|
252
|
+
}
|
|
253
|
+
function findLastIndex(items, predicate) {
|
|
254
|
+
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
255
|
+
if (predicate(items[index])) {
|
|
256
|
+
return index;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return -1;
|
|
260
|
+
}
|
|
261
|
+
function collapseRepeatedLines(lines) {
|
|
262
|
+
const collapsed = [];
|
|
263
|
+
for (const line of lines) {
|
|
264
|
+
if (collapsed[collapsed.length - 1] !== line) {
|
|
265
|
+
collapsed.push(line);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return collapsed;
|
|
269
|
+
}
|
|
270
|
+
function escapeMarkdownV2(text) {
|
|
271
|
+
return text.replace(/([_*.\[\]()~`>#+\-=|{}!\\])/g, '\\$1');
|
|
272
|
+
}
|
package/dist/tmux.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
export async function runTmux(args) {
|
|
5
|
+
const { stdout } = await execFileAsync('tmux', args, { env: process.env });
|
|
6
|
+
return stdout;
|
|
7
|
+
}
|
|
8
|
+
export async function tmuxSessionExists(session) {
|
|
9
|
+
try {
|
|
10
|
+
await runTmux(['has-session', '-t', session]);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function capturePane(session, rows) {
|
|
18
|
+
return runTmux(['capture-pane', '-p', '-t', session, '-S', `-${rows}`]);
|
|
19
|
+
}
|
|
20
|
+
export function shellCommand(parts) {
|
|
21
|
+
return parts.map(shellQuote).join(' ');
|
|
22
|
+
}
|
|
23
|
+
function shellQuote(value) {
|
|
24
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
25
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lapage/codex-telegram-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Private Telegram bridge for controlling Codex CLI on a home PC or server without opening inbound ports.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"codex-telegram-bridge": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
".env.example"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/zhuylanz/lapage-codex-telegram-bridge.git"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/zhuylanz/lapage-codex-telegram-bridge#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/zhuylanz/lapage-codex-telegram-bridge/issues"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"codex",
|
|
24
|
+
"codex-cli",
|
|
25
|
+
"telegram",
|
|
26
|
+
"telegram-bot",
|
|
27
|
+
"tmux",
|
|
28
|
+
"remote-control",
|
|
29
|
+
"agent"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"scripts": {
|
|
33
|
+
"dev": "tsx src/index.ts",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"build": "tsc",
|
|
36
|
+
"start": "node dist/index.js",
|
|
37
|
+
"prepublishOnly": "npm run typecheck && npm run build"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"dotenv": "^16.4.7",
|
|
41
|
+
"grammy": "^1.44.0",
|
|
42
|
+
"strip-ansi": "^7.1.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^22.20.0",
|
|
46
|
+
"tsx": "^4.19.2",
|
|
47
|
+
"typescript": "^5.7.2"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=22"
|
|
51
|
+
}
|
|
52
|
+
}
|