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.
- claude_link-0.2.1/.github/workflows/publish.yml +21 -0
- claude_link-0.2.1/.gitignore +9 -0
- claude_link-0.2.1/CONTRIBUTING.md +67 -0
- claude_link-0.2.1/LICENSE +21 -0
- claude_link-0.2.1/PKG-INFO +86 -0
- claude_link-0.2.1/README.md +62 -0
- claude_link-0.2.1/pyproject.toml +41 -0
- claude_link-0.2.1/src/claude_bridge/__init__.py +3 -0
- claude_link-0.2.1/src/claude_bridge/__main__.py +5 -0
- claude_link-0.2.1/src/claude_bridge/bot.py +473 -0
- claude_link-0.2.1/src/claude_bridge/cli.py +70 -0
- claude_link-0.2.1/src/claude_bridge/config.py +223 -0
|
@@ -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,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,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
|