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/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 pathlib import Path
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
- InputPeerChannel,
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
- API_ID = 22399403
27
- API_HASH = "9bf0e01ba1d63bc048172b8eb53d957b"
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: Optional[str] = None
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(os.environ.get("TELEGRAM_API_ID", API_ID)),
52
- api_hash=os.environ.get("TELEGRAM_API_HASH", 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
- "api_id": self.api_id,
64
- "api_hash": self.api_hash,
65
- "session_string": self.session_string,
66
- }, f, indent=2)
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: Optional[TelegramConfig] = None):
95
+
96
+ def __init__(self, config: TelegramConfig | None = None):
81
97
  self.config = config or TelegramConfig.from_env()
82
- self._client: Optional[TelegramClient] = None
83
- self._channel: Optional[Channel] = None
84
- self._channel_id: Optional[int] = None
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: Optional[str] = None) -> str:
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(CreateChannelRequest(
146
- title=name,
147
- about="TeleVault encrypted storage",
148
- megagroup=False, # Regular channel, not supergroup
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(msg, file=bytes, progress_callback=progress_callback)
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
- async for msg in self._client.iter_messages(
318
- self._channel_id,
319
- reply_to=metadata_msg_id,
320
- ):
321
- if msg.file:
322
- yield msg
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
- async for chunk_msg in self.iter_file_chunks(metadata_msg_id):
341
- msg_ids.append(chunk_msg.id)
342
-
343
- # Delete messages
344
- await self._client.delete_messages(self._channel_id, msg_ids)
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 file_id, msg_id in index.files.items():
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()]