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
File without changes
@@ -0,0 +1,132 @@
1
+ """CLI-facing attachment cleanup helpers."""
2
+
3
+ import argparse
4
+ import math
5
+ import sys
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+
9
+ from claude_code_tg.attachments import (
10
+ PROJECT_ATTACHMENT_DIRNAME,
11
+ AttachmentPruneResult,
12
+ prune_attachment_tree,
13
+ )
14
+ from claude_code_tg.instance_store import instance_paths
15
+ from claude_code_tg.utils import discover_env_files, read_env_value
16
+
17
+
18
+ def format_bytes(value: int) -> str:
19
+ units = ("B", "KiB", "MiB", "GiB")
20
+ amount = float(value)
21
+ for unit in units:
22
+ if amount < 1024 or unit == units[-1]:
23
+ if unit == "B":
24
+ return f"{int(amount)} {unit}"
25
+ return f"{amount:.1f} {unit}"
26
+ amount /= 1024
27
+ return f"{value} B"
28
+
29
+
30
+ def positive_float(value: str) -> float:
31
+ try:
32
+ parsed = float(value)
33
+ except ValueError:
34
+ raise argparse.ArgumentTypeError("must be a number") from None
35
+ if not math.isfinite(parsed):
36
+ raise argparse.ArgumentTypeError("must be a finite number")
37
+ if parsed < 0:
38
+ raise argparse.ArgumentTypeError("must be greater than or equal to 0")
39
+ return parsed
40
+
41
+
42
+ def project_attachment_root(env_file: Path, project_dir: str | None) -> Path:
43
+ if project_dir:
44
+ root = Path(project_dir)
45
+ else:
46
+ env_project_dir = read_env_value(env_file, "CLAUDE_PROJECT_DIR")
47
+ root = Path(env_project_dir or ".")
48
+ return root.expanduser() / PROJECT_ATTACHMENT_DIRNAME
49
+
50
+
51
+ def attachment_roots_for_env(
52
+ env_file: Path, *, scope: str, project_dir: str | None
53
+ ) -> list[tuple[str, Path]]:
54
+ roots: list[tuple[str, Path]] = []
55
+ if scope in {"all", "instance"}:
56
+ _, logfile = instance_paths(str(env_file), create=False)
57
+ roots.append(
58
+ (f"{env_file.name} instance attachments", logfile.parent / "attachments")
59
+ )
60
+ if scope in {"all", "project"}:
61
+ roots.append(
62
+ (
63
+ f"{env_file.name} project attachments",
64
+ project_attachment_root(env_file, project_dir),
65
+ )
66
+ )
67
+ return roots
68
+
69
+
70
+ def print_prune_result(label: str, result: AttachmentPruneResult) -> None:
71
+ action = "Would delete" if result.dry_run else "Deleted"
72
+ if not result.root_exists:
73
+ print(f"{label}: no attachment directory at {result.root}")
74
+ for error in result.errors:
75
+ print(f"{label}: warning: {error}")
76
+ return
77
+ print(
78
+ f"{label}: {action} {result.files} files "
79
+ f"({format_bytes(result.byte_count)}) from {result.root}"
80
+ )
81
+ if result.dirs_removed:
82
+ print(f"{label}: removed {result.dirs_removed} empty directories")
83
+ for error in result.errors:
84
+ print(f"{label}: warning: {error}")
85
+
86
+
87
+ def run_attachment_prune(
88
+ args: argparse.Namespace,
89
+ *,
90
+ resolve_single_env: Callable[[str | None], Path],
91
+ ) -> None:
92
+ if args.all_envs and args.env:
93
+ print("Error: use either --all-envs or --env, not both.")
94
+ sys.exit(1)
95
+
96
+ if args.all_envs:
97
+ env_files = discover_env_files()
98
+ if not env_files:
99
+ print("No .env files found in current directory.")
100
+ return
101
+ else:
102
+ env_files = [resolve_single_env(args.env)]
103
+
104
+ older_than_seconds = None if args.all_files else args.older_than_days * 86400
105
+ seen_roots: set[str] = set()
106
+ total_files = 0
107
+ total_bytes = 0
108
+ total_errors = 0
109
+ for env_file in env_files:
110
+ for label, root in attachment_roots_for_env(
111
+ env_file,
112
+ scope=args.scope,
113
+ project_dir=args.project_dir,
114
+ ):
115
+ root_key = str(root.expanduser().resolve(strict=False))
116
+ if root_key in seen_roots:
117
+ continue
118
+ seen_roots.add(root_key)
119
+ result = prune_attachment_tree(
120
+ root,
121
+ older_than_seconds=older_than_seconds,
122
+ dry_run=args.dry_run,
123
+ )
124
+ print_prune_result(label, result)
125
+ total_files += result.files
126
+ total_bytes += result.byte_count
127
+ total_errors += len(result.errors)
128
+
129
+ action = "would delete" if args.dry_run else "deleted"
130
+ print(f"Summary: {action} {total_files} files ({format_bytes(total_bytes)})")
131
+ if total_errors:
132
+ print(f"Summary: {total_errors} warnings")
@@ -0,0 +1,244 @@
1
+ """Attachment storage helpers."""
2
+
3
+ import os
4
+ import re
5
+ import stat
6
+ import time
7
+ import uuid
8
+ from contextlib import suppress
9
+ from dataclasses import dataclass
10
+ from math import isfinite
11
+ from pathlib import Path
12
+
13
+ from claude_code_tg.file_security import (
14
+ _raise_if_path_replaced,
15
+ _unlink_created_file,
16
+ ensure_owner_only_dir,
17
+ open_rejecting_symlink_read_bytes,
18
+ rejectable_symlink_path_component,
19
+ )
20
+
21
+ DEFAULT_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024
22
+ DEFAULT_ATTACHMENT_MODE = "path"
23
+ DEFAULT_ATTACHMENT_PROMPT = "请分析这个附件。"
24
+ PROJECT_ATTACHMENT_DIRNAME = ".tgcc-attachments"
25
+ VALID_ATTACHMENT_MODES = {"path", "copy-to-project", "reject"}
26
+ _NOFOLLOW_FLAG = getattr(os, "O_NOFOLLOW", 0)
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class AttachmentInfo:
31
+ kind: str
32
+ path: Path
33
+ original_name: str
34
+ size: int | None = None
35
+ mode: str = DEFAULT_ATTACHMENT_MODE
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class AttachmentPruneResult:
40
+ root: Path
41
+ root_exists: bool
42
+ files: int
43
+ byte_count: int
44
+ dirs_removed: int
45
+ errors: tuple[str, ...] = ()
46
+ dry_run: bool = False
47
+
48
+
49
+ def normalize_attachment_mode(value: str | None) -> str:
50
+ """Return a canonical attachment handling mode."""
51
+ if value is None:
52
+ return DEFAULT_ATTACHMENT_MODE
53
+ mode = value.strip().lower().replace("_", "-")
54
+ if not mode:
55
+ return DEFAULT_ATTACHMENT_MODE
56
+ if mode not in VALID_ATTACHMENT_MODES:
57
+ raise ValueError(f"invalid attachment mode: {value}")
58
+ return mode
59
+
60
+
61
+ def normalize_attachment_retention_days(value: str | None) -> float | None:
62
+ """Return an optional attachment retention window in days.
63
+
64
+ Empty values and zero disable automatic cleanup. Negative values are invalid.
65
+ """
66
+ if value is None:
67
+ return None
68
+ raw_value = value.strip().lower()
69
+ if not raw_value or raw_value in {"0", "false", "off", "none", "disabled"}:
70
+ return None
71
+ try:
72
+ days = float(raw_value)
73
+ except ValueError:
74
+ raise ValueError(f"invalid attachment retention days: {value}") from None
75
+ if not isfinite(days) or days < 0:
76
+ raise ValueError(f"invalid attachment retention days: {value}")
77
+ if days == 0:
78
+ return None
79
+ return days
80
+
81
+
82
+ def safe_filename(filename: str) -> str:
83
+ name = Path(filename).name.strip() or "attachment"
84
+ name = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip(".-")
85
+ return name[:120] or "attachment"
86
+
87
+
88
+ def unique_attachment_path(base_dir: Path, chat_id: int, filename: str) -> Path:
89
+ ensure_owner_only_dir(base_dir)
90
+ chat_dir = base_dir / str(chat_id)
91
+ ensure_owner_only_dir(chat_dir)
92
+ stamp = time.strftime("%Y%m%d-%H%M%S")
93
+ return chat_dir / f"{stamp}-{uuid.uuid4().hex[:8]}-{safe_filename(filename)}"
94
+
95
+
96
+ def copy_attachment_to_project(
97
+ source: Path, project_dir: str | Path, chat_id: int
98
+ ) -> Path:
99
+ """Copy a downloaded attachment into the project workspace with 0600 mode."""
100
+ symlink = rejectable_symlink_path_component(source)
101
+ if symlink:
102
+ raise OSError(f"{symlink} is a symlink")
103
+ target = unique_attachment_path(
104
+ Path(project_dir) / PROJECT_ATTACHMENT_DIRNAME,
105
+ chat_id,
106
+ source.name,
107
+ )
108
+ fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | _NOFOLLOW_FLAG, 0o600)
109
+ created_stat = os.fstat(fd)
110
+ try:
111
+ if hasattr(os, "fchmod"):
112
+ os.fchmod(fd, 0o600)
113
+ with (
114
+ open_rejecting_symlink_read_bytes(source) as src,
115
+ os.fdopen(fd, "wb") as dst,
116
+ ):
117
+ fd = -1
118
+ while chunk := src.read(1024 * 1024):
119
+ dst.write(chunk)
120
+ dst.flush()
121
+ os.fsync(dst.fileno())
122
+ _raise_if_path_replaced(target, dst.fileno())
123
+ except Exception:
124
+ if fd != -1:
125
+ with suppress(OSError):
126
+ os.close(fd)
127
+ _unlink_created_file(target, created_stat)
128
+ raise
129
+ return target
130
+
131
+
132
+ def prune_attachment_tree(
133
+ root: Path,
134
+ *,
135
+ older_than_seconds: float | None,
136
+ dry_run: bool = False,
137
+ now: float | None = None,
138
+ ) -> AttachmentPruneResult:
139
+ """Delete attachment files below root that are older than the retention window."""
140
+ root = root.expanduser()
141
+ symlink = rejectable_symlink_path_component(root)
142
+ if symlink:
143
+ return AttachmentPruneResult(
144
+ root=root,
145
+ root_exists=root.exists() or root.is_symlink(),
146
+ files=0,
147
+ byte_count=0,
148
+ dirs_removed=0,
149
+ errors=(f"{symlink}: symlink root skipped",),
150
+ dry_run=dry_run,
151
+ )
152
+
153
+ errors: list[str] = []
154
+ if not root.exists():
155
+ return AttachmentPruneResult(
156
+ root=root,
157
+ root_exists=False,
158
+ files=0,
159
+ byte_count=0,
160
+ dirs_removed=0,
161
+ dry_run=dry_run,
162
+ )
163
+
164
+ try:
165
+ root_info = root.lstat()
166
+ except OSError as exc:
167
+ return AttachmentPruneResult(
168
+ root=root,
169
+ root_exists=True,
170
+ files=0,
171
+ byte_count=0,
172
+ dirs_removed=0,
173
+ errors=(f"{root}: {exc}",),
174
+ dry_run=dry_run,
175
+ )
176
+ if not stat.S_ISDIR(root_info.st_mode):
177
+ return AttachmentPruneResult(
178
+ root=root,
179
+ root_exists=True,
180
+ files=0,
181
+ byte_count=0,
182
+ dirs_removed=0,
183
+ errors=(f"{root}: not a directory",),
184
+ dry_run=dry_run,
185
+ )
186
+
187
+ cutoff = None
188
+ if older_than_seconds is not None:
189
+ cutoff = (time.time() if now is None else now) - older_than_seconds
190
+
191
+ files = 0
192
+ byte_count = 0
193
+ for path in root.rglob("*"):
194
+ try:
195
+ info = path.lstat()
196
+ except OSError as exc:
197
+ errors.append(f"{path}: {exc}")
198
+ continue
199
+ if stat.S_ISLNK(info.st_mode):
200
+ errors.append(f"{path}: symlink skipped")
201
+ continue
202
+ if stat.S_ISDIR(info.st_mode):
203
+ continue
204
+ if cutoff is not None and info.st_mtime > cutoff:
205
+ continue
206
+
207
+ if dry_run:
208
+ files += 1
209
+ byte_count += info.st_size
210
+ continue
211
+ try:
212
+ path.unlink()
213
+ except OSError as exc:
214
+ errors.append(f"{path}: {exc}")
215
+ else:
216
+ files += 1
217
+ byte_count += info.st_size
218
+
219
+ dirs_removed = 0
220
+ if not dry_run:
221
+ dirs = []
222
+ for path in root.rglob("*"):
223
+ try:
224
+ info = path.lstat()
225
+ except OSError:
226
+ continue
227
+ if stat.S_ISDIR(info.st_mode):
228
+ dirs.append(path)
229
+ for directory in sorted(dirs, key=lambda item: len(item.parts), reverse=True):
230
+ try:
231
+ directory.rmdir()
232
+ except OSError:
233
+ continue
234
+ dirs_removed += 1
235
+
236
+ return AttachmentPruneResult(
237
+ root=root,
238
+ root_exists=True,
239
+ files=files,
240
+ byte_count=byte_count,
241
+ dirs_removed=dirs_removed,
242
+ errors=tuple(errors),
243
+ dry_run=dry_run,
244
+ )