claude-code-telegram-gateway 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.
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env bash
2
+ # Install the Claude Code Telegram gateway as a macOS launchd service (auto-start + auto-restart).
3
+ set -euo pipefail
4
+
5
+ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ LABEL="com.claude.telegram-gateway"
7
+ PLIST_SRC="$DIR/$LABEL.plist"
8
+ PLIST_DST="$HOME/Library/LaunchAgents/$LABEL.plist"
9
+
10
+ NODE_BIN="$(command -v node || true)"
11
+ if [[ -z "$NODE_BIN" ]]; then echo "❌ node not found on PATH. Install Node or fix PATH, then retry." >&2; exit 1; fi
12
+ NODE_DIR="$(dirname "$NODE_BIN")"
13
+
14
+ mkdir -p "$HOME/Library/LaunchAgents"
15
+
16
+ # Fill placeholders. Use | as sed delimiter since paths contain /.
17
+ sed -e "s|__NODE__|$NODE_BIN|g" \
18
+ -e "s|__DIR__|$DIR|g" \
19
+ -e "s|__HOME__|$HOME|g" \
20
+ -e "s|__PATH__|$NODE_DIR:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin|g" \
21
+ "$PLIST_SRC" > "$PLIST_DST"
22
+
23
+ # (Re)load the service. bootout is async, so retry bootstrap a few times to dodge the
24
+ # "Input/output error (5)" race when the old instance hasn't fully torn down yet.
25
+ launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
26
+ booted=0
27
+ for _ in 1 2 3 4 5; do
28
+ sleep 1
29
+ if launchctl bootstrap "gui/$(id -u)" "$PLIST_DST" 2>/dev/null; then booted=1; break; fi
30
+ done
31
+ if [ "$booted" != 1 ]; then
32
+ echo "⚠️ launchctl bootstrap failed. Try: launchctl bootout gui/$(id -u)/$LABEL ; ./install-service.sh" >&2
33
+ fi
34
+ launchctl enable "gui/$(id -u)/$LABEL" 2>/dev/null || true
35
+ launchctl kickstart "gui/$(id -u)/$LABEL" 2>/dev/null || true
36
+
37
+ # Shell integration: auto-resume the branch you were working on from your phone, so opening a
38
+ # terminal drops you straight back in β€” no `cr` needed (multi-repo aware). `cr` stays as a manual
39
+ # fallback. The block is idempotent and marked so uninstall can strip it.
40
+ RC="$HOME/.zshrc"
41
+ if ! grep -qF 'claude-gateway auto-resume' "$RC" 2>/dev/null; then
42
+ cat >> "$RC" <<HOOK
43
+
44
+ # >>> claude-gateway auto-resume >>>
45
+ # On an interactive shell, resume any branch you just drove from your phone, then clear the marker.
46
+ _claude_gateway_resume() {
47
+ local out; out="\$(node "$DIR/resume-hook.js" 2>/dev/null)" || return
48
+ [ -z "\$out" ] && return
49
+ local repo="\${out%%\$'\t'*}" sid="\${out##*\$'\t'}"
50
+ [ -d "\$repo" ] && cd "\$repo" && command claude --resume "\$sid"
51
+ }
52
+ alias cr='node "$DIR/resume-hook.js" >/dev/null 2>&1; claude -c' # manual: resume most recent here
53
+ [[ -o interactive ]] && _claude_gateway_resume
54
+ # <<< claude-gateway auto-resume <<<
55
+ HOOK
56
+ echo "πŸ”— Added auto-resume hook to $RC (open a new terminal to use it)."
57
+ fi
58
+
59
+ echo "βœ… Installed and started $LABEL."
60
+ echo " Logs: tail -f \"$DIR/gateway.log\""
61
+ echo " Status: launchctl print gui/$(id -u)/$LABEL | grep -i state"
62
+ echo " Stop: ./uninstall-service.sh"
63
+ echo
64
+ echo "⚠️ For auto-topics: make the bot a group Admin with the 'Manage Topics' permission."
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "claude-code-telegram-gateway",
3
+ "version": "1.0.0",
4
+ "description": "Steer and mirror Claude Code sessions from your phone via Telegram β€” auto-created Forum Topics, live desk↔phone mirroring, launchd service.",
5
+ "main": "gateway.js",
6
+ "bin": {
7
+ "claude-tg": "bin/claude-tg.js"
8
+ },
9
+ "scripts": {
10
+ "setup": "node setup.js",
11
+ "start": "node gateway.js",
12
+ "check": "node test/check-telegram.js",
13
+ "install-service": "./install-service.sh",
14
+ "uninstall-service": "./uninstall-service.sh",
15
+ "test": "node --test test/*.test.js"
16
+ },
17
+ "keywords": [
18
+ "claude-code",
19
+ "claude",
20
+ "anthropic",
21
+ "telegram",
22
+ "telegram-bot",
23
+ "gateway",
24
+ "macos",
25
+ "launchd"
26
+ ],
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "files": [
31
+ "gateway.js",
32
+ "setup.js",
33
+ "resume-hook.js",
34
+ "bin/",
35
+ "test/",
36
+ "install-service.sh",
37
+ "uninstall-service.sh",
38
+ "com.claude.telegram-gateway.plist",
39
+ "systemd/claude-gateway.service",
40
+ "config.example.json",
41
+ "README.md",
42
+ "SETUP.md",
43
+ "LICENSE"
44
+ ],
45
+ "dependencies": {},
46
+ "author": "hacctarr",
47
+ "license": "MIT",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/hacctarr/claude-code-telegram-gateway.git"
51
+ },
52
+ "homepage": "https://github.com/hacctarr/claude-code-telegram-gateway#readme",
53
+ "bugs": {
54
+ "url": "https://github.com/hacctarr/claude-code-telegram-gateway/issues"
55
+ }
56
+ }
package/resume-hook.js ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Prints "<repoDir>\t<sessionId>" for the newest phone-driven branch and consumes it, so a shell
4
+ // hook can auto-resume it. Prints nothing when there's nothing pending. Multi-repo aware.
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const MARKER = path.join(process.env.HOME, '.claude-gateway', 'resume.json');
8
+ try {
9
+ const m = JSON.parse(fs.readFileSync(MARKER, 'utf8'));
10
+ const entries = Object.entries(m).sort((a, b) => (b[1].ts || 0) - (a[1].ts || 0));
11
+ if (!entries.length) process.exit(0);
12
+ const [repo, info] = entries[0];
13
+ process.stdout.write(`${repo}\t${info.sessionId}`);
14
+ delete m[repo];
15
+ if (Object.keys(m).length) fs.writeFileSync(MARKER, JSON.stringify(m, null, 2));
16
+ else fs.unlinkSync(MARKER);
17
+ } catch (e) { /* no marker β†’ nothing to resume */ }
package/setup.js ADDED
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Interactive setup: validates the bot, auto-detects your claude path, auto-grabs your user id and
4
+ // group chat id from Telegram, and writes config.json. No dependencies. Run: npm run setup
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const https = require('https');
8
+ const readline = require('readline');
9
+ const { execSync } = require('child_process');
10
+
11
+ const CONFIG = path.join(__dirname, 'config.json');
12
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
13
+ const ask = (q) => new Promise((res) => rl.question(q, (a) => res(a.trim())));
14
+ const yes = (s) => /^y/i.test(s);
15
+
16
+ function tg(token, method, payload = {}) {
17
+ return new Promise((resolve) => {
18
+ const data = JSON.stringify(payload);
19
+ const req = https.request({
20
+ hostname: 'api.telegram.org', path: `/bot${token}/${method}`, method: 'POST',
21
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
22
+ }, (r) => { let b = ''; r.on('data', (c) => (b += c)); r.on('end', () => { try { resolve(JSON.parse(b)); } catch (e) { resolve({ ok: false }); } }); });
23
+ req.on('error', () => resolve({ ok: false })); req.write(data); req.end();
24
+ });
25
+ }
26
+ const expand = (p) => p.replace(/^~(?=\/|$)/, process.env.HOME);
27
+
28
+ (async () => {
29
+ console.log('\nπŸ€– Claude Code Telegram Gateway β€” setup\n');
30
+
31
+ let cfg = {};
32
+ if (fs.existsSync(CONFIG)) {
33
+ if (!yes(await ask('config.json already exists. Update it? [y/N] '))) { console.log('Aborted.'); return rl.close(); }
34
+ try { cfg = JSON.parse(fs.readFileSync(CONFIG, 'utf8')); } catch (e) { /* */ }
35
+ }
36
+
37
+ // 1) Bot token (validated via getMe)
38
+ let token = cfg.BOT_TOKEN, me = null;
39
+ for (;;) {
40
+ const entered = await ask(`Bot token from @BotFather${token ? ` [keep ${token.slice(0, 8)}…]` : ''}: `);
41
+ token = entered || token;
42
+ if (!token) { console.log(' A token is required.'); continue; }
43
+ process.stdout.write(' validating… ');
44
+ me = await tg(token, 'getMe');
45
+ if (me.ok) { console.log(`βœ… @${me.result.username}`); break; }
46
+ console.log('❌ invalid token β€” try again.');
47
+ }
48
+ cfg.BOT_TOKEN = token;
49
+ const botId = String(me.result.id);
50
+
51
+ // 2) Claude binary (auto-detected)
52
+ let claudePath = cfg.CLAUDE_PATH;
53
+ if (!claudePath) { try { claudePath = execSync('command -v claude', { shell: '/bin/bash' }).toString().trim(); } catch (e) { /* */ } }
54
+ const cp = await ask(`Path to the claude binary${claudePath ? ` [${claudePath}]` : ''}: `);
55
+ cfg.CLAUDE_PATH = cp || claudePath || 'claude';
56
+
57
+ // 3) Your user id (auto-detected via getUpdates)
58
+ console.log('\nπŸ‘€ Your Telegram user id β€” the ONLY account allowed to control the bot.');
59
+ console.log(' Open a DM with your bot (or post in your group) and send it any message.');
60
+ await ask(' Press Enter once you have sent a message… ');
61
+ let userId = null;
62
+ const up1 = await tg(token, 'getUpdates', { timeout: 0 });
63
+ if (up1.error_code === 409) console.log(' ⚠️ The gateway service seems to be running β€” stop it (./uninstall-service.sh) and re-run setup for auto-detect.');
64
+ const senders = [...new Set((up1.result || []).map((u) => u.message && u.message.from && u.message.from.id).filter(Boolean))].filter((id) => String(id) !== botId);
65
+ if (senders.length === 1) { userId = String(senders[0]); console.log(` βœ… detected user id ${userId}`); }
66
+ else if (senders.length > 1) { console.log(' Multiple senders:', senders.join(', ')); userId = await ask(' Which is yours? '); }
67
+ if (!userId) userId = await ask(' Enter your numeric user id (get it from @userinfobot): ');
68
+ cfg.ALLOWED_USER_IDS = userId ? [String(userId)] : (cfg.ALLOWED_USER_IDS || []);
69
+
70
+ // 4) Group β†’ repo mappings (chat id auto-detected)
71
+ console.log('\nπŸ“ Map each Telegram supergroup to a local repo directory.');
72
+ cfg.REPO_MAPPINGS = cfg.REPO_MAPPINGS || {};
73
+ for (;;) {
74
+ if (!yes(await ask(' Add a group β†’ repo mapping? [Y/n] ') || 'y')) break;
75
+ console.log(' In that group\'s General topic, post any message.');
76
+ await ask(' Press Enter once posted… ');
77
+ const up = await tg(token, 'getUpdates', { timeout: 0 });
78
+ const chats = [...new Map((up.result || [])
79
+ .map((u) => u.message && u.message.chat)
80
+ .filter((c) => c && (c.type === 'supergroup' || c.type === 'group'))
81
+ .map((c) => [String(c.id), c])).values()];
82
+ let chatId;
83
+ if (chats.length) {
84
+ chats.forEach((c, i) => console.log(` [${i + 1}] ${c.id} ${c.title || ''}`));
85
+ const pick = await ask(` Pick [1-${chats.length}] or type a chat id: `);
86
+ chatId = (/^-?\d+$/.test(pick) && pick.length > 3) ? pick : String((chats[(parseInt(pick, 10) || 1) - 1] || chats[0]).id);
87
+ } else {
88
+ chatId = await ask(' Couldn\'t detect a group β€” enter its chat id (-100…): ');
89
+ }
90
+ let dir = expand(await ask(' Local repo directory (absolute path): '));
91
+ if (dir && !fs.existsSync(dir) && !yes(await ask(` ${dir} doesn't exist β€” use anyway? [y/N] `))) continue;
92
+ if (chatId && dir) { cfg.REPO_MAPPINGS[chatId] = dir; console.log(` βœ… ${chatId} β†’ ${dir}`); }
93
+ }
94
+
95
+ fs.writeFileSync(CONFIG, JSON.stringify(cfg, null, 2) + '\n');
96
+ console.log(`\nβœ… Wrote ${CONFIG}`);
97
+ console.log(' (config.json is gitignored β€” your token stays local.)\n');
98
+
99
+ if (yes(await ask('Run the Telegram permission check now? [Y/n] ') || 'y')) {
100
+ try { execSync(`node ${path.join(__dirname, 'test', 'check-telegram.js')}`, { stdio: 'inherit' }); } catch (e) { /* */ }
101
+ }
102
+ if (yes(await ask('\nInstall as a background service (launchd, auto-start + restart)? [y/N] '))) {
103
+ try { execSync(path.join(__dirname, 'install-service.sh'), { stdio: 'inherit' }); } catch (e) { /* */ }
104
+ } else {
105
+ console.log('\nStart it anytime: npm start (foreground) Β· ./install-service.sh (service)');
106
+ }
107
+ rl.close();
108
+ })().catch((e) => { console.error('setup error:', e.message); rl.close(); process.exit(1); });
@@ -0,0 +1,23 @@
1
+ # Linux (systemd --user) unit for the Claude Code Telegram gateway.
2
+ # Install:
3
+ # mkdir -p ~/.config/systemd/user
4
+ # sed "s#__DIR__#$(pwd)#; s#__NODE__#$(command -v node)#" systemd/claude-gateway.service \
5
+ # > ~/.config/systemd/user/claude-gateway.service
6
+ # systemctl --user daemon-reload
7
+ # systemctl --user enable --now claude-gateway
8
+ # loginctl enable-linger "$USER" # keep it running after you log out
9
+ # Logs: journalctl --user -u claude-gateway -f
10
+ [Unit]
11
+ Description=Claude Code Telegram gateway
12
+ After=network-online.target
13
+ Wants=network-online.target
14
+
15
+ [Service]
16
+ Type=simple
17
+ WorkingDirectory=__DIR__
18
+ ExecStart=__NODE__ __DIR__/gateway.js
19
+ Restart=on-failure
20
+ RestartSec=10
21
+
22
+ [Install]
23
+ WantedBy=default.target
@@ -0,0 +1,85 @@
1
+ # Manual verification runbook
2
+
3
+ Unit tests (`npm test`) cover the pure logic. These steps cover what they can't: the real Telegram
4
+ API and the interaction between headless injection and the live desk TUI. Do them once before
5
+ trusting the gateway in daily use.
6
+
7
+ ---
8
+
9
+ ## Test A β€” Injection vs. an open TUI (the critical one, gap #1)
10
+
11
+ **Why:** phone→desk injection runs `claude -p --resume <id>` when the transcript looks idle. If the
12
+ native TUI is holding that session, we need to know whether that injection is safe (clean append),
13
+ locks/errors, or forks the transcript.
14
+
15
+ 1. **Terminal 1 (the "desk"):**
16
+ ```bash
17
+ cd <your mapped repo, e.g. ~/Documents>
18
+ claude
19
+ ```
20
+ Send one message, e.g. `Remember the codeword: BANANA.` Wait for the reply, then **leave the TUI
21
+ open and idle** (don't quit).
22
+
23
+ 2. **Terminal 2 (the "phone", simulated):**
24
+ ```bash
25
+ cd ~/telegram_gateway
26
+ ./test/inject-probe.sh
27
+ ```
28
+ It auto-picks the most recent session (your Terminal-1 one) and injects a headless turn.
29
+
30
+ 3. **Read the probe output:**
31
+ - `exit_ok: true` and `result: "INJECTED_OK"` β†’ headless resume succeeded even with the TUI open.
32
+ - `same_session_id` equals the session id β†’ no new session was minted.
33
+ - `appended to the SAME file: yes` and `New sibling session files: 0` β†’ **clean append, no fork.** βœ…
34
+ - Any `non-JSON output (possible lock/conflict)`, a new sibling file, or a changed session id β†’
35
+ **injection conflicts with the open TUI.** ❗ In that case we should tighten the idle-gate to
36
+ also refuse injection whenever the session is the *most recently active* one (assume TUI owns it),
37
+ or require the desk session be closed. Tell me the result and I'll adjust.
38
+
39
+ 4. **Back in Terminal 1:** send another message (e.g. `What was the codeword?`). Confirm the TUI still
40
+ responds coherently and didn't break. Note whether it "sees" the injected turn (it generally
41
+ won't until you `cr`/reload β€” that's expected).
42
+
43
+ 5. **Cleanup:** quit the TUI. Run `cr` (or `claude -c`) in the repo and confirm the session loads with
44
+ both the desk turns and the injected `INJECTED_OK` turn present.
45
+
46
+ ---
47
+
48
+ ## Test B β€” Telegram permissions & topic lifecycle (gap #2)
49
+
50
+ **Why:** auto-topics need the bot to be an admin with **Manage Topics**. This verifies it for real and
51
+ leaves no junk behind (it deletes its own self-test topic).
52
+
53
+ ```bash
54
+ cd ~/telegram_gateway
55
+ node test/check-telegram.js
56
+ ```
57
+ Expect `βœ… All checks passed`. If it reports "not an admin" or "can_manage_topics: false", fix the
58
+ bot's rights in the group (Admin β†’ Manage Topics) and re-run.
59
+
60
+ ---
61
+
62
+ ## Test C β€” End-to-end mirror + auto-initiate
63
+
64
+ 1. Start the gateway (`npm start`, or `./install-service.sh` then `tail -f gateway.log`).
65
+ 2. In the desk TUI, open/continue a session in the mapped repo and send a message.
66
+ 3. Within ~30 min of activity (default `ACTIVE_WINDOW_MIN`) a **new topic** should appear in the
67
+ supergroup with an opener, and within ~2s (`POLL_MS`) your exchange should mirror in as
68
+ `πŸ–₯️ desk:` / assistant text / `πŸ”§ tool` lines.
69
+ 4. From the phone, reply in that topic while the desk session is **idle** β†’ the turn runs and streams
70
+ back once (no duplicate from the mirror).
71
+ 5. From the phone, `/new draft a haiku` β†’ a brand-new topic + independent session appears.
72
+ 6. **Prune:** temporarily set `PRUNE_AFTER_DAYS` very low (e.g. via a quick config edit + restart) and
73
+ confirm an idle session's topic gets closed; revive the session and confirm it reopens.
74
+
75
+ ---
76
+
77
+ ## Quick reference
78
+
79
+ | Check | Command |
80
+ |---|---|
81
+ | Unit tests | `npm test` |
82
+ | Telegram perms | `node test/check-telegram.js` |
83
+ | Injection safety | `./test/inject-probe.sh [session-uuid]` |
84
+ | Live logs (service) | `tail -f gateway.log` |
85
+ | Service state | `launchctl print gui/$(id -u)/com.claude.telegram-gateway \| grep -i state` |
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Live check that the bot can do what the gateway needs: identify itself, create a forum topic,
4
+ // post into it, and delete it. Verifies the "admin + Manage Topics" requirement without guessing.
5
+ // Run: node test/check-telegram.js
6
+ const https = require('https');
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'config.json'), 'utf8'));
11
+ const { BOT_TOKEN, REPO_MAPPINGS } = cfg;
12
+
13
+ function tg(method, payload) {
14
+ return new Promise((resolve, reject) => {
15
+ const data = JSON.stringify(payload || {});
16
+ const req = https.request({
17
+ hostname: 'api.telegram.org', port: 443, path: `/bot${BOT_TOKEN}/${method}`, method: 'POST',
18
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
19
+ }, (res) => { let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => { try { resolve(JSON.parse(b)); } catch (e) { reject(e); } }); });
20
+ req.on('error', reject); req.write(data); req.end();
21
+ });
22
+ }
23
+
24
+ (async () => {
25
+ const me = await tg('getMe');
26
+ if (!me.ok) { console.error('❌ getMe failed β€” check BOT_TOKEN.', me); process.exit(1); }
27
+ console.log(`βœ… Bot: @${me.result.username} (id ${me.result.id})`);
28
+
29
+ let allGood = true;
30
+ for (const chatId of Object.keys(REPO_MAPPINGS)) {
31
+ process.stdout.write(`\nβ€” chat ${chatId} (${REPO_MAPPINGS[chatId]})\n`);
32
+
33
+ const chat = await tg('getChat', { chat_id: chatId });
34
+ if (!chat.ok) { console.error(` ❌ getChat failed: ${chat.description}`); allGood = false; continue; }
35
+ console.log(` chat type: ${chat.result.type}, forum: ${chat.result.is_forum === true}`);
36
+ if (!chat.result.is_forum) { console.error(' ❌ Topics/Forum not enabled on this group.'); allGood = false; }
37
+
38
+ const admin = await tg('getChatMember', { chat_id: chatId, user_id: me.result.id });
39
+ const status = admin.ok ? admin.result.status : 'unknown';
40
+ const canManage = admin.ok && (admin.result.can_manage_topics === true || status === 'creator');
41
+ console.log(` bot status: ${status}, can_manage_topics: ${admin.ok ? admin.result.can_manage_topics : '?'}`);
42
+ if (status !== 'administrator' && status !== 'creator') { console.error(' ❌ Bot is not an admin.'); allGood = false; continue; }
43
+
44
+ const created = await tg('createForumTopic', { chat_id: chatId, name: 'πŸ”§ gateway self-test' });
45
+ if (!created.ok) { console.error(` ❌ createForumTopic failed: ${created.description}`); allGood = false; continue; }
46
+ const threadId = created.result.message_thread_id;
47
+ console.log(` βœ… created topic ${threadId}`);
48
+ await tg('sendMessage', { chat_id: chatId, message_thread_id: threadId, text: 'Self-test OK β€” deleting this topic.' });
49
+ const del = await tg('deleteForumTopic', { chat_id: chatId, message_thread_id: threadId });
50
+ console.log(del.ok ? ' βœ… posted + deleted self-test topic (cleanup done)' : ` ⚠️ could not delete self-test topic: ${del.description}`);
51
+ }
52
+
53
+ console.log(`\n${allGood ? 'βœ… All checks passed β€” auto-topics + mirroring will work.' : '❌ Some checks failed β€” fix the above before relying on auto-topics.'}`);
54
+ process.exit(allGood ? 0 : 1);
55
+ })().catch((e) => { console.error('check-telegram error:', e); process.exit(1); });