@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 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
+ }