claude-link 0.2.1__tar.gz

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,21 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ environment: release
11
+ permissions:
12
+ contents: read
13
+ id-token: write
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.12"
19
+ - run: pip install build
20
+ - run: python -m build
21
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .env
7
+ venv/
8
+ claude.md
9
+ PLAN.md
@@ -0,0 +1,67 @@
1
+ # Contributing to claude-link
2
+
3
+ Thanks for your interest! This doc explains how the project is built.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ src/claude_bridge/
9
+ ├── __init__.py # Version string
10
+ ├── __main__.py # python -m claude_bridge entry
11
+ ├── cli.py # CLI argument parsing, startup checks
12
+ ├── config.py # Config load/save, setup wizard, verification
13
+ └── bot.py # Telegram bot core, Claude CLI runner, queue
14
+ ```
15
+
16
+ **5 files, ~450 lines total.** That's the whole project.
17
+
18
+ ### Flow
19
+
20
+ ```
21
+ cli.py → Parses args, loads config, checks Claude is installed
22
+
23
+ config.py → If no config: runs wizard (token, chat ID, verification)
24
+
25
+ bot.py → Starts Telegram polling, listens for messages
26
+
27
+ _send_to_claude → Queues the message
28
+
29
+ _queue_worker → Picks up message, acquires lock
30
+
31
+ _run_claude → Spawns `claude -p "..." --output-format stream-json`
32
+ ↓ Streams stdout, parses JSON events, updates Telegram status
33
+ ↓ Logs tool usage to console
34
+
35
+ response → Sends result back to Telegram
36
+ ```
37
+
38
+ ### Key design decisions
39
+
40
+ **One dependency** — `python-telegram-bot>=20.0`. Voice transcription uses `httpx` which comes as a transitive dependency. No extra packages needed.
41
+
42
+ **Subprocess, not SDK** — Claude Code is invoked as a CLI subprocess with `--output-format stream-json`. This means the bridge works with any Claude Code version without API coupling.
43
+
44
+ **Queue + Lock** — Messages are queued and processed sequentially. Only one Claude process runs at a time. `/cancel` kills the process and drains the queue.
45
+
46
+ **Cross-platform subprocess** — Uses `create_subprocess_exec` on Unix (no shell injection risk) and `create_subprocess_shell` with `list2cmdline` escaping on Windows (where exec can't find binaries on PATH).
47
+
48
+ **Config as plain JSON** — `~/.claude-link.json` with `chmod 600`. No dotenv, no YAML, no environment variables.
49
+
50
+ ## Development setup
51
+
52
+ ```bash
53
+ git clone https://github.com/Qsanti/claude-link.git
54
+ cd claude-link
55
+ python -m venv venv
56
+ source venv/bin/activate # or venv\Scripts\activate on Windows
57
+ pip install -e .
58
+ ```
59
+
60
+ Now `claude-link` runs from source.
61
+
62
+ ## Code style
63
+
64
+ - Keep it simple. No abstractions for things that happen once.
65
+ - No extra dependencies unless absolutely necessary.
66
+ - Every file should be readable top to bottom in a few minutes.
67
+ - Security by default (file permissions, auth checks, no shell on Unix).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Qsanti
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.
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: claude-link
3
+ Version: 0.2.1
4
+ Summary: Connect Claude Code to Telegram in one command
5
+ Project-URL: Homepage, https://github.com/Qsanti/claude-bridge
6
+ Project-URL: Repository, https://github.com/Qsanti/claude-bridge
7
+ Project-URL: Issues, https://github.com/Qsanti/claude-bridge/issues
8
+ Author-email: Qsanti <santiagossc@live.com.ar>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai,claude,claude-code,cli,telegram
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Communications :: Chat
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: python-telegram-bot>=20.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # claude-link
26
+
27
+ Control Claude Code from your phone. Nothing more.
28
+
29
+ ```
30
+ Phone → Telegram → claude-link → Claude Code → response → Phone
31
+ ```
32
+
33
+ claude-link is not a bot. It has no AI, no logic, no cloud servers.
34
+ It's a **bridge** — it forwards messages between Telegram and the Claude Code CLI running on your machine.
35
+
36
+ You start it when you want. You stop it when you're done. That's it.
37
+
38
+ Your existing Claude Code config, skills, CLAUDE.md, and project context — all there, because it's the same session running on your computer.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install claude-link
44
+ ```
45
+
46
+ ## Run
47
+
48
+ ```bash
49
+ claude-link
50
+ ```
51
+
52
+ Just the first time, it walks you through connecting your Telegram bot. Takes 2 minutes.
53
+
54
+ No daemon, no background process, no always-on server. It lives while your terminal lives.
55
+
56
+ ## Terminal output
57
+
58
+ ```
59
+ [12:35:10] 📩 Message from Santiago: "list my projects"
60
+ [12:35:10] 🤖 Claude working...
61
+ [12:35:11] 📖 Reading CLAUDE.md
62
+ [12:35:12] ⚙️ Running: ls ~/projects
63
+ [12:35:14] ✅ Done (4s)
64
+ [12:35:14] 📤 Reply: "You have 3 projects..."
65
+ ```
66
+
67
+ ## Commands
68
+
69
+ `/cancel` — stop current task and clear queue · `/new` — fresh conversation · `/status` — bot info · `/help` — all commands
70
+
71
+ Voice messages supported with `claude-link --enable-voice` (requires OpenAI key).
72
+
73
+ ## Prerequisites
74
+
75
+ - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
76
+ - Python 3.10+
77
+ - A Telegram bot token (from [@BotFather](https://t.me/BotFather))
78
+ - Your Telegram Chat ID (from [@userinfobot](https://t.me/userinfobot))
79
+
80
+ ## Security
81
+
82
+ Single-user only. Config stored with restricted permissions. No shell injection. Nothing stored remotely. Unauthorized attempts logged.
83
+
84
+ ## License
85
+
86
+ MIT
@@ -0,0 +1,62 @@
1
+ # claude-link
2
+
3
+ Control Claude Code from your phone. Nothing more.
4
+
5
+ ```
6
+ Phone → Telegram → claude-link → Claude Code → response → Phone
7
+ ```
8
+
9
+ claude-link is not a bot. It has no AI, no logic, no cloud servers.
10
+ It's a **bridge** — it forwards messages between Telegram and the Claude Code CLI running on your machine.
11
+
12
+ You start it when you want. You stop it when you're done. That's it.
13
+
14
+ Your existing Claude Code config, skills, CLAUDE.md, and project context — all there, because it's the same session running on your computer.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install claude-link
20
+ ```
21
+
22
+ ## Run
23
+
24
+ ```bash
25
+ claude-link
26
+ ```
27
+
28
+ Just the first time, it walks you through connecting your Telegram bot. Takes 2 minutes.
29
+
30
+ No daemon, no background process, no always-on server. It lives while your terminal lives.
31
+
32
+ ## Terminal output
33
+
34
+ ```
35
+ [12:35:10] 📩 Message from Santiago: "list my projects"
36
+ [12:35:10] 🤖 Claude working...
37
+ [12:35:11] 📖 Reading CLAUDE.md
38
+ [12:35:12] ⚙️ Running: ls ~/projects
39
+ [12:35:14] ✅ Done (4s)
40
+ [12:35:14] 📤 Reply: "You have 3 projects..."
41
+ ```
42
+
43
+ ## Commands
44
+
45
+ `/cancel` — stop current task and clear queue · `/new` — fresh conversation · `/status` — bot info · `/help` — all commands
46
+
47
+ Voice messages supported with `claude-link --enable-voice` (requires OpenAI key).
48
+
49
+ ## Prerequisites
50
+
51
+ - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
52
+ - Python 3.10+
53
+ - A Telegram bot token (from [@BotFather](https://t.me/BotFather))
54
+ - Your Telegram Chat ID (from [@userinfobot](https://t.me/userinfobot))
55
+
56
+ ## Security
57
+
58
+ Single-user only. Config stored with restricted permissions. No shell injection. Nothing stored remotely. Unauthorized attempts logged.
59
+
60
+ ## License
61
+
62
+ MIT
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "claude-link"
7
+ version = "0.2.1"
8
+ description = "Connect Claude Code to Telegram in one command"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Qsanti", email = "santiagossc@live.com.ar" }]
13
+ keywords = ["claude", "telegram", "ai", "cli", "claude-code"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Communications :: Chat",
23
+ "Topic :: Software Development :: Libraries",
24
+ ]
25
+ dependencies = [
26
+ "python-telegram-bot>=20.0",
27
+ ]
28
+
29
+ [project.scripts]
30
+ claude-link = "claude_bridge.cli:main"
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/Qsanti/claude-bridge"
34
+ Repository = "https://github.com/Qsanti/claude-bridge"
35
+ Issues = "https://github.com/Qsanti/claude-bridge/issues"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/claude_bridge"]
39
+
40
+ [tool.hatch.build.targets.sdist]
41
+ exclude = ["venv/", "dist/", "*.egg-info/"]
@@ -0,0 +1,3 @@
1
+ """claude-bridge — Connect Claude Code to Telegram in one command."""
2
+
3
+ __version__ = "0.2.1"
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m claude_bridge"""
2
+
3
+ from .cli import main
4
+
5
+ main()
@@ -0,0 +1,473 @@
1
+ """Telegram bot core — receives messages, runs Claude, logs everything."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import time
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ import httpx
15
+ from telegram import Update, Bot
16
+ from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
17
+
18
+ # Session file for conversation persistence
19
+ SESSION_FILE = Path.home() / ".claude-link-session.json"
20
+
21
+ # Queue + lock for sequential Claude CLI execution
22
+ _claude_lock = asyncio.Lock()
23
+ _message_queue: asyncio.Queue | None = None
24
+ _active_proc: asyncio.subprocess.Process | None = None
25
+
26
+ # Tool icons for console + Telegram status
27
+ TOOL_ICONS = {
28
+ "Bash": "⚙️",
29
+ "Read": "📖",
30
+ "Edit": "✏️",
31
+ "Write": "📝",
32
+ "Grep": "🔍",
33
+ "Glob": "📁",
34
+ "WebFetch": "🌐",
35
+ "WebSearch": "🌐",
36
+ "Task": "🤖",
37
+ "Skill": "🎯",
38
+ "TodoWrite": "📋",
39
+ }
40
+
41
+ WELCOME_TEXT = (
42
+ "🌉 claude-link is online!\n\n"
43
+ "Send me a message and I'll forward it to Claude Code.\n\n"
44
+ "Commands:\n"
45
+ "/cancel — Stop current task\n"
46
+ "/new — Start fresh session\n"
47
+ "/status — Bot info\n"
48
+ "/help — All commands"
49
+ )
50
+
51
+ HELP_TEXT = (
52
+ "🌉 claude-link\n\n"
53
+ "Commands:\n"
54
+ " /cancel — Stop current task + clear queue\n"
55
+ " /new — Start a fresh conversation\n"
56
+ " /status — Show bot info\n"
57
+ " /help — Show this message\n\n"
58
+ "Everything else is sent directly to Claude."
59
+ )
60
+
61
+
62
+ def _ts() -> str:
63
+ """Timestamp for console logs."""
64
+ return datetime.now().strftime("%H:%M:%S")
65
+
66
+
67
+ def _log(icon: str, msg: str):
68
+ """Print a timestamped log line."""
69
+ print(f"[{_ts()}] {icon} {msg}")
70
+
71
+
72
+ def _tool_label(name: str, inp: dict) -> str:
73
+ """Short description of a tool call."""
74
+ icon = TOOL_ICONS.get(name, "🔧")
75
+ if name == "Read" and "file_path" in inp:
76
+ return f"{icon} Reading {os.path.basename(inp['file_path'])}"
77
+ if name in ("Edit", "Write") and "file_path" in inp:
78
+ return f"{icon} {'Editing' if name == 'Edit' else 'Writing'} {os.path.basename(inp['file_path'])}"
79
+ if name == "Bash" and "command" in inp:
80
+ return f"{icon} Running: {inp['command'][:60]}"
81
+ if name == "Grep" and "pattern" in inp:
82
+ return f"{icon} Searching: {inp['pattern'][:40]}"
83
+ if name == "Glob" and "pattern" in inp:
84
+ return f"{icon} Finding: {inp['pattern'][:40]}"
85
+ if name == "Task" and "description" in inp:
86
+ return f"{icon} Sub-agent: {inp['description'][:50]}"
87
+ return f"{icon} {name}"
88
+
89
+
90
+ def _load_session() -> str | None:
91
+ if SESSION_FILE.exists():
92
+ data = json.loads(SESSION_FILE.read_text())
93
+ return data.get("session_id")
94
+ return None
95
+
96
+
97
+ def _save_session(session_id: str | None):
98
+ SESSION_FILE.write_text(json.dumps({"session_id": session_id}) + "\n")
99
+ try:
100
+ os.chmod(SESSION_FILE, 0o600)
101
+ except OSError:
102
+ pass
103
+
104
+
105
+ def _clear_session():
106
+ if SESSION_FILE.exists():
107
+ SESSION_FILE.unlink()
108
+
109
+
110
+ def _transcribe_audio(audio_path: str, api_key: str) -> str | None:
111
+ """Transcribe audio using OpenAI Whisper API via httpx."""
112
+ try:
113
+ with open(audio_path, "rb") as f:
114
+ resp = httpx.post(
115
+ "https://api.openai.com/v1/audio/transcriptions",
116
+ headers={"Authorization": f"Bearer {api_key}"},
117
+ data={"model": "whisper-1"},
118
+ files={"file": ("audio.ogg", f, "audio/ogg")},
119
+ timeout=30,
120
+ )
121
+ if resp.status_code == 200:
122
+ return resp.json().get("text", "").strip()
123
+ except Exception as e:
124
+ _log("⚠️", f"Transcription error: {type(e).__name__}")
125
+ return None
126
+
127
+
128
+ async def _run_claude(message: str, cfg: dict, status_msg, session_id: str | None) -> tuple[str | None, str | None]:
129
+ """Run Claude CLI with stream-json, update Telegram status + console logs."""
130
+ global _active_proc
131
+ claude_path = shutil.which(cfg.get("claude_path", "claude")) or cfg.get("claude_path", "claude")
132
+ workspace = cfg.get("workspace", str(Path.home()))
133
+
134
+ args = ["-p", message, "--output-format", "stream-json", "--verbose"]
135
+ if session_id:
136
+ args.extend(["--resume", session_id])
137
+
138
+ env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"}
139
+
140
+ # Use exec on Unix (safer, no shell injection risk), shell only on Windows
141
+ if sys.platform == "win32":
142
+ cmd_line = subprocess.list2cmdline([claude_path] + args)
143
+ proc = await asyncio.create_subprocess_shell(
144
+ cmd_line,
145
+ stdout=asyncio.subprocess.PIPE,
146
+ stderr=asyncio.subprocess.PIPE,
147
+ cwd=workspace,
148
+ env=env,
149
+ )
150
+ else:
151
+ proc = await asyncio.create_subprocess_exec(
152
+ claude_path, *args,
153
+ stdout=asyncio.subprocess.PIPE,
154
+ stderr=asyncio.subprocess.PIPE,
155
+ cwd=workspace,
156
+ env=env,
157
+ )
158
+
159
+ _active_proc = proc
160
+
161
+ status_lines = ["⏳ Working..."]
162
+ last_edit = 0
163
+ result_text = None
164
+ new_session_id = None
165
+ expired = False
166
+ start = time.time()
167
+
168
+ async def _update_status():
169
+ nonlocal last_edit
170
+ now = time.time()
171
+ if now - last_edit < 2:
172
+ return
173
+ last_edit = now
174
+ try:
175
+ await status_msg.edit_text("\n".join(status_lines[-10:]))
176
+ except Exception:
177
+ pass
178
+
179
+ async for raw in proc.stdout:
180
+ line = raw.decode("utf-8", errors="replace").strip()
181
+ if not line:
182
+ continue
183
+ try:
184
+ event = json.loads(line)
185
+ except json.JSONDecodeError:
186
+ if "no conversation found" in line.lower() or "session" in line.lower():
187
+ expired = True
188
+ continue
189
+
190
+ etype = event.get("type")
191
+
192
+ if etype == "assistant":
193
+ for block in event.get("message", {}).get("content", []):
194
+ if block.get("type") == "tool_use":
195
+ label = _tool_label(block.get("name", ""), block.get("input", {}))
196
+ status_lines.append(label)
197
+ _log(" ", label)
198
+ await _update_status()
199
+
200
+ elif etype == "result":
201
+ result_text = event.get("result")
202
+ new_session_id = event.get("session_id")
203
+
204
+ await proc.wait()
205
+ _active_proc = None
206
+
207
+ elapsed = int(time.time() - start)
208
+ status_lines.append(f"✅ Done ({elapsed}s)")
209
+ try:
210
+ await status_msg.edit_text("\n".join(status_lines[-10:]))
211
+ except Exception:
212
+ pass
213
+
214
+ if expired:
215
+ return None, None
216
+
217
+ if result_text is None:
218
+ stderr = ""
219
+ if proc.stderr:
220
+ stderr = (await proc.stderr.read()).decode("utf-8", errors="replace")
221
+ if "no conversation found" in stderr.lower():
222
+ return None, None
223
+ return stderr or "No response from Claude.", None
224
+
225
+ return result_text, new_session_id
226
+
227
+
228
+ async def _queue_worker():
229
+ """Process queued messages one at a time."""
230
+ while True:
231
+ text, update, cfg = await _message_queue.get()
232
+ try:
233
+ async with _claude_lock:
234
+ await _send_to_claude_inner(text, update, cfg)
235
+ except Exception as e:
236
+ _log("⚠️", f"Queue worker error: {type(e).__name__}")
237
+ finally:
238
+ _message_queue.task_done()
239
+
240
+
241
+ async def _send_to_claude(text: str, update: Update, cfg: dict):
242
+ """Enqueue a message for Claude. If busy, notifies the user it's queued."""
243
+ if _claude_lock.locked():
244
+ pos = _message_queue.qsize() + 1
245
+ await update.message.reply_text(f"📋 Queued (position {pos}). Will process when current task finishes.")
246
+ _log("📋", f"Message queued (position {pos})")
247
+
248
+ await _message_queue.put((text, update, cfg))
249
+
250
+
251
+ async def _send_to_claude_inner(text: str, update: Update, cfg: dict):
252
+ """Inner logic: send text to Claude and reply (called under lock)."""
253
+ status_msg = await update.message.reply_text("⏳ Working...")
254
+ _log("🤖", "Claude working...")
255
+
256
+ session_id = _load_session()
257
+
258
+ # Try with existing session
259
+ if session_id:
260
+ response, new_sid = await _run_claude(text, cfg, status_msg, session_id)
261
+ if response is None:
262
+ _log("🔄", "Session expired, starting fresh")
263
+ _clear_session()
264
+ session_id = None
265
+
266
+ # Fresh session
267
+ if not session_id:
268
+ prompt = f"First, silently read CLAUDE.md for context. Then respond to: {text}"
269
+ response, new_sid = await _run_claude(prompt, cfg, status_msg, None)
270
+
271
+ if new_sid:
272
+ _save_session(new_sid)
273
+
274
+ if not response or not response.strip():
275
+ response = "(No response from Claude)"
276
+
277
+ # Truncate if too long for Telegram
278
+ if len(response) > 4000:
279
+ response = response[:4000] + "\n\n... (truncated)"
280
+
281
+ _log("📤", f"Reply: \"{response[:80]}{'...' if len(response) > 80 else ''}\"")
282
+ await update.message.reply_text(response)
283
+
284
+
285
+ def _check_auth(update: Update, cfg: dict) -> bool:
286
+ """Check if the message is from the authorized chat. Logs unauthorized attempts."""
287
+ if update.effective_chat.id != cfg["chat_id"]:
288
+ user = update.effective_user
289
+ name = f"{user.first_name or ''} (id={user.id})" if user else f"id={update.effective_chat.id}"
290
+ _log("⛔", f"Unauthorized access attempt from {name}")
291
+ return False
292
+ return True
293
+
294
+
295
+ async def _handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
296
+ """Handle incoming text messages."""
297
+ cfg = context.bot_data["cfg"]
298
+
299
+ if not _check_auth(update, cfg):
300
+ return
301
+
302
+ text = update.message.text
303
+ user = update.effective_user.first_name or "User"
304
+
305
+ _log("📩", f"Message from {user}: \"{text[:80]}{'...' if len(text) > 80 else ''}\"")
306
+
307
+ await _send_to_claude(text, update, cfg)
308
+
309
+
310
+ async def _handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE):
311
+ """Handle voice messages — transcribe and send to Claude."""
312
+ cfg = context.bot_data["cfg"]
313
+
314
+ if not _check_auth(update, cfg):
315
+ return
316
+
317
+ api_key = cfg.get("openai_api_key")
318
+ if not api_key:
319
+ await update.message.reply_text(
320
+ "🎤 Voice messages are not enabled.\n\n"
321
+ "To enable, run in your terminal:\n"
322
+ " claude-bridge --enable-voice"
323
+ )
324
+ return
325
+
326
+ user = update.effective_user.first_name or "User"
327
+ _log("🎤", f"Voice message from {user}")
328
+
329
+ await update.message.reply_text("🎤 Transcribing...")
330
+
331
+ voice = update.message.voice
332
+ file = await context.bot.get_file(voice.file_id)
333
+
334
+ with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as tmp:
335
+ tmp_path = tmp.name
336
+
337
+ await file.download_to_drive(tmp_path)
338
+
339
+ try:
340
+ transcript = _transcribe_audio(tmp_path, api_key)
341
+
342
+ if not transcript:
343
+ await update.message.reply_text("❌ Could not transcribe voice message.")
344
+ return
345
+
346
+ _log("📝", f"Transcribed: \"{transcript[:80]}{'...' if len(transcript) > 80 else ''}\"")
347
+ await update.message.reply_text(f"📝 {transcript}")
348
+
349
+ voice_text = f"[This is a voice message transcription]: {transcript}"
350
+ await _send_to_claude(voice_text, update, cfg)
351
+
352
+ finally:
353
+ if os.path.exists(tmp_path):
354
+ os.remove(tmp_path)
355
+
356
+
357
+ def _drain_queue() -> int:
358
+ """Empty the message queue. Returns number of messages cleared."""
359
+ count = 0
360
+ while not _message_queue.empty():
361
+ try:
362
+ _message_queue.get_nowait()
363
+ _message_queue.task_done()
364
+ count += 1
365
+ except asyncio.QueueEmpty:
366
+ break
367
+ return count
368
+
369
+
370
+ async def _handle_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE):
371
+ """Handle /cancel command — kill active Claude process and clear queue."""
372
+ cfg = context.bot_data["cfg"]
373
+ if not _check_auth(update, cfg):
374
+ return
375
+
376
+ global _active_proc
377
+
378
+ if _active_proc is None or _active_proc.returncode is not None:
379
+ await update.message.reply_text("Nothing running.")
380
+ return
381
+
382
+ # Kill the process
383
+ try:
384
+ _active_proc.terminate()
385
+ try:
386
+ await asyncio.wait_for(_active_proc.wait(), timeout=2)
387
+ except asyncio.TimeoutError:
388
+ _active_proc.kill()
389
+ except ProcessLookupError:
390
+ pass
391
+
392
+ _active_proc = None
393
+
394
+ # Drain the queue
395
+ cleared = _drain_queue()
396
+
397
+ suffix = f" ({cleared} queued message{'s' if cleared != 1 else ''} cleared)" if cleared else ""
398
+ _log("⛔", f"Cancelled by user{suffix}")
399
+ await update.message.reply_text(f"⛔ Cancelled{suffix}")
400
+
401
+
402
+ async def _handle_new(update: Update, context: ContextTypes.DEFAULT_TYPE):
403
+ """Handle /new command — clear session."""
404
+ cfg = context.bot_data["cfg"]
405
+ if not _check_auth(update, cfg):
406
+ return
407
+
408
+ _clear_session()
409
+ _log("🔄", "Session cleared by user")
410
+ await update.message.reply_text("Session cleared. Next message starts fresh.")
411
+
412
+
413
+ async def _handle_status(update: Update, context: ContextTypes.DEFAULT_TYPE):
414
+ """Handle /status command."""
415
+ cfg = context.bot_data["cfg"]
416
+ if not _check_auth(update, cfg):
417
+ return
418
+
419
+ has_session = _load_session() is not None
420
+ voice = "Enabled" if cfg.get("openai_api_key") else "Disabled"
421
+ await update.message.reply_text(
422
+ f"🌉 claude-link\n"
423
+ f"Session: {'Active' if has_session else 'None'}\n"
424
+ f"Voice: {voice}\n"
425
+ f"Workspace: {cfg['workspace']}\n"
426
+ f"Claude: {cfg.get('claude_path', 'claude')}"
427
+ )
428
+
429
+
430
+ async def _handle_help(update: Update, context: ContextTypes.DEFAULT_TYPE):
431
+ """Handle /help command."""
432
+ cfg = context.bot_data["cfg"]
433
+ if not _check_auth(update, cfg):
434
+ return
435
+
436
+ await update.message.reply_text(HELP_TEXT)
437
+
438
+
439
+ async def _send_welcome(app: Application):
440
+ """Send a welcome message and start the queue worker."""
441
+ global _message_queue
442
+ _message_queue = asyncio.Queue()
443
+ asyncio.create_task(_queue_worker())
444
+
445
+ cfg = app.bot_data["cfg"]
446
+ try:
447
+ await app.bot.send_message(chat_id=cfg["chat_id"], text=WELCOME_TEXT)
448
+ _log("📤", "Welcome message sent")
449
+ except Exception as e:
450
+ _log("⚠️", f"Could not send welcome message: {e}")
451
+
452
+
453
+ def run_bot(cfg: dict):
454
+ """Start the Telegram bot. Blocks until Ctrl+C."""
455
+ _log("🚀", "Bot starting...")
456
+
457
+ app = Application.builder().token(cfg["telegram_token"]).build()
458
+ app.bot_data["cfg"] = cfg
459
+
460
+ app.add_handler(CommandHandler("cancel", _handle_cancel))
461
+ app.add_handler(CommandHandler("new", _handle_new))
462
+ app.add_handler(CommandHandler("status", _handle_status))
463
+ app.add_handler(CommandHandler("help", _handle_help))
464
+ app.add_handler(MessageHandler(filters.VOICE, _handle_voice))
465
+ app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, _handle_message))
466
+
467
+ # Send welcome message on startup
468
+ app.post_init = _send_welcome
469
+
470
+ _log("✅", f"Listening for messages from chat {cfg['chat_id']}")
471
+ print(f"[{_ts()}] Press Ctrl+C to stop\n")
472
+
473
+ app.run_polling(allowed_updates=Update.ALL_TYPES)
@@ -0,0 +1,70 @@
1
+ """CLI entry point — argument parsing and startup."""
2
+
3
+ import argparse
4
+ import shutil
5
+ import sys
6
+
7
+ from . import __version__
8
+ from . import config
9
+
10
+
11
+ def main():
12
+ parser = argparse.ArgumentParser(
13
+ prog="claude-link",
14
+ description="Connect Claude Code to Telegram in one command",
15
+ )
16
+ parser.add_argument(
17
+ "-v", "--version", action="version", version=f"claude-link {__version__}"
18
+ )
19
+ parser.add_argument(
20
+ "--setup", action="store_true", help="Re-run the setup wizard"
21
+ )
22
+ parser.add_argument(
23
+ "--enable-voice", action="store_true", help="Enable voice transcription (requires OpenAI key)"
24
+ )
25
+ parser.add_argument(
26
+ "--workspace", type=str, help="Override workspace directory"
27
+ )
28
+ args = parser.parse_args()
29
+
30
+ # Handle --enable-voice
31
+ if args.enable_voice:
32
+ cfg = config.load()
33
+ if cfg is None:
34
+ print("❌ Run claude-link first to complete initial setup.")
35
+ sys.exit(1)
36
+ api_key = config.voice_wizard()
37
+ if api_key:
38
+ cfg["openai_api_key"] = api_key
39
+ config.save(cfg)
40
+ sys.exit(0)
41
+
42
+ # Load or create config
43
+ cfg = config.load()
44
+ if cfg is None or args.setup:
45
+ cfg = config.setup_wizard()
46
+
47
+ # Override workspace if provided
48
+ if args.workspace:
49
+ cfg["workspace"] = args.workspace
50
+
51
+ # Check claude is available
52
+ claude_path = cfg.get("claude_path", "claude")
53
+ if not shutil.which(claude_path):
54
+ print(f"❌ Claude Code not found at '{claude_path}'")
55
+ print(" Install it: https://docs.anthropic.com/en/docs/claude-code")
56
+ print(f" Or set a custom path with: claude-link --setup")
57
+ sys.exit(1)
58
+
59
+ # Start the bot
60
+ voice = "on" if cfg.get("openai_api_key") else "off"
61
+ print(f"\n🌉 claude-link v{__version__}")
62
+ print(f" Workspace: {cfg['workspace']}")
63
+ print(f" Claude: {claude_path}")
64
+ print(f" Chat ID: {cfg['chat_id']}")
65
+ print(f" Voice: {voice}")
66
+ print()
67
+
68
+ from .bot import run_bot
69
+
70
+ run_bot(cfg)
@@ -0,0 +1,223 @@
1
+ """Configuration management — load/save ~/.claude-bridge.json"""
2
+
3
+ import json
4
+ import os
5
+ import secrets
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+
10
+ CONFIG_PATH = Path.home() / ".claude-link.json"
11
+
12
+ DEFAULTS = {
13
+ "claude_path": "claude",
14
+ "workspace": str(Path.home()),
15
+ }
16
+
17
+ BOT_DESCRIPTION = "Claude Code on Telegram — powered by claude-link"
18
+ BOT_SHORT_DESCRIPTION = "Send me a message and I'll forward it to Claude Code"
19
+ BOT_COMMANDS = [
20
+ {"command": "cancel", "description": "Stop current task and clear queue"},
21
+ {"command": "new", "description": "Start a fresh conversation"},
22
+ {"command": "status", "description": "Show bot info"},
23
+ {"command": "help", "description": "Show available commands"},
24
+ ]
25
+
26
+
27
+ def load() -> dict | None:
28
+ """Load config from disk. Returns None if not found."""
29
+ if not CONFIG_PATH.exists():
30
+ return None
31
+ return json.loads(CONFIG_PATH.read_text())
32
+
33
+
34
+ def save(config: dict):
35
+ """Save config to disk with restricted permissions (owner-only)."""
36
+ CONFIG_PATH.write_text(json.dumps(config, indent=2) + "\n")
37
+ try:
38
+ os.chmod(CONFIG_PATH, 0o600)
39
+ except OSError:
40
+ pass # Windows doesn't support Unix permissions
41
+
42
+
43
+ def _bot_api(token: str, method: str, payload: dict = None) -> dict | None:
44
+ """Call a Telegram Bot API method."""
45
+ try:
46
+ url = f"https://api.telegram.org/bot{token}/{method}"
47
+ if payload:
48
+ resp = httpx.post(url, json=payload, timeout=10)
49
+ else:
50
+ resp = httpx.get(url, timeout=10)
51
+ data = resp.json()
52
+ if data.get("ok"):
53
+ return data.get("result")
54
+ except Exception:
55
+ pass
56
+ return None
57
+
58
+
59
+ def _configure_bot(token: str):
60
+ """Auto-configure bot description and commands."""
61
+ _bot_api(token, "setMyDescription", {"description": BOT_DESCRIPTION})
62
+ _bot_api(token, "setMyShortDescription", {"short_description": BOT_SHORT_DESCRIPTION})
63
+ _bot_api(token, "setMyCommands", {"commands": BOT_COMMANDS})
64
+ print(" ✅ Bot configured (description + commands)")
65
+
66
+
67
+ def _verify_telegram(token: str, chat_id: int) -> bool:
68
+ """Send a verification code via Telegram and ask user to confirm."""
69
+ code = str(secrets.randbelow(9000) + 1000)
70
+
71
+ print(" 🔐 Sending verification code to your Telegram...")
72
+
73
+ resp = _bot_api(token, "sendMessage", {
74
+ "chat_id": chat_id,
75
+ "text": f"🌉 claude-link verification code:\n\n🔑 {code}\n\nEnter this code in your terminal to complete setup.",
76
+ })
77
+
78
+ if resp is None:
79
+ print("\n ❌ Could not send message.")
80
+ print(" → Make sure you've opened the bot link above and pressed Start!")
81
+ return False
82
+
83
+ print()
84
+ print(" 📱 Check your Telegram — a code was sent!")
85
+ user_code = input(" Enter the 4-digit code: ").strip()
86
+
87
+ if user_code == code:
88
+ print(" ✅ Verified!\n")
89
+ return True
90
+
91
+ # One retry
92
+ print(" ❌ Wrong code. Try once more:")
93
+ user_code = input(" Enter the 4-digit code: ").strip()
94
+
95
+ if user_code == code:
96
+ print(" ✅ Verified!\n")
97
+ return True
98
+
99
+ print(" ❌ Verification failed. Check your token and chat ID.\n")
100
+ return False
101
+
102
+
103
+ def voice_wizard() -> str | None:
104
+ """Mini-wizard to enable voice transcription."""
105
+ print()
106
+ print("🎤 Enable voice transcription")
107
+ print("=" * 40)
108
+ print(" Requires an OpenAI API key (for Whisper)")
109
+ print(" → Get one at https://platform.openai.com/api-keys")
110
+ print()
111
+
112
+ api_key = input(" OpenAI API Key: ").strip()
113
+ if not api_key:
114
+ print(" ❌ Cancelled.\n")
115
+ return None
116
+
117
+ # Validate key with a simple API call
118
+ print(" Validating key...")
119
+ try:
120
+ resp = httpx.get(
121
+ "https://api.openai.com/v1/models",
122
+ headers={"Authorization": f"Bearer {api_key}"},
123
+ timeout=10,
124
+ )
125
+ if resp.status_code != 200:
126
+ print(" ❌ Invalid API key.\n")
127
+ return None
128
+ except Exception:
129
+ print(" ❌ Could not reach OpenAI API.\n")
130
+ return None
131
+
132
+ print(" ✅ Voice transcription enabled!\n")
133
+ return api_key
134
+
135
+
136
+ def setup_wizard() -> dict:
137
+ """Interactive first-time setup."""
138
+ print()
139
+ print("🌉 claude-link — Setup")
140
+ print("=" * 40)
141
+ print(" You only need to do this once.")
142
+ print()
143
+
144
+ # Step 1: Bot Token
145
+ print("Step 1: Create a Telegram bot")
146
+ print(" → Open https://t.me/BotFather")
147
+ print(" → Send /newbot and follow the steps")
148
+ print(" → Copy the token it gives you")
149
+ print()
150
+ token = input(" Bot Token: ").strip()
151
+ if not token:
152
+ print("\n❌ Token is required.")
153
+ raise SystemExit(1)
154
+
155
+ # Validate token and get bot info
156
+ print("\n Validating token...")
157
+ bot_info = _bot_api(token, "getMe")
158
+ if not bot_info:
159
+ print(" ❌ Invalid token. Please check and try again.")
160
+ raise SystemExit(1)
161
+
162
+ bot_username = bot_info.get("username", "")
163
+ bot_name = bot_info.get("first_name", "your bot")
164
+ print(f" ✅ Connected to @{bot_username} ({bot_name})")
165
+ print()
166
+
167
+ # Auto-configure bot
168
+ _configure_bot(token)
169
+ print()
170
+
171
+ # Step 2: Chat ID
172
+ print("Step 2: Get your Chat ID")
173
+ print(" → Open https://t.me/userinfobot")
174
+ print(" → It will reply with your ID (a number)")
175
+ print()
176
+ chat_id = input(" Chat ID: ").strip()
177
+ if not chat_id or not chat_id.lstrip("-").isdigit():
178
+ print("\n ❌ Valid Chat ID is required (must be a number).")
179
+ raise SystemExit(1)
180
+ print()
181
+
182
+ # Step 3: Verify connection
183
+ print("Step 3: Verify connection")
184
+ print(f" → Open your bot: https://t.me/{bot_username}")
185
+ print(" → Press Start if you haven't already")
186
+ print()
187
+ input(" Press Enter when ready...")
188
+ print()
189
+
190
+ if not _verify_telegram(token, int(chat_id)):
191
+ raise SystemExit(1)
192
+
193
+ # Step 4: Claude path
194
+ print("Step 4: Claude Code CLI path")
195
+ print(f" → Press Enter to use default: {DEFAULTS['claude_path']}")
196
+ print(" → Only change if claude is installed somewhere else")
197
+ claude_path = input(f" Claude path [{DEFAULTS['claude_path']}]: ").strip()
198
+ print()
199
+
200
+ # Step 5: Workspace
201
+ print("Step 5: Workspace directory")
202
+ print(" → This is where Claude will work (read/edit files)")
203
+ print(f" → Press Enter to use default: {DEFAULTS['workspace']}")
204
+ workspace = input(f" Workspace [{DEFAULTS['workspace']}]: ").strip()
205
+ print()
206
+
207
+ config = {
208
+ "telegram_token": token,
209
+ "chat_id": int(chat_id),
210
+ "claude_path": claude_path or DEFAULTS["claude_path"],
211
+ "workspace": workspace or DEFAULTS["workspace"],
212
+ }
213
+
214
+ save(config)
215
+ print(f"✅ Config saved to {CONFIG_PATH}")
216
+ print()
217
+
218
+ # Tip about bot photo
219
+ print("💡 Tip: To set a profile photo for your bot,")
220
+ print(" send /setuserpic to @BotFather")
221
+ print()
222
+
223
+ return config