claude-tg 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +283 -0
- package/bin/claude-tg +261 -0
- package/package.json +41 -0
- package/src/config.js +38 -0
- package/src/daemon.js +1274 -0
- package/src/hooks/notification.js +92 -0
- package/src/hooks/permission-request.js +109 -0
- package/src/setup.js +221 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Himanshu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# claude-tg
|
|
2
|
+
|
|
3
|
+
Control Claude Code from your phone via Telegram — approve permissions, answer interactive questions, reply to idle sessions, and send files.
|
|
4
|
+
|
|
5
|
+
If you run Claude CLI and step away from your machine, it stalls whenever it needs tool permission or asks a question. This bridge sends everything to Telegram so you can keep Claude working remotely.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Claude CLI (any terminal) Daemon (background)
|
|
9
|
+
┌─────────────────────┐ ┌─────────────────────┐
|
|
10
|
+
│ PermissionRequest │──HTTP──> │ Telegram Bot │──> Your Phone
|
|
11
|
+
│ hook (blocking) │<─────────│ HTTP Server (:7483) │<── (Telegram App)
|
|
12
|
+
└─────────────────────┘ └─────────────────────┘
|
|
13
|
+
|
|
14
|
+
Notification hook ──async HTTP──> Daemon ──> Telegram alert
|
|
15
|
+
<── You reply with text
|
|
16
|
+
Daemon ──> Types into terminal via osascript
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Uses Claude Code's native [hooks system](https://docs.anthropic.com/en/docs/claude-code/hooks) — installs into `~/.claude/settings.json` and applies to all Claude instances automatically. No PTY wrappers or hacks.
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- **Remote permission approval** — Allow, Deny, or Always Allow tool calls from Telegram inline buttons
|
|
24
|
+
- **Interactive questions** — When Claude uses `AskUserQuestion`, you see the actual options as Telegram buttons — pick answers, type custom responses, review a summary, then confirm or redo
|
|
25
|
+
- **Rich context** — Each message shows the session number, project name, original task, what Claude was doing, and the exact tool/command
|
|
26
|
+
- **Multi-session support** — Sessions are labeled #1, #2, #3... and persist as long as the terminal is open
|
|
27
|
+
- **Idle notifications** — Get alerted when Claude is waiting for your input, with its last message shown
|
|
28
|
+
- **Reply from Telegram** — Swipe-reply to a notification to send text input to the correct terminal (macOS)
|
|
29
|
+
- **Concurrent session routing** — Reply-to targets a specific session; auto-routes when only one is idle
|
|
30
|
+
- **Send messages & files** — Tell Claude "send this to my telegram" and it sends text, images, videos, documents, audio
|
|
31
|
+
- **Smart file handling** — Images display as photos, videos play inline, audio streams — not just generic document attachments
|
|
32
|
+
- **Graceful fallback** — If the daemon isn't running, hooks exit silently and Claude shows the normal local dialog
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install -g claude-tg
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Setup
|
|
41
|
+
|
|
42
|
+
### 1. Create a Telegram Bot
|
|
43
|
+
|
|
44
|
+
1. Open Telegram and message [@BotFather](https://t.me/botfather)
|
|
45
|
+
2. Send `/newbot` and follow the prompts
|
|
46
|
+
3. Copy the bot token
|
|
47
|
+
|
|
48
|
+
### 2. Run Setup
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
claude-tg setup
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This will:
|
|
55
|
+
- Ask for your bot token (validates it against the Telegram API)
|
|
56
|
+
- Capture your chat ID (send `/start` to your bot when prompted)
|
|
57
|
+
- Install hooks into `~/.claude/settings.json` (merges with existing config)
|
|
58
|
+
- Save config to `~/.claude-telegram-bridge/config.json`
|
|
59
|
+
- Send a test message to your Telegram
|
|
60
|
+
|
|
61
|
+
### 3. Start the Daemon
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
claude-tg daemon start
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
That's it. Use `claude` as normal — permission prompts and questions now go to Telegram.
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
### Permission Requests
|
|
72
|
+
|
|
73
|
+
When Claude needs tool permission, you get a Telegram message:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
📋 #2 my-project
|
|
77
|
+
━━━━━━━━━━━━━━━━━━━━
|
|
78
|
+
📝 Task: implement user authentication with JWT
|
|
79
|
+
💭 Doing: Let me install the jsonwebtoken package...
|
|
80
|
+
|
|
81
|
+
🔧 Bash
|
|
82
|
+
npm install jsonwebtoken
|
|
83
|
+
|
|
84
|
+
[Allow] [Deny] [Always Allow]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- **Allow** — permits this one tool call
|
|
88
|
+
- **Deny** — blocks the tool call
|
|
89
|
+
- **Always Allow** — permits and adds a rule so future calls of this type don't ask
|
|
90
|
+
|
|
91
|
+
### Interactive Questions
|
|
92
|
+
|
|
93
|
+
When Claude asks you questions (via `AskUserQuestion`), you see the actual options as buttons:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
💬 #2 my-project — Claude has a question
|
|
97
|
+
━━━━━━━━━━━━━━━━━━━━
|
|
98
|
+
📝 Task: build a web app
|
|
99
|
+
|
|
100
|
+
❓ [1/2] Which auth approach?
|
|
101
|
+
|
|
102
|
+
• NextAuth.js: Built-in Next.js auth solution
|
|
103
|
+
• Clerk: Managed auth service
|
|
104
|
+
• Supabase Auth: Open-source auth
|
|
105
|
+
|
|
106
|
+
[NextAuth.js] [Clerk]
|
|
107
|
+
[Supabase Auth] [✏️ Custom]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- Tap an option to select it and move to the next question
|
|
111
|
+
- Tap **Custom** to type a free-text answer
|
|
112
|
+
- For multi-select questions, tap multiple options then **Next**
|
|
113
|
+
- After all questions, review your answers and tap **Confirm** or **Redo**
|
|
114
|
+
|
|
115
|
+
Your answers are automatically injected into the terminal via keystrokes.
|
|
116
|
+
|
|
117
|
+
### Idle Notifications
|
|
118
|
+
|
|
119
|
+
When Claude finishes and waits for input:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
⏳ #2 my-project — Claude is idle
|
|
123
|
+
━━━━━━━━━━━━━━━━━━━━
|
|
124
|
+
📝 Task: implement user authentication with JWT
|
|
125
|
+
💬 Claude said:
|
|
126
|
+
I've set up the JWT middleware. What would you like me to work on next?
|
|
127
|
+
|
|
128
|
+
↩️ Reply to this message to send input
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Swipe-reply** to this message in Telegram with your next instruction — it gets typed into the correct terminal and submitted.
|
|
132
|
+
|
|
133
|
+
### Multiple Sessions
|
|
134
|
+
|
|
135
|
+
Each Claude session gets a persistent label (#1, #2, #3...) that lasts as long as the terminal stays open. When multiple sessions are idle and you send a plain message, the bot asks you to reply to a specific notification:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
Multiple sessions are waiting. Reply to a specific notification message to choose:
|
|
139
|
+
|
|
140
|
+
#1 saas-factory
|
|
141
|
+
#3 ml-pipeline
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
If only one session is idle, your text is auto-routed.
|
|
145
|
+
|
|
146
|
+
### Sending Messages & Files
|
|
147
|
+
|
|
148
|
+
Tell Claude "send this to my telegram" or "send this file to my telegram". It uses these commands:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
# Send a text message
|
|
152
|
+
claude-tg send "Here's the summary you asked for..."
|
|
153
|
+
|
|
154
|
+
# Send a file (images, videos, audio, documents)
|
|
155
|
+
claude-tg send-file ./screenshot.png "Latest UI"
|
|
156
|
+
claude-tg send-file ./demo.mp4 "Feature demo"
|
|
157
|
+
claude-tg send-file ./report.pdf "Monthly report"
|
|
158
|
+
|
|
159
|
+
# Pipe content from stdin
|
|
160
|
+
echo "hello" | claude-tg send -
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
These work independently of the daemon — they hit the Telegram API directly.
|
|
164
|
+
|
|
165
|
+
Files are sent using the correct Telegram method based on type:
|
|
166
|
+
| Extension | Sent as |
|
|
167
|
+
|---|---|
|
|
168
|
+
| `.jpg` `.jpeg` `.png` `.webp` | Photo (with preview) |
|
|
169
|
+
| `.mp4` `.mov` `.avi` `.mkv` `.webm` | Video (plays inline) |
|
|
170
|
+
| `.gif` | Animation |
|
|
171
|
+
| `.mp3` `.ogg` `.wav` `.flac` `.m4a` `.aac` | Audio (streams) |
|
|
172
|
+
| Everything else | Document |
|
|
173
|
+
|
|
174
|
+
For long text (>4096 chars), `claude-tg send` automatically sends it as a `.md` document.
|
|
175
|
+
|
|
176
|
+
### Bot Commands
|
|
177
|
+
|
|
178
|
+
- `/status` — list active sessions, pending permissions, and pending questions
|
|
179
|
+
- `/start` — register chat ID (used during setup)
|
|
180
|
+
|
|
181
|
+
## CLI Reference
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
claude-tg setup # Interactive setup
|
|
185
|
+
claude-tg daemon start # Start background daemon
|
|
186
|
+
claude-tg daemon stop # Stop daemon
|
|
187
|
+
claude-tg daemon status # Check daemon status + pending requests
|
|
188
|
+
claude-tg daemon logs # Tail daemon logs
|
|
189
|
+
claude-tg send <text> # Send text message (use "-" for stdin)
|
|
190
|
+
claude-tg send-file <path> # Send a file (optional caption as 2nd arg)
|
|
191
|
+
claude-tg uninstall # Remove hooks from ~/.claude/settings.json
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## How It Works
|
|
195
|
+
|
|
196
|
+
### Hooks
|
|
197
|
+
|
|
198
|
+
Claude Code supports [hooks](https://docs.anthropic.com/en/docs/claude-code/hooks) — shell commands that run in response to lifecycle events. Two hooks are installed:
|
|
199
|
+
|
|
200
|
+
**PermissionRequest** (blocking) — Fires when Claude needs tool permission. The hook:
|
|
201
|
+
1. Reads the hook input from stdin (session ID, tool name, tool input, transcript path)
|
|
202
|
+
2. Detects the parent Claude process's TTY (for reply routing)
|
|
203
|
+
3. POSTs to the local daemon
|
|
204
|
+
4. Blocks until the daemon responds (which waits for your Telegram tap)
|
|
205
|
+
5. Returns the decision as JSON to stdout
|
|
206
|
+
|
|
207
|
+
When `AskUserQuestion` is the tool, the daemon shows the actual questions as Telegram buttons instead of Allow/Deny. After you answer, it allows the tool and injects your selections into the terminal.
|
|
208
|
+
|
|
209
|
+
**Notification** (async) — Fires on `idle_prompt` and `elicitation_dialog`. The hook:
|
|
210
|
+
1. Reads the hook input from stdin
|
|
211
|
+
2. Detects the parent TTY
|
|
212
|
+
3. Fire-and-forget POST to the daemon
|
|
213
|
+
4. Exits immediately
|
|
214
|
+
|
|
215
|
+
### Daemon
|
|
216
|
+
|
|
217
|
+
A single background process on `localhost:7483`. Runs a Telegram bot (via telegraf) and an HTTP server.
|
|
218
|
+
|
|
219
|
+
- `POST /api/permission` — Holds the HTTP connection open until you respond on Telegram
|
|
220
|
+
- `POST /api/notify` — Sends an alert and stores the session for reply routing
|
|
221
|
+
- `GET /api/health` — Health check with pending count
|
|
222
|
+
|
|
223
|
+
### Session Tracking
|
|
224
|
+
|
|
225
|
+
Sessions are tracked by their `session_id` from Claude Code. Each session's TTY path is detected by walking the process tree. Sessions persist as long as the TTY has active processes — they don't expire on a timer.
|
|
226
|
+
|
|
227
|
+
### Reply Injection (macOS)
|
|
228
|
+
|
|
229
|
+
When you reply to a notification from Telegram, the daemon uses `osascript` to type your text into the correct terminal:
|
|
230
|
+
|
|
231
|
+
- **iTerm2** — Uses `write text` on the session matched by TTY path. Works without bringing the window to front.
|
|
232
|
+
- **Terminal.app** — Finds the tab by TTY, focuses it, then uses System Events to keystroke the text + press Return.
|
|
233
|
+
|
|
234
|
+
For interactive questions, the daemon injects the answer sequence using keyboard navigation (arrow keys, space, tab, enter).
|
|
235
|
+
|
|
236
|
+
### Graceful Degradation
|
|
237
|
+
|
|
238
|
+
If the daemon is not running:
|
|
239
|
+
- Hook scripts detect the connection failure and `exit 0` with no output
|
|
240
|
+
- Claude Code falls through to the normal local permission dialog
|
|
241
|
+
- Zero disruption — you just don't get Telegram notifications
|
|
242
|
+
|
|
243
|
+
## Project Structure
|
|
244
|
+
|
|
245
|
+
```
|
|
246
|
+
claude-tg/
|
|
247
|
+
├── bin/
|
|
248
|
+
│ └── claude-tg # CLI entry point
|
|
249
|
+
├── src/
|
|
250
|
+
│ ├── config.js # Read/write ~/.claude-telegram-bridge/config.json
|
|
251
|
+
│ ├── daemon.js # Telegram bot + HTTP server + session tracking
|
|
252
|
+
│ ├── setup.js # Interactive setup + hook installation
|
|
253
|
+
│ └── hooks/
|
|
254
|
+
│ ├── permission-request.js # Blocking PermissionRequest hook
|
|
255
|
+
│ └── notification.js # Async Notification hook
|
|
256
|
+
├── package.json
|
|
257
|
+
└── README.md
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
**Config directory:** `~/.claude-telegram-bridge/`
|
|
261
|
+
- `config.json` — bot token, chat ID, port
|
|
262
|
+
- `daemon.pid` — PID of running daemon
|
|
263
|
+
- `daemon.log` — daemon logs
|
|
264
|
+
|
|
265
|
+
## Limitations
|
|
266
|
+
|
|
267
|
+
- **Reply from Telegram** requires macOS with Terminal.app or iTerm2. On Linux or other terminals, you'll see notifications but need to respond in the terminal.
|
|
268
|
+
- **Interactive question injection** uses AppleScript keystrokes — requires the terminal to be accessible (not locked screen).
|
|
269
|
+
- **Session labels reset** when the daemon restarts (#1, #2... start over).
|
|
270
|
+
- **30-minute timeout** on permission requests. If you don't respond, the hook exits and Claude shows the local dialog.
|
|
271
|
+
|
|
272
|
+
## Uninstalling
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
claude-tg daemon stop
|
|
276
|
+
claude-tg uninstall
|
|
277
|
+
npm uninstall -g claude-tg
|
|
278
|
+
rm -rf ~/.claude-telegram-bridge
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## License
|
|
282
|
+
|
|
283
|
+
MIT
|
package/bin/claude-tg
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
const { spawn, execSync } = require('child_process');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { loadConfig, PID_PATH, LOG_PATH, CONFIG_DIR } = require('../src/config');
|
|
8
|
+
|
|
9
|
+
const program = new Command();
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.name('claude-tg')
|
|
13
|
+
.description('Claude Code ↔ Telegram Bridge')
|
|
14
|
+
.version('1.0.0');
|
|
15
|
+
|
|
16
|
+
program
|
|
17
|
+
.command('setup')
|
|
18
|
+
.description('Interactive setup: bot token, chat ID, hook installation')
|
|
19
|
+
.action(async () => {
|
|
20
|
+
const { run } = require('../src/setup');
|
|
21
|
+
await run();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const daemon = program.command('daemon').description('Manage the background daemon');
|
|
25
|
+
|
|
26
|
+
daemon
|
|
27
|
+
.command('start')
|
|
28
|
+
.description('Start the daemon as a background process')
|
|
29
|
+
.action(() => {
|
|
30
|
+
const config = loadConfig();
|
|
31
|
+
if (!config.botToken || !config.chatId) {
|
|
32
|
+
console.error('Not configured. Run: claude-tg setup');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check if already running
|
|
37
|
+
if (fs.existsSync(PID_PATH)) {
|
|
38
|
+
const pid = parseInt(fs.readFileSync(PID_PATH, 'utf8').trim(), 10);
|
|
39
|
+
try {
|
|
40
|
+
process.kill(pid, 0); // Check if process exists
|
|
41
|
+
console.log(`Daemon already running (PID ${pid})`);
|
|
42
|
+
return;
|
|
43
|
+
} catch {
|
|
44
|
+
// PID file stale, remove it
|
|
45
|
+
fs.unlinkSync(PID_PATH);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Ensure config dir exists
|
|
50
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
51
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const logFd = fs.openSync(LOG_PATH, 'a');
|
|
55
|
+
const daemonPath = path.resolve(__dirname, '..', 'src', 'daemon.js');
|
|
56
|
+
|
|
57
|
+
const child = spawn('node', [daemonPath], {
|
|
58
|
+
detached: true,
|
|
59
|
+
stdio: ['ignore', logFd, logFd],
|
|
60
|
+
env: { ...process.env },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
fs.writeFileSync(PID_PATH, child.pid.toString());
|
|
64
|
+
child.unref();
|
|
65
|
+
fs.closeSync(logFd);
|
|
66
|
+
|
|
67
|
+
console.log(`Daemon started (PID ${child.pid})`);
|
|
68
|
+
console.log(`Logs: ${LOG_PATH}`);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
daemon
|
|
72
|
+
.command('stop')
|
|
73
|
+
.description('Stop the daemon')
|
|
74
|
+
.action(() => {
|
|
75
|
+
if (!fs.existsSync(PID_PATH)) {
|
|
76
|
+
console.log('Daemon is not running.');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const pid = parseInt(fs.readFileSync(PID_PATH, 'utf8').trim(), 10);
|
|
80
|
+
try {
|
|
81
|
+
process.kill(pid, 'SIGTERM');
|
|
82
|
+
fs.unlinkSync(PID_PATH);
|
|
83
|
+
console.log(`Daemon stopped (PID ${pid})`);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err.code === 'ESRCH') {
|
|
86
|
+
fs.unlinkSync(PID_PATH);
|
|
87
|
+
console.log('Daemon was not running (stale PID file removed).');
|
|
88
|
+
} else {
|
|
89
|
+
console.error(`Failed to stop daemon: ${err.message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
daemon
|
|
95
|
+
.command('status')
|
|
96
|
+
.description('Check if daemon is running')
|
|
97
|
+
.action(() => {
|
|
98
|
+
if (!fs.existsSync(PID_PATH)) {
|
|
99
|
+
console.log('Daemon is not running.');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const pid = parseInt(fs.readFileSync(PID_PATH, 'utf8').trim(), 10);
|
|
103
|
+
try {
|
|
104
|
+
process.kill(pid, 0);
|
|
105
|
+
console.log(`Daemon is running (PID ${pid})`);
|
|
106
|
+
|
|
107
|
+
// Try health check
|
|
108
|
+
const http = require('http');
|
|
109
|
+
const config = loadConfig();
|
|
110
|
+
const req = http.get(`http://127.0.0.1:${config.port}/api/health`, (res) => {
|
|
111
|
+
let data = '';
|
|
112
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
113
|
+
res.on('end', () => {
|
|
114
|
+
try {
|
|
115
|
+
const health = JSON.parse(data);
|
|
116
|
+
console.log(`Pending requests: ${health.pending}`);
|
|
117
|
+
} catch {}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
req.on('error', () => {});
|
|
121
|
+
req.setTimeout(2000, () => req.destroy());
|
|
122
|
+
} catch {
|
|
123
|
+
fs.unlinkSync(PID_PATH);
|
|
124
|
+
console.log('Daemon is not running (stale PID file removed).');
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
daemon
|
|
129
|
+
.command('logs')
|
|
130
|
+
.description('Tail daemon log file')
|
|
131
|
+
.action(() => {
|
|
132
|
+
if (!fs.existsSync(LOG_PATH)) {
|
|
133
|
+
console.log('No log file found.');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
execSync(`tail -f ${LOG_PATH}`, { stdio: 'inherit' });
|
|
138
|
+
} catch {
|
|
139
|
+
// User pressed Ctrl+C
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// --- Send commands (stateless, no daemon needed) ---
|
|
144
|
+
|
|
145
|
+
function createTelegramClient() {
|
|
146
|
+
const config = loadConfig();
|
|
147
|
+
if (!config.botToken || !config.chatId) {
|
|
148
|
+
console.error('Not configured. Run: claude-tg setup');
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
const { Telegram } = require('telegraf');
|
|
152
|
+
return { client: new Telegram(config.botToken), chatId: config.chatId };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function readAllStdin() {
|
|
156
|
+
return new Promise((resolve) => {
|
|
157
|
+
let data = '';
|
|
158
|
+
process.stdin.setEncoding('utf8');
|
|
159
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
160
|
+
process.stdin.on('end', () => resolve(data));
|
|
161
|
+
if (process.stdin.isTTY) resolve('');
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
program
|
|
166
|
+
.command('send')
|
|
167
|
+
.argument('<text>', 'Text to send, or "-" to read from stdin')
|
|
168
|
+
.description('Send a text message to Telegram')
|
|
169
|
+
.action(async (text) => {
|
|
170
|
+
try {
|
|
171
|
+
if (text === '-') {
|
|
172
|
+
text = await readAllStdin();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!text.trim()) {
|
|
176
|
+
console.error('No text to send.');
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const { client, chatId } = createTelegramClient();
|
|
181
|
+
|
|
182
|
+
if (text.length <= 4096) {
|
|
183
|
+
await client.sendMessage(chatId, text);
|
|
184
|
+
console.log('Message sent.');
|
|
185
|
+
} else {
|
|
186
|
+
// Long text — send as a document
|
|
187
|
+
const tmpFile = path.join(require('os').tmpdir(), `claude-tg-${Date.now()}.md`);
|
|
188
|
+
fs.writeFileSync(tmpFile, text);
|
|
189
|
+
const { Input } = require('telegraf');
|
|
190
|
+
await client.sendDocument(chatId, Input.fromLocalFile(tmpFile, 'message.md'), {
|
|
191
|
+
caption: `Message from Claude (${text.length} chars)`,
|
|
192
|
+
});
|
|
193
|
+
fs.unlinkSync(tmpFile);
|
|
194
|
+
console.log('Message sent as document.');
|
|
195
|
+
}
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.error(`Send failed: ${err.message}`);
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
program
|
|
203
|
+
.command('send-file')
|
|
204
|
+
.argument('<filepath>', 'Path to file')
|
|
205
|
+
.argument('[caption]', 'Optional caption')
|
|
206
|
+
.description('Send a file to Telegram')
|
|
207
|
+
.action(async (filepath, caption) => {
|
|
208
|
+
try {
|
|
209
|
+
const resolved = path.resolve(filepath);
|
|
210
|
+
if (!fs.existsSync(resolved)) {
|
|
211
|
+
console.error(`File not found: ${resolved}`);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const stat = fs.statSync(resolved);
|
|
216
|
+
if (stat.size > 50 * 1024 * 1024) {
|
|
217
|
+
console.error('File exceeds 50MB Telegram limit.');
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const { client, chatId } = createTelegramClient();
|
|
222
|
+
const { Input } = require('telegraf');
|
|
223
|
+
const filename = path.basename(resolved);
|
|
224
|
+
const extra = caption ? { caption } : {};
|
|
225
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
226
|
+
|
|
227
|
+
const photoExts = ['.jpg', '.jpeg', '.png', '.webp'];
|
|
228
|
+
const videoExts = ['.mp4', '.mov', '.avi', '.mkv', '.webm'];
|
|
229
|
+
const gifExts = ['.gif'];
|
|
230
|
+
const audioExts = ['.mp3', '.ogg', '.wav', '.flac', '.m4a', '.aac'];
|
|
231
|
+
|
|
232
|
+
const source = Input.fromLocalFile(resolved, filename);
|
|
233
|
+
|
|
234
|
+
if (photoExts.includes(ext)) {
|
|
235
|
+
await client.sendPhoto(chatId, source, extra);
|
|
236
|
+
} else if (videoExts.includes(ext)) {
|
|
237
|
+
await client.sendVideo(chatId, source, extra);
|
|
238
|
+
} else if (gifExts.includes(ext)) {
|
|
239
|
+
await client.sendAnimation(chatId, source, extra);
|
|
240
|
+
} else if (audioExts.includes(ext)) {
|
|
241
|
+
await client.sendAudio(chatId, source, extra);
|
|
242
|
+
} else {
|
|
243
|
+
await client.sendDocument(chatId, source, extra);
|
|
244
|
+
}
|
|
245
|
+
console.log(`File sent: ${filename}`);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.error(`Send failed: ${err.message}`);
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
program
|
|
253
|
+
.command('uninstall')
|
|
254
|
+
.description('Remove hooks from ~/.claude/settings.json')
|
|
255
|
+
.action(() => {
|
|
256
|
+
const { uninstallHooks } = require('../src/setup');
|
|
257
|
+
uninstallHooks();
|
|
258
|
+
console.log('Hooks removed from ~/.claude/settings.json');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-tg",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Control Claude Code from Telegram — approve permissions, answer questions, reply to idle sessions, send files, all from your phone",
|
|
5
|
+
"bin": {
|
|
6
|
+
"claude-tg": "./bin/claude-tg"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"src/",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"claude",
|
|
16
|
+
"claude-code",
|
|
17
|
+
"telegram",
|
|
18
|
+
"telegram-bot",
|
|
19
|
+
"cli",
|
|
20
|
+
"hooks",
|
|
21
|
+
"remote",
|
|
22
|
+
"approval",
|
|
23
|
+
"permission",
|
|
24
|
+
"notification",
|
|
25
|
+
"multi-session"
|
|
26
|
+
],
|
|
27
|
+
"author": "Himanshu",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/kokhp/claude-tg.git"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/kokhp/claude-tg#readme",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"telegraf": "^4.16.3",
|
|
39
|
+
"commander": "^12.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const CONFIG_DIR = path.join(process.env.HOME, '.claude-telegram-bridge');
|
|
5
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
6
|
+
const PID_PATH = path.join(CONFIG_DIR, 'daemon.pid');
|
|
7
|
+
const LOG_PATH = path.join(CONFIG_DIR, 'daemon.log');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CONFIG = {
|
|
10
|
+
botToken: '',
|
|
11
|
+
chatId: '',
|
|
12
|
+
port: 7483,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function ensureConfigDir() {
|
|
16
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
17
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function loadConfig() {
|
|
22
|
+
ensureConfigDir();
|
|
23
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
24
|
+
return { ...DEFAULT_CONFIG };
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) };
|
|
28
|
+
} catch {
|
|
29
|
+
return { ...DEFAULT_CONFIG };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function saveConfig(config) {
|
|
34
|
+
ensureConfigDir();
|
|
35
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { loadConfig, saveConfig, CONFIG_DIR, CONFIG_PATH, PID_PATH, LOG_PATH };
|