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.
- claude_code_tg/__init__.py +0 -0
- claude_code_tg/attachment_cleanup.py +132 -0
- claude_code_tg/attachments.py +244 -0
- claude_code_tg/bot.py +509 -0
- claude_code_tg/bot_app.py +326 -0
- claude_code_tg/bot_commands.py +1281 -0
- claude_code_tg/bot_processing.py +398 -0
- claude_code_tg/claude_sessions.py +375 -0
- claude_code_tg/cli.py +456 -0
- claude_code_tg/cli_init.py +391 -0
- claude_code_tg/cli_instances.py +169 -0
- claude_code_tg/cli_parser.py +182 -0
- claude_code_tg/command_menu.py +330 -0
- claude_code_tg/command_view.py +107 -0
- claude_code_tg/config.py +235 -0
- claude_code_tg/diagnostics.py +517 -0
- claude_code_tg/executor.py +841 -0
- claude_code_tg/file_security.py +351 -0
- claude_code_tg/instance_store.py +208 -0
- claude_code_tg/interaction_log.py +64 -0
- claude_code_tg/message_input.py +142 -0
- claude_code_tg/message_output.py +57 -0
- claude_code_tg/pending_reply.py +64 -0
- claude_code_tg/process_control.py +58 -0
- claude_code_tg/py.typed +1 -0
- claude_code_tg/result_view.py +99 -0
- claude_code_tg/resume_view.py +113 -0
- claude_code_tg/run_view.py +618 -0
- claude_code_tg/sanitizer.py +45 -0
- claude_code_tg/server.py +142 -0
- claude_code_tg/sessions.py +402 -0
- claude_code_tg/telegram_ui.py +123 -0
- claude_code_tg/utils.py +133 -0
- claude_code_tg/web_console.py +260 -0
- claude_code_tg-0.8.3.dist-info/METADATA +245 -0
- claude_code_tg-0.8.3.dist-info/RECORD +39 -0
- claude_code_tg-0.8.3.dist-info/WHEEL +4 -0
- claude_code_tg-0.8.3.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|