Skip to content

Commit d0e9fe1

Browse files
author
John O'Hare
committed
feat: Bidirectional image and file support
Add photo/document handling in both directions: - Telegram → Claude: Photos and documents sent to a session topic are downloaded to /tmp/ctm-images/ with UUID filenames and 0o600 perms, then the file path is injected into the tmux session - Claude → Telegram: New SendImage message type lets any script send images or documents via the bridge socket - bot.rs: download_file_to(), send_photo(), send_document() methods - bridge.rs: handle_telegram_photo/document for inbound, handle_send_image for outbound with path traversal validation Co-Authored-By: DreamLabAI <github@thedreamlab.uk>
1 parent 51f1717 commit d0e9fe1

File tree

5 files changed

+621
-34
lines changed

5 files changed

+621
-34
lines changed

README.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
44
[![Rust](https://img.shields.io/badge/Rust-1.75%2B-orange.svg)](https://www.rust-lang.org/)
5-
[![Tests](https://img.shields.io/badge/Tests-21%20passing-green.svg)]()
5+
[![Tests](https://img.shields.io/badge/Tests-23%20passing-green.svg)]()
66
[![Clippy](https://img.shields.io/badge/Clippy-0%20warnings-green.svg)]()
77

88
Monitor and control Claude Code from your phone. CTM is a Rust daemon that bridges Claude Code CLI sessions to Telegram, giving you a real-time mobile interface to what Claude is doing.
@@ -26,6 +26,8 @@ You (phone) Your machine
2626
- Approve/reject tool executions with inline buttons
2727
- Each Claude session gets its own Forum Topic thread
2828
- Type prompts, stop, or kill Claude directly from chat
29+
- Send photos/files from Telegram directly into Claude's session
30+
- Send images and documents from Claude back to Telegram
2931

3032
## Quick Start
3133

@@ -71,6 +73,12 @@ This is a security-focused rewrite of the original TypeScript version. The TypeS
7173
- **Stop/interrupt** — Send `stop` to press Escape, `kill` to send Ctrl-C
7274
- **Slash commands**`cc clear`, `cc compact` forwarded to Claude
7375

76+
### Image & File Support
77+
- **Telegram → Claude** — Send photos or documents from Telegram; they're downloaded locally and the file path is injected into Claude's tmux session
78+
- **Claude → Telegram** — Send images or files to Telegram via the bridge socket using the `send_image` message type
79+
- Photos include dimensions; documents include file size and original filename
80+
- Secure download directory (`/tmp/ctm-images/`) with UUID-based filenames and `0o600` permissions
81+
7482
### Tool Approval
7583
- **Inline keyboards** — Approve, Reject, or Abort with one tap
7684
- **Details button** — Expand to see full tool input parameters
@@ -182,6 +190,7 @@ ctm setup # Interactive setup wizard
182190
| Input | Action |
183191
|-------|--------|
184192
| Any text | Sends as input to Claude |
193+
| Photo/file | Downloads and injects file path into Claude's session |
185194
| `stop` / `esc` | Sends Escape (pause Claude) |
186195
| `kill` / `ctrl-c` | Sends Ctrl-C (exit Claude) |
187196
| `cc clear` | Sends `/clear` to Claude |
@@ -190,6 +199,17 @@ ctm setup # Interactive setup wizard
190199
| `/help` | Show commands |
191200
| `/ping` | Health check |
192201

202+
## Sending Images to Telegram
203+
204+
Any script or tool can send images/files to Telegram via the bridge socket:
205+
206+
```bash
207+
echo '{"type":"send_image","sessionId":"test","content":"/tmp/diagram.png","metadata":{"caption":"Architecture diagram"},"timestamp":"now"}' \
208+
| socat - UNIX-CONNECT:~/.config/claude-telegram-mirror/bridge.sock
209+
```
210+
211+
The `content` field is the absolute path to the file. Image extensions (jpg, png, gif, webp, bmp) are sent as photos; everything else is sent as a document. The optional `caption` metadata field adds a caption. If `sessionId` matches an active session, the file is posted to that session's forum topic.
212+
193213
## Configuration Reference
194214

195215
### Environment Variables
@@ -266,8 +286,8 @@ Run CTM on multiple machines with a shared Telegram group:
266286

267287
| Module | Lines | Responsibility |
268288
|--------|-------|---------------|
269-
| `bridge.rs` | ~1100 | Central orchestrator: routes messages between all components |
270-
| `bot.rs` | ~300 | Telegram API: send/receive, forums, inline keyboards, rate limiting |
289+
| `bridge.rs` | ~1500 | Central orchestrator: routes messages between all components |
290+
| `bot.rs` | ~400 | Telegram API: send/receive, forums, inline keyboards, file transfer, rate limiting |
271291
| `socket.rs` | ~250 | Unix socket server with flock PID locking, NDJSON protocol |
272292
| `session.rs` | ~250 | SQLite persistence: sessions, approvals, stale cleanup |
273293
| `formatting.rs` | ~700 | Tool summaries, message formatting, ANSI stripping, chunking |
@@ -299,7 +319,7 @@ ctm doctor --fix # Auto-fix permissions and config
299319
```bash
300320
cargo build # Debug build
301321
cargo build --release # Optimized release build (~8MB binary)
302-
cargo test # 21 tests
322+
cargo test # 23 tests
303323
cargo clippy # 0 warnings
304324
cargo fmt --check # Check formatting
305325
RUST_LOG=debug ctm start # Verbose logging

src/bot.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,27 @@ impl TelegramBot {
153153
}
154154
}
155155

156+
/// Rename a forum topic
157+
pub async fn edit_forum_topic(&self, thread_id: i32, name: &str) -> Result<bool> {
158+
self.rate_limiter.until_ready().await;
159+
160+
match self
161+
.bot
162+
.edit_forum_topic(self.chat_id, ThreadId(MessageId(thread_id)))
163+
.name(name)
164+
.await
165+
{
166+
Ok(_) => {
167+
tracing::info!(%thread_id, %name, "Forum topic renamed");
168+
Ok(true)
169+
}
170+
Err(e) => {
171+
tracing::warn!(error = %e, %thread_id, "Failed to rename forum topic");
172+
Ok(false)
173+
}
174+
}
175+
}
176+
156177
/// Close a forum topic
157178
pub async fn close_forum_topic(&self, thread_id: i32) -> Result<bool> {
158179
self.rate_limiter.until_ready().await;
@@ -255,6 +276,77 @@ impl TelegramBot {
255276

256277
Ok(updates)
257278
}
279+
280+
/// Download a Telegram file by file_id, saving to `dest_path`.
281+
/// Returns the file extension from the server-side path (e.g. "jpg", "pdf").
282+
pub async fn download_file_to(
283+
&self,
284+
file_id: &str,
285+
dest_path: &std::path::Path,
286+
) -> Result<String> {
287+
use teloxide::net::Download;
288+
289+
let file_info = self
290+
.bot
291+
.get_file(file_id)
292+
.await
293+
.map_err(|e| AppError::Telegram(scrub_telegram_error(&e)))?;
294+
295+
let ext = file_info
296+
.path
297+
.rsplit('.')
298+
.next()
299+
.unwrap_or("bin")
300+
.to_string();
301+
302+
let mut dest_file = tokio::fs::File::create(dest_path).await?;
303+
self.bot
304+
.download_file(&file_info.path, &mut dest_file)
305+
.await
306+
.map_err(|e| AppError::Telegram(format!("download: {}", e)))?;
307+
308+
Ok(ext)
309+
}
310+
311+
/// Send a photo from a local file path with optional caption and thread targeting.
312+
pub async fn send_photo(
313+
&self,
314+
path: &std::path::Path,
315+
caption: Option<&str>,
316+
thread_id: Option<i32>,
317+
) -> Result<Message> {
318+
self.rate_limiter.until_ready().await;
319+
let input_file = teloxide::types::InputFile::file(path.to_path_buf());
320+
let mut req = self.bot.send_photo(self.chat_id, input_file);
321+
if let Some(c) = caption {
322+
req = req.caption(c);
323+
}
324+
if let Some(tid) = thread_id {
325+
req = req.message_thread_id(ThreadId(MessageId(tid)));
326+
}
327+
req.await
328+
.map_err(|e| AppError::Telegram(scrub_telegram_error(&e)))
329+
}
330+
331+
/// Send a document from a local file path with optional caption and thread targeting.
332+
pub async fn send_document(
333+
&self,
334+
path: &std::path::Path,
335+
caption: Option<&str>,
336+
thread_id: Option<i32>,
337+
) -> Result<Message> {
338+
self.rate_limiter.until_ready().await;
339+
let input_file = teloxide::types::InputFile::file(path.to_path_buf());
340+
let mut req = self.bot.send_document(self.chat_id, input_file);
341+
if let Some(c) = caption {
342+
req = req.caption(c);
343+
}
344+
if let Some(tid) = thread_id {
345+
req = req.message_thread_id(ThreadId(MessageId(tid)));
346+
}
347+
req.await
348+
.map_err(|e| AppError::Telegram(scrub_telegram_error(&e)))
349+
}
258350
}
259351

260352
impl Clone for TelegramBot {

0 commit comments

Comments
 (0)