televault 0.1.0__py3-none-any.whl → 2.0.0__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.
- televault/__init__.py +1 -1
- televault/chunker.py +29 -27
- televault/cli.py +237 -90
- televault/compress.py +59 -23
- televault/config.py +16 -17
- televault/core.py +140 -203
- televault/crypto.py +26 -33
- televault/models.py +29 -30
- televault/telegram.py +136 -107
- televault/tui.py +632 -0
- televault-2.0.0.dist-info/METADATA +310 -0
- televault-2.0.0.dist-info/RECORD +14 -0
- {televault-0.1.0.dist-info → televault-2.0.0.dist-info}/entry_points.txt +1 -0
- televault-0.1.0.dist-info/METADATA +0 -242
- televault-0.1.0.dist-info/RECORD +0 -13
- {televault-0.1.0.dist-info → televault-2.0.0.dist-info}/WHEEL +0 -0
televault/telegram.py
CHANGED
|
@@ -1,169 +1,187 @@
|
|
|
1
1
|
"""Telegram MTProto client wrapper for TeleVault."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import io
|
|
4
6
|
import json
|
|
5
|
-
from
|
|
6
|
-
from typing import Optional, AsyncIterator
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
7
8
|
from dataclasses import dataclass
|
|
8
|
-
import io
|
|
9
9
|
|
|
10
10
|
from telethon import TelegramClient
|
|
11
|
+
from telethon.errors import FloodWaitError
|
|
11
12
|
from telethon.sessions import StringSession
|
|
12
13
|
from telethon.tl.types import (
|
|
13
14
|
Channel,
|
|
14
|
-
Message,
|
|
15
15
|
DocumentAttributeFilename,
|
|
16
|
-
|
|
16
|
+
Message,
|
|
17
17
|
)
|
|
18
|
-
from telethon.tl.functions.messages import GetPinnedDialogsRequest
|
|
19
|
-
from telethon.errors import FloodWaitError
|
|
20
|
-
|
|
21
|
-
from .models import FileMetadata, VaultIndex, ChunkInfo, TransferProgress
|
|
22
|
-
from .config import Config, get_config_dir
|
|
23
18
|
|
|
19
|
+
from .config import get_config_dir
|
|
20
|
+
from .models import FileMetadata, VaultIndex
|
|
24
21
|
|
|
25
22
|
# TeleVault Telegram app credentials
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
# Users must provide their own from https://my.telegram.org
|
|
24
|
+
# Set via environment variables:
|
|
25
|
+
# export TELEGRAM_API_ID=your_api_id
|
|
26
|
+
# export TELEGRAM_API_HASH=your_api_hash
|
|
28
27
|
|
|
29
28
|
|
|
30
29
|
@dataclass
|
|
31
30
|
class TelegramConfig:
|
|
32
31
|
"""Telegram connection configuration."""
|
|
33
|
-
|
|
32
|
+
|
|
34
33
|
api_id: int
|
|
35
34
|
api_hash: str
|
|
36
|
-
session_string:
|
|
37
|
-
|
|
35
|
+
session_string: str | None = None
|
|
36
|
+
|
|
38
37
|
@classmethod
|
|
39
38
|
def from_env(cls) -> "TelegramConfig":
|
|
40
39
|
"""Load from environment or config file."""
|
|
41
40
|
import os
|
|
42
|
-
|
|
41
|
+
|
|
43
42
|
config_path = get_config_dir() / "telegram.json"
|
|
44
|
-
|
|
43
|
+
|
|
45
44
|
if config_path.exists():
|
|
46
45
|
with open(config_path) as f:
|
|
47
46
|
data = json.load(f)
|
|
48
47
|
return cls(**data)
|
|
49
|
-
|
|
48
|
+
|
|
49
|
+
# Load from environment variables (required)
|
|
50
|
+
api_id = os.environ.get("TELEGRAM_API_ID")
|
|
51
|
+
api_hash = os.environ.get("TELEGRAM_API_HASH")
|
|
52
|
+
|
|
53
|
+
if not api_id or not api_hash:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"Telegram API credentials not found.\n"
|
|
56
|
+
"Please set environment variables:\n"
|
|
57
|
+
" export TELEGRAM_API_ID=your_api_id\n"
|
|
58
|
+
" export TELEGRAM_API_HASH=your_api_hash\n\n"
|
|
59
|
+
"Get your credentials at: https://my.telegram.org"
|
|
60
|
+
)
|
|
61
|
+
|
|
50
62
|
return cls(
|
|
51
|
-
api_id=int(
|
|
52
|
-
api_hash=
|
|
63
|
+
api_id=int(api_id),
|
|
64
|
+
api_hash=api_hash,
|
|
53
65
|
session_string=os.environ.get("TELEGRAM_SESSION"),
|
|
54
66
|
)
|
|
55
|
-
|
|
67
|
+
|
|
56
68
|
def save(self) -> None:
|
|
57
69
|
"""Save config to file."""
|
|
58
70
|
config_path = get_config_dir() / "telegram.json"
|
|
59
71
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
-
|
|
72
|
+
|
|
61
73
|
with open(config_path, "w") as f:
|
|
62
|
-
json.dump(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
74
|
+
json.dump(
|
|
75
|
+
{
|
|
76
|
+
"api_id": self.api_id,
|
|
77
|
+
"api_hash": self.api_hash,
|
|
78
|
+
"session_string": self.session_string,
|
|
79
|
+
},
|
|
80
|
+
f,
|
|
81
|
+
indent=2,
|
|
82
|
+
)
|
|
67
83
|
|
|
68
84
|
|
|
69
85
|
class TelegramVault:
|
|
70
86
|
"""
|
|
71
87
|
Telegram MTProto client for TeleVault operations.
|
|
72
|
-
|
|
88
|
+
|
|
73
89
|
Handles:
|
|
74
90
|
- Authentication
|
|
75
91
|
- Channel management
|
|
76
92
|
- File upload/download
|
|
77
93
|
- Index management
|
|
78
94
|
"""
|
|
79
|
-
|
|
80
|
-
def __init__(self, config:
|
|
95
|
+
|
|
96
|
+
def __init__(self, config: TelegramConfig | None = None):
|
|
81
97
|
self.config = config or TelegramConfig.from_env()
|
|
82
|
-
self._client:
|
|
83
|
-
self._channel:
|
|
84
|
-
self._channel_id:
|
|
85
|
-
|
|
98
|
+
self._client: TelegramClient | None = None
|
|
99
|
+
self._channel: Channel | None = None
|
|
100
|
+
self._channel_id: int | None = None
|
|
101
|
+
|
|
86
102
|
async def connect(self) -> None:
|
|
87
103
|
"""Connect to Telegram."""
|
|
88
104
|
if self.config.session_string:
|
|
89
105
|
session = StringSession(self.config.session_string)
|
|
90
106
|
else:
|
|
91
107
|
session = StringSession()
|
|
92
|
-
|
|
108
|
+
|
|
93
109
|
self._client = TelegramClient(
|
|
94
110
|
session,
|
|
95
111
|
self.config.api_id,
|
|
96
112
|
self.config.api_hash,
|
|
97
113
|
)
|
|
98
|
-
|
|
114
|
+
|
|
99
115
|
await self._client.connect()
|
|
100
|
-
|
|
116
|
+
|
|
101
117
|
async def disconnect(self) -> None:
|
|
102
118
|
"""Disconnect from Telegram."""
|
|
103
119
|
if self._client:
|
|
104
120
|
await self._client.disconnect()
|
|
105
|
-
|
|
106
|
-
async def login(self, phone:
|
|
121
|
+
|
|
122
|
+
async def login(self, phone: str | None = None) -> str:
|
|
107
123
|
"""
|
|
108
124
|
Interactive login flow.
|
|
109
|
-
|
|
125
|
+
|
|
110
126
|
Returns session string for future use.
|
|
111
127
|
"""
|
|
112
128
|
if not self._client:
|
|
113
129
|
await self.connect()
|
|
114
|
-
|
|
130
|
+
|
|
115
131
|
if not await self._client.is_user_authorized():
|
|
116
132
|
if phone is None:
|
|
117
133
|
phone = input("Enter phone number: ")
|
|
118
|
-
|
|
134
|
+
|
|
119
135
|
await self._client.send_code_request(phone)
|
|
120
136
|
code = input("Enter the code you received: ")
|
|
121
|
-
|
|
137
|
+
|
|
122
138
|
try:
|
|
123
139
|
await self._client.sign_in(phone, code)
|
|
124
140
|
except Exception:
|
|
125
141
|
# 2FA required
|
|
126
142
|
password = input("Enter 2FA password: ")
|
|
127
143
|
await self._client.sign_in(password=password)
|
|
128
|
-
|
|
144
|
+
|
|
129
145
|
# Save session
|
|
130
146
|
session_string = self._client.session.save()
|
|
131
147
|
self.config.session_string = session_string
|
|
132
148
|
self.config.save()
|
|
133
|
-
|
|
149
|
+
|
|
134
150
|
return session_string
|
|
135
|
-
|
|
151
|
+
|
|
136
152
|
async def set_channel(self, channel_id: int) -> None:
|
|
137
153
|
"""Set the storage channel."""
|
|
138
154
|
self._channel_id = channel_id
|
|
139
155
|
self._channel = await self._client.get_entity(channel_id)
|
|
140
|
-
|
|
156
|
+
|
|
141
157
|
async def create_channel(self, name: str = "TeleVault Storage") -> int:
|
|
142
158
|
"""Create a new private channel for storage."""
|
|
143
159
|
from telethon.tl.functions.channels import CreateChannelRequest
|
|
144
|
-
|
|
145
|
-
result = await self._client(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
160
|
+
|
|
161
|
+
result = await self._client(
|
|
162
|
+
CreateChannelRequest(
|
|
163
|
+
title=name,
|
|
164
|
+
about="TeleVault encrypted storage",
|
|
165
|
+
megagroup=False, # Regular channel, not supergroup
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
151
169
|
channel = result.chats[0]
|
|
152
170
|
self._channel = channel
|
|
153
171
|
self._channel_id = channel.id
|
|
154
|
-
|
|
172
|
+
|
|
155
173
|
# Return full channel ID format (negative with -100 prefix)
|
|
156
174
|
# Telegram channels need -100 prefix for MTProto
|
|
157
175
|
full_channel_id = int(f"-100{channel.id}")
|
|
158
176
|
return full_channel_id
|
|
159
|
-
|
|
177
|
+
|
|
160
178
|
# === Index Operations ===
|
|
161
|
-
|
|
179
|
+
|
|
162
180
|
async def get_index(self) -> VaultIndex:
|
|
163
181
|
"""Get the vault index from pinned message."""
|
|
164
182
|
if not self._channel_id:
|
|
165
183
|
raise ValueError("No channel set")
|
|
166
|
-
|
|
184
|
+
|
|
167
185
|
# Get pinned messages
|
|
168
186
|
async for msg in self._client.iter_messages(
|
|
169
187
|
self._channel_id,
|
|
@@ -178,15 +196,15 @@ class TelegramVault:
|
|
|
178
196
|
return VaultIndex.from_json(msg.text)
|
|
179
197
|
except json.JSONDecodeError:
|
|
180
198
|
continue
|
|
181
|
-
|
|
199
|
+
|
|
182
200
|
# No valid index found, create empty one
|
|
183
201
|
return VaultIndex()
|
|
184
|
-
|
|
202
|
+
|
|
185
203
|
async def save_index(self, index: VaultIndex) -> int:
|
|
186
204
|
"""Save the vault index as pinned message."""
|
|
187
205
|
if not self._channel_id:
|
|
188
206
|
raise ValueError("No channel set")
|
|
189
|
-
|
|
207
|
+
|
|
190
208
|
# Find existing pinned index message
|
|
191
209
|
existing_msg_id = None
|
|
192
210
|
async for msg in self._client.iter_messages(
|
|
@@ -201,7 +219,7 @@ class TelegramVault:
|
|
|
201
219
|
break
|
|
202
220
|
except json.JSONDecodeError:
|
|
203
221
|
continue
|
|
204
|
-
|
|
222
|
+
|
|
205
223
|
if existing_msg_id:
|
|
206
224
|
# Edit existing
|
|
207
225
|
await self._client.edit_message(
|
|
@@ -218,42 +236,42 @@ class TelegramVault:
|
|
|
218
236
|
)
|
|
219
237
|
await self._client.pin_message(self._channel_id, msg.id)
|
|
220
238
|
return msg.id
|
|
221
|
-
|
|
239
|
+
|
|
222
240
|
# === File Operations ===
|
|
223
|
-
|
|
241
|
+
|
|
224
242
|
async def upload_metadata(self, metadata: FileMetadata) -> int:
|
|
225
243
|
"""Upload file metadata as a text message."""
|
|
226
244
|
if not self._channel_id:
|
|
227
245
|
raise ValueError("No channel set")
|
|
228
|
-
|
|
246
|
+
|
|
229
247
|
msg = await self._client.send_message(
|
|
230
248
|
self._channel_id,
|
|
231
249
|
metadata.to_json(),
|
|
232
250
|
)
|
|
233
251
|
return msg.id
|
|
234
|
-
|
|
252
|
+
|
|
235
253
|
async def get_metadata(self, message_id: int) -> FileMetadata:
|
|
236
254
|
"""Get file metadata from message."""
|
|
237
255
|
if not self._channel_id:
|
|
238
256
|
raise ValueError("No channel set")
|
|
239
|
-
|
|
257
|
+
|
|
240
258
|
msg = await self._client.get_messages(self._channel_id, ids=message_id)
|
|
241
259
|
if not msg or not msg.text:
|
|
242
260
|
raise ValueError(f"Metadata message {message_id} not found")
|
|
243
|
-
|
|
261
|
+
|
|
244
262
|
return FileMetadata.from_json(msg.text)
|
|
245
|
-
|
|
263
|
+
|
|
246
264
|
async def update_metadata(self, message_id: int, metadata: FileMetadata) -> None:
|
|
247
265
|
"""Update file metadata message."""
|
|
248
266
|
if not self._channel_id:
|
|
249
267
|
raise ValueError("No channel set")
|
|
250
|
-
|
|
268
|
+
|
|
251
269
|
await self._client.edit_message(
|
|
252
270
|
self._channel_id,
|
|
253
271
|
message_id,
|
|
254
272
|
metadata.to_json(),
|
|
255
273
|
)
|
|
256
|
-
|
|
274
|
+
|
|
257
275
|
async def upload_chunk(
|
|
258
276
|
self,
|
|
259
277
|
data: bytes,
|
|
@@ -263,23 +281,23 @@ class TelegramVault:
|
|
|
263
281
|
) -> int:
|
|
264
282
|
"""
|
|
265
283
|
Upload a chunk as a file message.
|
|
266
|
-
|
|
284
|
+
|
|
267
285
|
Args:
|
|
268
286
|
data: Chunk data
|
|
269
287
|
filename: Chunk filename (e.g., "0001.chunk")
|
|
270
288
|
reply_to: Metadata message ID to reply to
|
|
271
289
|
progress_callback: Optional progress callback
|
|
272
|
-
|
|
290
|
+
|
|
273
291
|
Returns:
|
|
274
292
|
Message ID of uploaded chunk
|
|
275
293
|
"""
|
|
276
294
|
if not self._channel_id:
|
|
277
295
|
raise ValueError("No channel set")
|
|
278
|
-
|
|
296
|
+
|
|
279
297
|
# Create file-like object
|
|
280
298
|
file = io.BytesIO(data)
|
|
281
299
|
file.name = filename
|
|
282
|
-
|
|
300
|
+
|
|
283
301
|
try:
|
|
284
302
|
msg = await self._client.send_file(
|
|
285
303
|
self._channel_id,
|
|
@@ -293,7 +311,7 @@ class TelegramVault:
|
|
|
293
311
|
# Rate limited, wait and retry
|
|
294
312
|
await asyncio.sleep(e.seconds + 1)
|
|
295
313
|
return await self.upload_chunk(data, filename, reply_to, progress_callback)
|
|
296
|
-
|
|
314
|
+
|
|
297
315
|
async def download_chunk(
|
|
298
316
|
self,
|
|
299
317
|
message_id: int,
|
|
@@ -302,61 +320,72 @@ class TelegramVault:
|
|
|
302
320
|
"""Download a chunk by message ID."""
|
|
303
321
|
if not self._channel_id:
|
|
304
322
|
raise ValueError("No channel set")
|
|
305
|
-
|
|
323
|
+
|
|
306
324
|
msg = await self._client.get_messages(self._channel_id, ids=message_id)
|
|
307
325
|
if not msg or not msg.file:
|
|
308
326
|
raise ValueError(f"Chunk message {message_id} not found")
|
|
309
|
-
|
|
310
|
-
return await self._client.download_media(
|
|
311
|
-
|
|
327
|
+
|
|
328
|
+
return await self._client.download_media(
|
|
329
|
+
msg, file=bytes, progress_callback=progress_callback
|
|
330
|
+
)
|
|
331
|
+
|
|
312
332
|
async def iter_file_chunks(self, metadata_msg_id: int) -> AsyncIterator[Message]:
|
|
313
333
|
"""Iterate over chunk messages that reply to a metadata message."""
|
|
314
334
|
if not self._channel_id:
|
|
315
335
|
raise ValueError("No channel set")
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
self.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
async for msg in self._client.iter_messages(
|
|
339
|
+
self._channel_id,
|
|
340
|
+
reply_to=metadata_msg_id,
|
|
341
|
+
):
|
|
342
|
+
if msg.file:
|
|
343
|
+
yield msg
|
|
344
|
+
except Exception:
|
|
345
|
+
# If iterating fails (e.g., message deleted), just return empty
|
|
346
|
+
return
|
|
347
|
+
|
|
324
348
|
async def delete_file(self, file_id: str) -> bool:
|
|
325
349
|
"""Delete a file and all its chunks."""
|
|
326
350
|
if not self._channel_id:
|
|
327
351
|
raise ValueError("No channel set")
|
|
328
|
-
|
|
352
|
+
|
|
329
353
|
# Get index
|
|
330
354
|
index = await self.get_index()
|
|
331
|
-
|
|
355
|
+
|
|
332
356
|
if file_id not in index.files:
|
|
333
357
|
return False
|
|
334
|
-
|
|
358
|
+
|
|
335
359
|
metadata_msg_id = index.files[file_id]
|
|
336
|
-
|
|
360
|
+
|
|
337
361
|
# Collect all message IDs to delete
|
|
338
362
|
msg_ids = [metadata_msg_id]
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
async for chunk_msg in self.iter_file_chunks(metadata_msg_id):
|
|
366
|
+
msg_ids.append(chunk_msg.id)
|
|
367
|
+
except Exception:
|
|
368
|
+
# If we can't get chunk messages, continue with just metadata
|
|
369
|
+
pass
|
|
370
|
+
|
|
371
|
+
# Delete messages (ignore errors for already-deleted messages)
|
|
372
|
+
with contextlib.suppress(Exception):
|
|
373
|
+
await self._client.delete_messages(self._channel_id, msg_ids)
|
|
374
|
+
|
|
346
375
|
# Update index
|
|
347
376
|
index.remove_file(file_id)
|
|
348
377
|
await self.save_index(index)
|
|
349
|
-
|
|
378
|
+
|
|
350
379
|
return True
|
|
351
|
-
|
|
380
|
+
|
|
352
381
|
# === Listing ===
|
|
353
|
-
|
|
382
|
+
|
|
354
383
|
async def list_files(self) -> list[FileMetadata]:
|
|
355
384
|
"""List all files in the vault."""
|
|
356
385
|
index = await self.get_index()
|
|
357
386
|
files = []
|
|
358
|
-
|
|
359
|
-
for
|
|
387
|
+
|
|
388
|
+
for _file_id, msg_id in index.files.items():
|
|
360
389
|
try:
|
|
361
390
|
metadata = await self.get_metadata(msg_id)
|
|
362
391
|
metadata.message_id = msg_id
|
|
@@ -364,12 +393,12 @@ class TelegramVault:
|
|
|
364
393
|
except Exception:
|
|
365
394
|
# Skip corrupted entries
|
|
366
395
|
continue
|
|
367
|
-
|
|
396
|
+
|
|
368
397
|
return files
|
|
369
|
-
|
|
398
|
+
|
|
370
399
|
async def search_files(self, query: str) -> list[FileMetadata]:
|
|
371
400
|
"""Search files by name."""
|
|
372
401
|
files = await self.list_files()
|
|
373
402
|
query_lower = query.lower()
|
|
374
|
-
|
|
403
|
+
|
|
375
404
|
return [f for f in files if query_lower in f.name.lower()]
|