claude-code-tg 0.8.3__py3-none-any.whl

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.
Files changed (39) hide show
  1. claude_code_tg/__init__.py +0 -0
  2. claude_code_tg/attachment_cleanup.py +132 -0
  3. claude_code_tg/attachments.py +244 -0
  4. claude_code_tg/bot.py +509 -0
  5. claude_code_tg/bot_app.py +326 -0
  6. claude_code_tg/bot_commands.py +1281 -0
  7. claude_code_tg/bot_processing.py +398 -0
  8. claude_code_tg/claude_sessions.py +375 -0
  9. claude_code_tg/cli.py +456 -0
  10. claude_code_tg/cli_init.py +391 -0
  11. claude_code_tg/cli_instances.py +169 -0
  12. claude_code_tg/cli_parser.py +182 -0
  13. claude_code_tg/command_menu.py +330 -0
  14. claude_code_tg/command_view.py +107 -0
  15. claude_code_tg/config.py +235 -0
  16. claude_code_tg/diagnostics.py +517 -0
  17. claude_code_tg/executor.py +841 -0
  18. claude_code_tg/file_security.py +351 -0
  19. claude_code_tg/instance_store.py +208 -0
  20. claude_code_tg/interaction_log.py +64 -0
  21. claude_code_tg/message_input.py +142 -0
  22. claude_code_tg/message_output.py +57 -0
  23. claude_code_tg/pending_reply.py +64 -0
  24. claude_code_tg/process_control.py +58 -0
  25. claude_code_tg/py.typed +1 -0
  26. claude_code_tg/result_view.py +99 -0
  27. claude_code_tg/resume_view.py +113 -0
  28. claude_code_tg/run_view.py +618 -0
  29. claude_code_tg/sanitizer.py +45 -0
  30. claude_code_tg/server.py +142 -0
  31. claude_code_tg/sessions.py +402 -0
  32. claude_code_tg/telegram_ui.py +123 -0
  33. claude_code_tg/utils.py +133 -0
  34. claude_code_tg/web_console.py +260 -0
  35. claude_code_tg-0.8.3.dist-info/METADATA +245 -0
  36. claude_code_tg-0.8.3.dist-info/RECORD +39 -0
  37. claude_code_tg-0.8.3.dist-info/WHEEL +4 -0
  38. claude_code_tg-0.8.3.dist-info/entry_points.txt +2 -0
  39. claude_code_tg-0.8.3.dist-info/licenses/LICENSE +21 -0
claude_code_tg/bot.py ADDED
@@ -0,0 +1,509 @@
1
+ """Telegram Bot handlers."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+ import uuid
7
+ from pathlib import Path
8
+ from types import SimpleNamespace
9
+ from typing import Any, cast
10
+
11
+ from telegram import Update
12
+ from telegram.ext import Application, ContextTypes
13
+
14
+ from claude_code_tg.attachments import (
15
+ DEFAULT_ATTACHMENT_MAX_BYTES,
16
+ DEFAULT_ATTACHMENT_MODE,
17
+ PROJECT_ATTACHMENT_DIRNAME,
18
+ prune_attachment_tree,
19
+ )
20
+ from claude_code_tg.bot_app import build_telegram_app
21
+ from claude_code_tg.bot_commands import BotCommandHandlers
22
+ from claude_code_tg.bot_processing import BotMessageProcessor
23
+ from claude_code_tg.command_view import CommandPickerStore
24
+ from claude_code_tg.executor import (
25
+ Executor,
26
+ normalize_effort,
27
+ normalize_model,
28
+ normalize_permission_mode,
29
+ )
30
+ from claude_code_tg.message_input import TelegramInputBuilder
31
+ from claude_code_tg.message_output import MAX_TG_MESSAGE_LENGTH
32
+ from claude_code_tg.pending_reply import PendingReplyStore
33
+ from claude_code_tg.result_view import ResultActionStore
34
+ from claude_code_tg.resume_view import ResumePickerStore
35
+ from claude_code_tg.run_view import RunViewStore
36
+ from claude_code_tg.sessions import ChatSessionStore, ReplyCallback
37
+ from claude_code_tg.utils import _format_uptime
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ __all__ = ["MAX_TG_MESSAGE_LENGTH", "TGBot"]
42
+
43
+ HEARTBEAT_LOG_INTERVAL = 10
44
+
45
+
46
+ class TGBot(BotMessageProcessor, BotCommandHandlers):
47
+ def __init__(
48
+ self,
49
+ token: str,
50
+ admin_ids: set[int],
51
+ allowed_ids: set[int],
52
+ project_dir: str,
53
+ timeout: int = 300,
54
+ queue_max_size: int = 3,
55
+ permission_mode: str | None = None,
56
+ model: str | None = None,
57
+ effort: str | None = None,
58
+ attachment_dir: Path | None = None,
59
+ attachment_max_bytes: int = DEFAULT_ATTACHMENT_MAX_BYTES,
60
+ attachment_mode: str = DEFAULT_ATTACHMENT_MODE,
61
+ attachment_retention_days: float | None = None,
62
+ command_menu_enabled: bool = False,
63
+ draft_preview_enabled: bool = False,
64
+ mini_app_enabled: bool = False,
65
+ mini_app_public_url: str = "",
66
+ mini_app_host: str = "127.0.0.1",
67
+ mini_app_port: int = 8787,
68
+ mini_app_menu_text: str = "tgcc",
69
+ cli_resume_compat: bool = False,
70
+ status_file: Path | None = None,
71
+ ) -> None:
72
+ self.token = token
73
+ self.admin_ids = admin_ids
74
+ self.allowed_ids = allowed_ids | admin_ids
75
+ self.project_dir = project_dir
76
+ self.timeout = timeout
77
+ default_attachment_dir = (
78
+ status_file.parent / "attachments"
79
+ if status_file
80
+ else Path.home() / ".tgcc" / "attachments"
81
+ )
82
+ self.input_builder = TelegramInputBuilder(
83
+ attachment_dir=attachment_dir or default_attachment_dir,
84
+ project_dir=project_dir,
85
+ attachment_max_bytes=attachment_max_bytes,
86
+ attachment_mode=attachment_mode,
87
+ )
88
+ self.attachment_dir = self.input_builder.attachment_dir
89
+ self.attachment_max_bytes = self.input_builder.attachment_max_bytes
90
+ self.attachment_mode = self.input_builder.attachment_mode
91
+ self.attachment_retention_days = attachment_retention_days
92
+ self.command_menu_enabled = command_menu_enabled
93
+ self.draft_preview_enabled = draft_preview_enabled
94
+ self.mini_app_enabled = mini_app_enabled
95
+ self.mini_app_public_url = mini_app_public_url
96
+ self.mini_app_host = mini_app_host
97
+ self.mini_app_port = mini_app_port
98
+ self.mini_app_menu_text = mini_app_menu_text
99
+ self.cli_resume_compat_enabled = cli_resume_compat
100
+ self._mini_app_server: object | None = None
101
+ self._mini_app_task: asyncio.Task[Any] | None = None
102
+ self.command_menu_cache_file = (
103
+ status_file.parent / "command-menu.json" if status_file else None
104
+ )
105
+ self.run_views = RunViewStore()
106
+ self.resume_pickers = ResumePickerStore()
107
+ self.command_pickers = CommandPickerStore()
108
+ self.result_actions = ResultActionStore()
109
+ self.pending_replies = PendingReplyStore()
110
+ self.last_prompts: dict[int, str] = {}
111
+
112
+ self.executor = Executor()
113
+ # tg-safe command name -> Claude slash command; filled in post_init.
114
+ self.claude_command_map: dict[str, str] = {}
115
+ self.state = ChatSessionStore(
116
+ queue_max_size=queue_max_size,
117
+ permission_mode=permission_mode,
118
+ model=model,
119
+ effort=effort,
120
+ status_file=status_file,
121
+ )
122
+ self.queue_max_size = self.state.queue_max_size
123
+ self.default_permission_mode = self.state.default_permission_mode
124
+ self.default_model = self.state.default_model
125
+ self.default_effort = self.state.default_effort
126
+ self.sessions = self.state.sessions
127
+ self.permission_modes = self.state.permission_modes
128
+ self.model_overrides = self.state.model_overrides
129
+ self.effort_overrides = self.state.effort_overrides
130
+ self._session_versions = self.state.session_versions
131
+ self.busy = self.state.busy
132
+ self.queues = self.state.queues
133
+
134
+ @property
135
+ def queue_max_size(self) -> int:
136
+ return self.state.queue_max_size
137
+
138
+ @queue_max_size.setter
139
+ def queue_max_size(self, value: int) -> None:
140
+ self.state.queue_max_size = max(value, 1)
141
+
142
+ @property
143
+ def default_permission_mode(self) -> str | None:
144
+ return self.state.default_permission_mode
145
+
146
+ @default_permission_mode.setter
147
+ def default_permission_mode(self, value: str | None) -> None:
148
+ self.state.default_permission_mode = normalize_permission_mode(value)
149
+
150
+ @property
151
+ def default_model(self) -> str | None:
152
+ return self.state.default_model
153
+
154
+ @default_model.setter
155
+ def default_model(self, value: str | None) -> None:
156
+ self.state.default_model = normalize_model(value)
157
+
158
+ @property
159
+ def default_effort(self) -> str | None:
160
+ return self.state.default_effort
161
+
162
+ @default_effort.setter
163
+ def default_effort(self, value: str | None) -> None:
164
+ self.state.default_effort = normalize_effort(value)
165
+
166
+ @property
167
+ def status_file(self) -> Path | None:
168
+ return self.state.status_file
169
+
170
+ @status_file.setter
171
+ def status_file(self, value: Path | None) -> None:
172
+ self.state.status_file = value
173
+
174
+ @property
175
+ def _start_time(self) -> float:
176
+ return self.state.start_time
177
+
178
+ @_start_time.setter
179
+ def _start_time(self, value: float) -> None:
180
+ self.state.start_time = value
181
+
182
+ @property
183
+ def _heartbeat_counter(self) -> int:
184
+ return self.state.heartbeat_counter
185
+
186
+ @_heartbeat_counter.setter
187
+ def _heartbeat_counter(self, value: int) -> None:
188
+ self.state.heartbeat_counter = value
189
+
190
+ def _is_authorized(self, user_id: int) -> bool:
191
+ return user_id in self.allowed_ids
192
+
193
+ def _write_status(self) -> None:
194
+ """Write current bot status to JSON file for `tgcc status` consumption."""
195
+ error = self.state.write_status()
196
+ if error:
197
+ logger.debug("Failed to write status file: %s", error)
198
+
199
+ def _restore_sessions(self) -> None:
200
+ """Restore sessions from status.json after restart."""
201
+ restored = self.state.restore_sessions()
202
+ if restored:
203
+ logger.info(f"Restored {restored} session(s) from status file")
204
+
205
+ def _record_periodic_status(self) -> None:
206
+ """Write status and periodically emit a lightweight heartbeat log."""
207
+ self._write_status()
208
+ self._heartbeat_counter += 1
209
+ if self._heartbeat_counter < HEARTBEAT_LOG_INTERVAL:
210
+ return
211
+ self._heartbeat_counter = 0
212
+ queue_total = self.state.queue_total()
213
+ uptime = int(time.time() - self._start_time)
214
+ logger.info(
215
+ "Heartbeat | sessions=%d busy=%d queue=%d uptime=%s",
216
+ len(self.sessions),
217
+ len(self.busy),
218
+ queue_total,
219
+ _format_uptime(uptime),
220
+ )
221
+
222
+ def _attachment_cleanup_roots(self) -> list[tuple[str, Path]]:
223
+ roots = [
224
+ ("instance", self.attachment_dir),
225
+ (
226
+ "project",
227
+ Path(self.project_dir).expanduser().resolve(strict=False)
228
+ / PROJECT_ATTACHMENT_DIRNAME,
229
+ ),
230
+ ]
231
+ seen: set[str] = set()
232
+ unique_roots: list[tuple[str, Path]] = []
233
+ for label, root in roots:
234
+ root_key = str(root.expanduser().resolve(strict=False))
235
+ if root_key in seen:
236
+ continue
237
+ seen.add(root_key)
238
+ unique_roots.append((label, root))
239
+ return unique_roots
240
+
241
+ def _run_attachment_retention_cleanup(self) -> tuple[int, int, int]:
242
+ """Prune old attachment files when automatic retention is configured."""
243
+ if self.attachment_retention_days is None:
244
+ return (0, 0, 0)
245
+
246
+ older_than_seconds = self.attachment_retention_days * 86400
247
+ total_files = 0
248
+ total_bytes = 0
249
+ total_errors = 0
250
+ for label, root in self._attachment_cleanup_roots():
251
+ result = prune_attachment_tree(
252
+ root,
253
+ older_than_seconds=older_than_seconds,
254
+ dry_run=False,
255
+ )
256
+ total_files += result.files
257
+ total_bytes += result.byte_count
258
+ total_errors += len(result.errors)
259
+ for error in result.errors:
260
+ logger.warning("Attachment cleanup warning | scope=%s %s", label, error)
261
+ if result.root_exists and (result.files or result.dirs_removed):
262
+ logger.info(
263
+ "Attachment cleanup | scope=%s files=%d bytes=%d dirs=%d",
264
+ label,
265
+ result.files,
266
+ result.byte_count,
267
+ result.dirs_removed,
268
+ )
269
+ if total_errors:
270
+ logger.warning(
271
+ "Attachment cleanup completed with %d warning(s)", total_errors
272
+ )
273
+ return (total_files, total_bytes, total_errors)
274
+
275
+ def _get_or_create_session(self, chat_id: int) -> tuple[str | None, bool]:
276
+ """Returns (session_id, is_existing). None means new session."""
277
+ return self.state.get_or_create_session(chat_id)
278
+
279
+ def _effective_permission_mode(self, chat_id: int) -> str | None:
280
+ return self.state.effective_permission_mode(chat_id)
281
+
282
+ def _permission_mode_label(self, chat_id: int) -> str:
283
+ return self.state.permission_mode_label(chat_id)
284
+
285
+ def _effective_model(self, chat_id: int) -> str | None:
286
+ return self.state.effective_model(chat_id)
287
+
288
+ def _model_label(self, chat_id: int) -> str:
289
+ return self.state.model_label(chat_id)
290
+
291
+ def _effective_effort(self, chat_id: int) -> str | None:
292
+ return self.state.effective_effort(chat_id)
293
+
294
+ def _effort_label(self, chat_id: int) -> str:
295
+ return self.state.effort_label(chat_id)
296
+
297
+ @staticmethod
298
+ def _normalize_session_id(session_id: str) -> str | None:
299
+ """Return a canonical Claude session UUID, or None if invalid."""
300
+ try:
301
+ return str(uuid.UUID(session_id.strip()))
302
+ except (AttributeError, ValueError):
303
+ return None
304
+
305
+ async def _try_enqueue(
306
+ self,
307
+ chat_id: int,
308
+ user_id: int,
309
+ prompt: str,
310
+ reply_fn: ReplyCallback,
311
+ ) -> bool:
312
+ """Try to enqueue a message. Returns True if enqueued, False if should process immediately."""
313
+ result = await self.state.try_enqueue(chat_id, user_id, prompt, reply_fn)
314
+ self._write_status()
315
+ return result
316
+
317
+ async def _prompt_from_update(
318
+ self, update: Update, context: ContextTypes.DEFAULT_TYPE
319
+ ) -> str:
320
+ return await self.input_builder.prompt_from_update(update, context)
321
+
322
+ async def handle_message(
323
+ self, update: Update, context: ContextTypes.DEFAULT_TYPE
324
+ ) -> None:
325
+ if not update.message or not update.effective_user or not update.effective_chat:
326
+ return
327
+ user_id = update.effective_user.id
328
+ chat = update.effective_chat
329
+ chat_id = chat.id
330
+
331
+ if not self._is_authorized(user_id):
332
+ return
333
+
334
+ # Group chat: only respond to @bot or reply-to-bot
335
+ if chat.type != "private":
336
+ bot_username = context.bot.username
337
+ msg = update.message
338
+ msg_text = msg.text or msg.caption or ""
339
+ is_mention = bot_username and f"@{bot_username}" in msg_text
340
+ is_reply_to_bot = (
341
+ msg.reply_to_message
342
+ and msg.reply_to_message.from_user
343
+ and msg.reply_to_message.from_user.id == context.bot.id
344
+ )
345
+ if not is_mention and not is_reply_to_bot:
346
+ return
347
+
348
+ try:
349
+ prompt = await self._prompt_from_update(update, context)
350
+ except ValueError as e:
351
+ await update.message.reply_text(f"⚠️ {e}")
352
+ return
353
+ except Exception:
354
+ logger.exception("Failed to download Telegram attachment")
355
+ await update.message.reply_text("❌ 附件下载失败,请稍后重试。")
356
+ return
357
+
358
+ if not prompt:
359
+ return
360
+
361
+ if await self._try_enqueue(chat_id, user_id, prompt, update.message.reply_text):
362
+ return
363
+
364
+ await self._process_message(chat_id, user_id, prompt, context)
365
+
366
+ def build_app(self) -> Application:
367
+ return build_telegram_app(self)
368
+
369
+ async def start_mini_app(self, telegram_bot: Any) -> None:
370
+ """Start the optional Mini App web console alongside polling."""
371
+ if not self.mini_app_enabled:
372
+ return
373
+ try:
374
+ import uvicorn
375
+
376
+ from claude_code_tg.web_console import build_web_console_app
377
+ except ImportError as exc:
378
+ raise RuntimeError(
379
+ "Mini App support requires `uvicorn` and `starlette`; "
380
+ "install with `uv sync --extra mini-app`."
381
+ ) from exc
382
+
383
+ app = build_web_console_app(self, telegram_bot)
384
+ config = uvicorn.Config(
385
+ cast(Any, app),
386
+ host=self.mini_app_host,
387
+ port=self.mini_app_port,
388
+ log_level="info",
389
+ lifespan="off",
390
+ )
391
+ server = uvicorn.Server(config)
392
+ self._mini_app_server = server
393
+ self._mini_app_task = asyncio.create_task(server.serve())
394
+
395
+ async def stop_mini_app(self) -> None:
396
+ task = self._mini_app_task
397
+ server = self._mini_app_server
398
+ if server is not None and hasattr(server, "should_exit"):
399
+ cast(Any, server).should_exit = True
400
+ if task is not None:
401
+ from contextlib import suppress
402
+
403
+ with suppress(asyncio.CancelledError):
404
+ await task
405
+ self._mini_app_task = None
406
+ self._mini_app_server = None
407
+
408
+ def mini_app_status(self, chat_id: int) -> dict[str, object]:
409
+ queue_len = len(self.queues.get(chat_id, []))
410
+ session_id = self.sessions.get(chat_id)
411
+ latest = self.run_views.latest(chat_id)
412
+ current_tool = latest.current_tool if latest else None
413
+ return {
414
+ "chat_id": chat_id,
415
+ "busy": chat_id in self.busy,
416
+ "session_id": session_id,
417
+ "queue": {"current": queue_len, "max": self.queue_max_size},
418
+ "permission_mode": self._permission_mode_label(chat_id),
419
+ "model": self._model_label(chat_id),
420
+ "effort": self._effort_label(chat_id),
421
+ "attachment": self._attachment_config_label(),
422
+ "last_prompt_available": chat_id in self.last_prompts,
423
+ "latest_run": None
424
+ if latest is None
425
+ else {
426
+ "run_id": latest.run_id,
427
+ "status": latest.status,
428
+ "task": latest.task_summary,
429
+ "tool_count": latest.tool_count,
430
+ "current_tool": None
431
+ if current_tool is None
432
+ else {
433
+ "index": current_tool.index,
434
+ "name": current_tool.name,
435
+ "summary": current_tool.summary,
436
+ "output": current_tool.output,
437
+ "is_error": current_tool.is_error,
438
+ },
439
+ "latest_output": latest.latest_output,
440
+ },
441
+ }
442
+
443
+ async def handle_mini_app_action(
444
+ self,
445
+ chat_id: int,
446
+ user_id: int,
447
+ action: str,
448
+ payload: dict[str, object],
449
+ telegram_bot: Any,
450
+ ) -> dict[str, object]:
451
+ if not self._is_authorized(user_id):
452
+ return {"ok": False, "error": "unauthorized"}
453
+ if action == "stop":
454
+ return {"ok": True, "stopped": await self.executor.stop(chat_id)}
455
+ if action == "new":
456
+ dropped = self.state.reset_chat(chat_id)
457
+ stopped = (
458
+ await self.executor.stop(chat_id) if chat_id in self.busy else False
459
+ )
460
+ self._write_status()
461
+ return {"ok": True, "stopped": stopped, "dropped": dropped}
462
+ if action == "resume":
463
+ session_id = self._normalize_session_id(str(payload.get("session_id", "")))
464
+ if not session_id:
465
+ return {"ok": False, "error": "invalid_session_id"}
466
+ message_text = await self._attach_session_text(chat_id, session_id)
467
+ return {"ok": True, "message": message_text}
468
+ if action == "set_model":
469
+ model_text = self._apply_model_choice(
470
+ chat_id, str(payload.get("model", ""))
471
+ )
472
+ if model_text is None:
473
+ return {"ok": False, "error": "invalid_model"}
474
+ self._write_status()
475
+ return {"ok": True, "message": model_text}
476
+ if action == "set_permissions":
477
+ permission_text = self._apply_permission_choice(
478
+ chat_id, str(payload.get("mode", ""))
479
+ )
480
+ if permission_text is None:
481
+ return {"ok": False, "error": "invalid_permission_mode"}
482
+ self._write_status()
483
+ return {"ok": True, "message": permission_text}
484
+ if action == "set_effort":
485
+ effort_text = self._apply_effort_choice(
486
+ chat_id, str(payload.get("effort", ""))
487
+ )
488
+ if effort_text is None:
489
+ return {"ok": False, "error": "invalid_effort"}
490
+ self._write_status()
491
+ return {"ok": True, "message": effort_text}
492
+ if action == "rerun":
493
+ prompt = self.last_prompts.get(chat_id)
494
+ if not prompt:
495
+ return {"ok": False, "error": "no_last_prompt"}
496
+
497
+ async def reply_enqueue(text: str) -> None:
498
+ await telegram_bot.send_message(chat_id=chat_id, text=text)
499
+
500
+ if await self._try_enqueue(chat_id, user_id, prompt, reply_enqueue):
501
+ return {"ok": True, "queued": True}
502
+ context = cast(ContextTypes.DEFAULT_TYPE, SimpleNamespace(bot=telegram_bot))
503
+ await self._process_message(chat_id, user_id, prompt, context)
504
+ return {"ok": True, "queued": False}
505
+ return {"ok": False, "error": "unknown_action"}
506
+
507
+ def run(self) -> None:
508
+ app = self.build_app()
509
+ app.run_polling(drop_pending_updates=True)