televault 0.1.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 +16 -0
- televault/chunker.py +189 -0
- televault/cli.py +445 -0
- televault/compress.py +138 -0
- televault/config.py +81 -0
- televault/core.py +479 -0
- televault/crypto.py +170 -0
- televault/models.py +149 -0
- televault/telegram.py +375 -0
- televault-0.1.0.dist-info/METADATA +242 -0
- televault-0.1.0.dist-info/RECORD +13 -0
- televault-0.1.0.dist-info/WHEEL +4 -0
- televault-0.1.0.dist-info/entry_points.txt +3 -0
televault/crypto.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Encryption utilities for TeleVault - AES-256-GCM with Argon2id."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import struct
|
|
5
|
+
from typing import BinaryIO, Iterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
9
|
+
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
|
10
|
+
from cryptography.hazmat.backends import default_backend
|
|
11
|
+
|
|
12
|
+
# Constants
|
|
13
|
+
SALT_SIZE = 16
|
|
14
|
+
NONCE_SIZE = 12
|
|
15
|
+
TAG_SIZE = 16 # GCM auth tag
|
|
16
|
+
KEY_SIZE = 32 # 256-bit key
|
|
17
|
+
HEADER_SIZE = SALT_SIZE + NONCE_SIZE # 28 bytes
|
|
18
|
+
|
|
19
|
+
# For streaming, we encrypt in blocks
|
|
20
|
+
BLOCK_SIZE = 64 * 1024 # 64KB blocks for streaming encryption
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class EncryptionHeader:
|
|
25
|
+
"""Header prepended to encrypted data."""
|
|
26
|
+
|
|
27
|
+
salt: bytes
|
|
28
|
+
nonce: bytes
|
|
29
|
+
|
|
30
|
+
def to_bytes(self) -> bytes:
|
|
31
|
+
return self.salt + self.nonce
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_bytes(cls, data: bytes) -> "EncryptionHeader":
|
|
35
|
+
if len(data) < HEADER_SIZE:
|
|
36
|
+
raise ValueError(f"Header too short: {len(data)} < {HEADER_SIZE}")
|
|
37
|
+
return cls(
|
|
38
|
+
salt=data[:SALT_SIZE],
|
|
39
|
+
nonce=data[SALT_SIZE:HEADER_SIZE]
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def generate(cls) -> "EncryptionHeader":
|
|
44
|
+
return cls(
|
|
45
|
+
salt=os.urandom(SALT_SIZE),
|
|
46
|
+
nonce=os.urandom(NONCE_SIZE)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def derive_key(password: str, salt: bytes) -> bytes:
|
|
51
|
+
"""
|
|
52
|
+
Derive encryption key from password using Scrypt.
|
|
53
|
+
|
|
54
|
+
Using Scrypt instead of Argon2id for broader compatibility.
|
|
55
|
+
Parameters tuned for ~100ms on modern hardware.
|
|
56
|
+
"""
|
|
57
|
+
kdf = Scrypt(
|
|
58
|
+
salt=salt,
|
|
59
|
+
length=KEY_SIZE,
|
|
60
|
+
n=2**17, # CPU/memory cost
|
|
61
|
+
r=8, # Block size
|
|
62
|
+
p=1, # Parallelization
|
|
63
|
+
backend=default_backend()
|
|
64
|
+
)
|
|
65
|
+
return kdf.derive(password.encode("utf-8"))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def encrypt_chunk(data: bytes, password: str) -> bytes:
|
|
69
|
+
"""
|
|
70
|
+
Encrypt a chunk of data.
|
|
71
|
+
|
|
72
|
+
Returns: header (28 bytes) + ciphertext + tag (16 bytes)
|
|
73
|
+
"""
|
|
74
|
+
header = EncryptionHeader.generate()
|
|
75
|
+
key = derive_key(password, header.salt)
|
|
76
|
+
cipher = AESGCM(key)
|
|
77
|
+
|
|
78
|
+
ciphertext = cipher.encrypt(header.nonce, data, None)
|
|
79
|
+
return header.to_bytes() + ciphertext
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def decrypt_chunk(encrypted_data: bytes, password: str) -> bytes:
|
|
83
|
+
"""
|
|
84
|
+
Decrypt a chunk of data.
|
|
85
|
+
|
|
86
|
+
Expects: header (28 bytes) + ciphertext + tag (16 bytes)
|
|
87
|
+
"""
|
|
88
|
+
header = EncryptionHeader.from_bytes(encrypted_data)
|
|
89
|
+
key = derive_key(password, header.salt)
|
|
90
|
+
cipher = AESGCM(key)
|
|
91
|
+
|
|
92
|
+
ciphertext = encrypted_data[HEADER_SIZE:]
|
|
93
|
+
return cipher.decrypt(header.nonce, ciphertext, None)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class StreamingEncryptor:
|
|
97
|
+
"""
|
|
98
|
+
Streaming encryptor for large files.
|
|
99
|
+
|
|
100
|
+
Note: For simplicity, this encrypts the entire file with one key/nonce.
|
|
101
|
+
For very large files, consider chunking with per-chunk nonces.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, password: str):
|
|
105
|
+
self.password = password
|
|
106
|
+
self.header = EncryptionHeader.generate()
|
|
107
|
+
self.key = derive_key(password, self.header.salt)
|
|
108
|
+
self.cipher = AESGCM(self.key)
|
|
109
|
+
self._counter = 0
|
|
110
|
+
|
|
111
|
+
def get_header(self) -> bytes:
|
|
112
|
+
"""Get the header to prepend to encrypted output."""
|
|
113
|
+
return self.header.to_bytes()
|
|
114
|
+
|
|
115
|
+
def _get_nonce(self) -> bytes:
|
|
116
|
+
"""Generate unique nonce for each block using counter mode."""
|
|
117
|
+
# Use base nonce + counter to ensure uniqueness
|
|
118
|
+
counter_bytes = struct.pack(">Q", self._counter) # 8 bytes
|
|
119
|
+
self._counter += 1
|
|
120
|
+
# XOR with base nonce (take first 8 bytes of nonce, keep last 4)
|
|
121
|
+
nonce = bytearray(self.header.nonce)
|
|
122
|
+
for i in range(8):
|
|
123
|
+
nonce[i] ^= counter_bytes[i]
|
|
124
|
+
return bytes(nonce)
|
|
125
|
+
|
|
126
|
+
def encrypt_block(self, data: bytes, is_last: bool = False) -> bytes:
|
|
127
|
+
"""Encrypt a block of data."""
|
|
128
|
+
nonce = self._get_nonce()
|
|
129
|
+
# Prepend nonce to each block for independent decryption
|
|
130
|
+
ciphertext = self.cipher.encrypt(nonce, data, None)
|
|
131
|
+
return nonce + ciphertext
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class StreamingDecryptor:
|
|
135
|
+
"""Streaming decryptor for large files."""
|
|
136
|
+
|
|
137
|
+
def __init__(self, password: str, header: EncryptionHeader):
|
|
138
|
+
self.password = password
|
|
139
|
+
self.header = header
|
|
140
|
+
self.key = derive_key(password, header.salt)
|
|
141
|
+
self.cipher = AESGCM(self.key)
|
|
142
|
+
|
|
143
|
+
def decrypt_block(self, encrypted_block: bytes) -> bytes:
|
|
144
|
+
"""Decrypt a block of data."""
|
|
145
|
+
# Extract nonce from block
|
|
146
|
+
nonce = encrypted_block[:NONCE_SIZE]
|
|
147
|
+
ciphertext = encrypted_block[NONCE_SIZE:]
|
|
148
|
+
return self.cipher.decrypt(nonce, ciphertext, None)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def encrypt_file_simple(input_path: str, output_path: str, password: str) -> None:
|
|
152
|
+
"""Simple file encryption - loads entire file into memory."""
|
|
153
|
+
with open(input_path, "rb") as f:
|
|
154
|
+
data = f.read()
|
|
155
|
+
|
|
156
|
+
encrypted = encrypt_chunk(data, password)
|
|
157
|
+
|
|
158
|
+
with open(output_path, "wb") as f:
|
|
159
|
+
f.write(encrypted)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def decrypt_file_simple(input_path: str, output_path: str, password: str) -> None:
|
|
163
|
+
"""Simple file decryption - loads entire file into memory."""
|
|
164
|
+
with open(input_path, "rb") as f:
|
|
165
|
+
encrypted = f.read()
|
|
166
|
+
|
|
167
|
+
decrypted = decrypt_chunk(encrypted, password)
|
|
168
|
+
|
|
169
|
+
with open(output_path, "wb") as f:
|
|
170
|
+
f.write(decrypted)
|
televault/models.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Data models for TeleVault - stored as JSON on Telegram."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field, asdict
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Optional
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ChunkInfo:
|
|
11
|
+
"""Information about a single chunk stored on Telegram."""
|
|
12
|
+
|
|
13
|
+
index: int # Chunk order (0-based)
|
|
14
|
+
message_id: int # Telegram message ID
|
|
15
|
+
size: int # Chunk size in bytes
|
|
16
|
+
hash: str # BLAKE3 hash for verification
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> dict:
|
|
19
|
+
return asdict(self)
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_dict(cls, data: dict) -> "ChunkInfo":
|
|
23
|
+
return cls(**data)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class FileMetadata:
|
|
28
|
+
"""
|
|
29
|
+
Metadata for a file stored on Telegram.
|
|
30
|
+
This is stored as a JSON text message, with chunks replying to it.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
id: str # Unique file ID (short hash)
|
|
34
|
+
name: str # Original filename
|
|
35
|
+
size: int # Original file size in bytes
|
|
36
|
+
hash: str # BLAKE3 hash of original file
|
|
37
|
+
chunks: list[ChunkInfo] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
# Optional fields
|
|
40
|
+
encrypted: bool = True
|
|
41
|
+
compressed: bool = False
|
|
42
|
+
compression_ratio: Optional[float] = None
|
|
43
|
+
mime_type: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
# Timestamps
|
|
46
|
+
created_at: float = field(default_factory=lambda: datetime.now().timestamp())
|
|
47
|
+
modified_at: Optional[float] = None
|
|
48
|
+
|
|
49
|
+
# Telegram reference
|
|
50
|
+
message_id: Optional[int] = None # Message ID of this metadata
|
|
51
|
+
|
|
52
|
+
def to_json(self) -> str:
|
|
53
|
+
"""Serialize to JSON for storage on Telegram."""
|
|
54
|
+
data = asdict(self)
|
|
55
|
+
# Convert ChunkInfo objects
|
|
56
|
+
data["chunks"] = [c.to_dict() if isinstance(c, ChunkInfo) else c for c in data["chunks"]]
|
|
57
|
+
return json.dumps(data, separators=(",", ":")) # Compact JSON
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_json(cls, text: str) -> "FileMetadata":
|
|
61
|
+
"""Deserialize from JSON stored on Telegram."""
|
|
62
|
+
data = json.loads(text)
|
|
63
|
+
data["chunks"] = [ChunkInfo.from_dict(c) for c in data.get("chunks", [])]
|
|
64
|
+
return cls(**data)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def chunk_count(self) -> int:
|
|
68
|
+
return len(self.chunks)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def total_stored_size(self) -> int:
|
|
72
|
+
"""Total size of all chunks (after compression/encryption)."""
|
|
73
|
+
return sum(c.size for c in self.chunks)
|
|
74
|
+
|
|
75
|
+
def is_complete(self) -> bool:
|
|
76
|
+
"""Check if all chunks are present."""
|
|
77
|
+
if not self.chunks:
|
|
78
|
+
return False
|
|
79
|
+
indices = {c.index for c in self.chunks}
|
|
80
|
+
expected = set(range(len(self.chunks)))
|
|
81
|
+
return indices == expected
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class VaultIndex:
|
|
86
|
+
"""
|
|
87
|
+
Master index of all files in the vault.
|
|
88
|
+
Stored as pinned message in the channel.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
version: int = 1
|
|
92
|
+
files: dict[str, int] = field(default_factory=dict) # file_id -> metadata_message_id
|
|
93
|
+
updated_at: float = field(default_factory=lambda: datetime.now().timestamp())
|
|
94
|
+
|
|
95
|
+
def to_json(self) -> str:
|
|
96
|
+
return json.dumps(asdict(self), separators=(",", ":"))
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_json(cls, text: str) -> "VaultIndex":
|
|
100
|
+
data = json.loads(text)
|
|
101
|
+
# Only take known fields, ignore extras
|
|
102
|
+
return cls(
|
|
103
|
+
version=data.get("version", 1),
|
|
104
|
+
files=data.get("files", {}),
|
|
105
|
+
updated_at=data.get("updated_at", datetime.now().timestamp()),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def add_file(self, file_id: str, message_id: int) -> None:
|
|
109
|
+
self.files[file_id] = message_id
|
|
110
|
+
self.updated_at = datetime.now().timestamp()
|
|
111
|
+
|
|
112
|
+
def remove_file(self, file_id: str) -> Optional[int]:
|
|
113
|
+
msg_id = self.files.pop(file_id, None)
|
|
114
|
+
if msg_id:
|
|
115
|
+
self.updated_at = datetime.now().timestamp()
|
|
116
|
+
return msg_id
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class TransferProgress:
|
|
121
|
+
"""
|
|
122
|
+
Progress tracking for resumable transfers.
|
|
123
|
+
Stored as a temporary message, deleted on completion.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
operation: str # "upload" or "download"
|
|
127
|
+
file_id: str
|
|
128
|
+
file_name: str
|
|
129
|
+
total_chunks: int
|
|
130
|
+
completed_chunks: list[int] = field(default_factory=list) # Completed chunk indices
|
|
131
|
+
started_at: float = field(default_factory=lambda: datetime.now().timestamp())
|
|
132
|
+
|
|
133
|
+
def to_json(self) -> str:
|
|
134
|
+
return json.dumps(asdict(self), separators=(",", ":"))
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_json(cls, text: str) -> "TransferProgress":
|
|
138
|
+
return cls(**json.loads(text))
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def pending_chunks(self) -> list[int]:
|
|
142
|
+
completed = set(self.completed_chunks)
|
|
143
|
+
return [i for i in range(self.total_chunks) if i not in completed]
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def progress_percent(self) -> float:
|
|
147
|
+
if self.total_chunks == 0:
|
|
148
|
+
return 100.0
|
|
149
|
+
return (len(self.completed_chunks) / self.total_chunks) * 100
|
televault/telegram.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Telegram MTProto client wrapper for TeleVault."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, AsyncIterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
import io
|
|
9
|
+
|
|
10
|
+
from telethon import TelegramClient
|
|
11
|
+
from telethon.sessions import StringSession
|
|
12
|
+
from telethon.tl.types import (
|
|
13
|
+
Channel,
|
|
14
|
+
Message,
|
|
15
|
+
DocumentAttributeFilename,
|
|
16
|
+
InputPeerChannel,
|
|
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
|
+
|
|
24
|
+
|
|
25
|
+
# TeleVault Telegram app credentials
|
|
26
|
+
API_ID = 22399403
|
|
27
|
+
API_HASH = "9bf0e01ba1d63bc048172b8eb53d957b"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class TelegramConfig:
|
|
32
|
+
"""Telegram connection configuration."""
|
|
33
|
+
|
|
34
|
+
api_id: int
|
|
35
|
+
api_hash: str
|
|
36
|
+
session_string: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_env(cls) -> "TelegramConfig":
|
|
40
|
+
"""Load from environment or config file."""
|
|
41
|
+
import os
|
|
42
|
+
|
|
43
|
+
config_path = get_config_dir() / "telegram.json"
|
|
44
|
+
|
|
45
|
+
if config_path.exists():
|
|
46
|
+
with open(config_path) as f:
|
|
47
|
+
data = json.load(f)
|
|
48
|
+
return cls(**data)
|
|
49
|
+
|
|
50
|
+
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),
|
|
53
|
+
session_string=os.environ.get("TELEGRAM_SESSION"),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def save(self) -> None:
|
|
57
|
+
"""Save config to file."""
|
|
58
|
+
config_path = get_config_dir() / "telegram.json"
|
|
59
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
|
|
61
|
+
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)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TelegramVault:
|
|
70
|
+
"""
|
|
71
|
+
Telegram MTProto client for TeleVault operations.
|
|
72
|
+
|
|
73
|
+
Handles:
|
|
74
|
+
- Authentication
|
|
75
|
+
- Channel management
|
|
76
|
+
- File upload/download
|
|
77
|
+
- Index management
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, config: Optional[TelegramConfig] = None):
|
|
81
|
+
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
|
+
|
|
86
|
+
async def connect(self) -> None:
|
|
87
|
+
"""Connect to Telegram."""
|
|
88
|
+
if self.config.session_string:
|
|
89
|
+
session = StringSession(self.config.session_string)
|
|
90
|
+
else:
|
|
91
|
+
session = StringSession()
|
|
92
|
+
|
|
93
|
+
self._client = TelegramClient(
|
|
94
|
+
session,
|
|
95
|
+
self.config.api_id,
|
|
96
|
+
self.config.api_hash,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
await self._client.connect()
|
|
100
|
+
|
|
101
|
+
async def disconnect(self) -> None:
|
|
102
|
+
"""Disconnect from Telegram."""
|
|
103
|
+
if self._client:
|
|
104
|
+
await self._client.disconnect()
|
|
105
|
+
|
|
106
|
+
async def login(self, phone: Optional[str] = None) -> str:
|
|
107
|
+
"""
|
|
108
|
+
Interactive login flow.
|
|
109
|
+
|
|
110
|
+
Returns session string for future use.
|
|
111
|
+
"""
|
|
112
|
+
if not self._client:
|
|
113
|
+
await self.connect()
|
|
114
|
+
|
|
115
|
+
if not await self._client.is_user_authorized():
|
|
116
|
+
if phone is None:
|
|
117
|
+
phone = input("Enter phone number: ")
|
|
118
|
+
|
|
119
|
+
await self._client.send_code_request(phone)
|
|
120
|
+
code = input("Enter the code you received: ")
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
await self._client.sign_in(phone, code)
|
|
124
|
+
except Exception:
|
|
125
|
+
# 2FA required
|
|
126
|
+
password = input("Enter 2FA password: ")
|
|
127
|
+
await self._client.sign_in(password=password)
|
|
128
|
+
|
|
129
|
+
# Save session
|
|
130
|
+
session_string = self._client.session.save()
|
|
131
|
+
self.config.session_string = session_string
|
|
132
|
+
self.config.save()
|
|
133
|
+
|
|
134
|
+
return session_string
|
|
135
|
+
|
|
136
|
+
async def set_channel(self, channel_id: int) -> None:
|
|
137
|
+
"""Set the storage channel."""
|
|
138
|
+
self._channel_id = channel_id
|
|
139
|
+
self._channel = await self._client.get_entity(channel_id)
|
|
140
|
+
|
|
141
|
+
async def create_channel(self, name: str = "TeleVault Storage") -> int:
|
|
142
|
+
"""Create a new private channel for storage."""
|
|
143
|
+
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
|
+
|
|
151
|
+
channel = result.chats[0]
|
|
152
|
+
self._channel = channel
|
|
153
|
+
self._channel_id = channel.id
|
|
154
|
+
|
|
155
|
+
# Return full channel ID format (negative with -100 prefix)
|
|
156
|
+
# Telegram channels need -100 prefix for MTProto
|
|
157
|
+
full_channel_id = int(f"-100{channel.id}")
|
|
158
|
+
return full_channel_id
|
|
159
|
+
|
|
160
|
+
# === Index Operations ===
|
|
161
|
+
|
|
162
|
+
async def get_index(self) -> VaultIndex:
|
|
163
|
+
"""Get the vault index from pinned message."""
|
|
164
|
+
if not self._channel_id:
|
|
165
|
+
raise ValueError("No channel set")
|
|
166
|
+
|
|
167
|
+
# Get pinned messages
|
|
168
|
+
async for msg in self._client.iter_messages(
|
|
169
|
+
self._channel_id,
|
|
170
|
+
filter=None,
|
|
171
|
+
limit=10,
|
|
172
|
+
):
|
|
173
|
+
if msg.pinned and msg.text:
|
|
174
|
+
try:
|
|
175
|
+
data = json.loads(msg.text)
|
|
176
|
+
# Check if it looks like our index (has 'files' key)
|
|
177
|
+
if "files" in data:
|
|
178
|
+
return VaultIndex.from_json(msg.text)
|
|
179
|
+
except json.JSONDecodeError:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
# No valid index found, create empty one
|
|
183
|
+
return VaultIndex()
|
|
184
|
+
|
|
185
|
+
async def save_index(self, index: VaultIndex) -> int:
|
|
186
|
+
"""Save the vault index as pinned message."""
|
|
187
|
+
if not self._channel_id:
|
|
188
|
+
raise ValueError("No channel set")
|
|
189
|
+
|
|
190
|
+
# Find existing pinned index message
|
|
191
|
+
existing_msg_id = None
|
|
192
|
+
async for msg in self._client.iter_messages(
|
|
193
|
+
self._channel_id,
|
|
194
|
+
filter=None,
|
|
195
|
+
limit=10,
|
|
196
|
+
):
|
|
197
|
+
if msg.pinned and msg.text:
|
|
198
|
+
try:
|
|
199
|
+
VaultIndex.from_json(msg.text)
|
|
200
|
+
existing_msg_id = msg.id
|
|
201
|
+
break
|
|
202
|
+
except json.JSONDecodeError:
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
if existing_msg_id:
|
|
206
|
+
# Edit existing
|
|
207
|
+
await self._client.edit_message(
|
|
208
|
+
self._channel_id,
|
|
209
|
+
existing_msg_id,
|
|
210
|
+
index.to_json(),
|
|
211
|
+
)
|
|
212
|
+
return existing_msg_id
|
|
213
|
+
else:
|
|
214
|
+
# Create new and pin
|
|
215
|
+
msg = await self._client.send_message(
|
|
216
|
+
self._channel_id,
|
|
217
|
+
index.to_json(),
|
|
218
|
+
)
|
|
219
|
+
await self._client.pin_message(self._channel_id, msg.id)
|
|
220
|
+
return msg.id
|
|
221
|
+
|
|
222
|
+
# === File Operations ===
|
|
223
|
+
|
|
224
|
+
async def upload_metadata(self, metadata: FileMetadata) -> int:
|
|
225
|
+
"""Upload file metadata as a text message."""
|
|
226
|
+
if not self._channel_id:
|
|
227
|
+
raise ValueError("No channel set")
|
|
228
|
+
|
|
229
|
+
msg = await self._client.send_message(
|
|
230
|
+
self._channel_id,
|
|
231
|
+
metadata.to_json(),
|
|
232
|
+
)
|
|
233
|
+
return msg.id
|
|
234
|
+
|
|
235
|
+
async def get_metadata(self, message_id: int) -> FileMetadata:
|
|
236
|
+
"""Get file metadata from message."""
|
|
237
|
+
if not self._channel_id:
|
|
238
|
+
raise ValueError("No channel set")
|
|
239
|
+
|
|
240
|
+
msg = await self._client.get_messages(self._channel_id, ids=message_id)
|
|
241
|
+
if not msg or not msg.text:
|
|
242
|
+
raise ValueError(f"Metadata message {message_id} not found")
|
|
243
|
+
|
|
244
|
+
return FileMetadata.from_json(msg.text)
|
|
245
|
+
|
|
246
|
+
async def update_metadata(self, message_id: int, metadata: FileMetadata) -> None:
|
|
247
|
+
"""Update file metadata message."""
|
|
248
|
+
if not self._channel_id:
|
|
249
|
+
raise ValueError("No channel set")
|
|
250
|
+
|
|
251
|
+
await self._client.edit_message(
|
|
252
|
+
self._channel_id,
|
|
253
|
+
message_id,
|
|
254
|
+
metadata.to_json(),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
async def upload_chunk(
|
|
258
|
+
self,
|
|
259
|
+
data: bytes,
|
|
260
|
+
filename: str,
|
|
261
|
+
reply_to: int,
|
|
262
|
+
progress_callback=None,
|
|
263
|
+
) -> int:
|
|
264
|
+
"""
|
|
265
|
+
Upload a chunk as a file message.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
data: Chunk data
|
|
269
|
+
filename: Chunk filename (e.g., "0001.chunk")
|
|
270
|
+
reply_to: Metadata message ID to reply to
|
|
271
|
+
progress_callback: Optional progress callback
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Message ID of uploaded chunk
|
|
275
|
+
"""
|
|
276
|
+
if not self._channel_id:
|
|
277
|
+
raise ValueError("No channel set")
|
|
278
|
+
|
|
279
|
+
# Create file-like object
|
|
280
|
+
file = io.BytesIO(data)
|
|
281
|
+
file.name = filename
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
msg = await self._client.send_file(
|
|
285
|
+
self._channel_id,
|
|
286
|
+
file,
|
|
287
|
+
reply_to=reply_to,
|
|
288
|
+
progress_callback=progress_callback,
|
|
289
|
+
attributes=[DocumentAttributeFilename(filename)],
|
|
290
|
+
)
|
|
291
|
+
return msg.id
|
|
292
|
+
except FloodWaitError as e:
|
|
293
|
+
# Rate limited, wait and retry
|
|
294
|
+
await asyncio.sleep(e.seconds + 1)
|
|
295
|
+
return await self.upload_chunk(data, filename, reply_to, progress_callback)
|
|
296
|
+
|
|
297
|
+
async def download_chunk(
|
|
298
|
+
self,
|
|
299
|
+
message_id: int,
|
|
300
|
+
progress_callback=None,
|
|
301
|
+
) -> bytes:
|
|
302
|
+
"""Download a chunk by message ID."""
|
|
303
|
+
if not self._channel_id:
|
|
304
|
+
raise ValueError("No channel set")
|
|
305
|
+
|
|
306
|
+
msg = await self._client.get_messages(self._channel_id, ids=message_id)
|
|
307
|
+
if not msg or not msg.file:
|
|
308
|
+
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
|
+
|
|
312
|
+
async def iter_file_chunks(self, metadata_msg_id: int) -> AsyncIterator[Message]:
|
|
313
|
+
"""Iterate over chunk messages that reply to a metadata message."""
|
|
314
|
+
if not self._channel_id:
|
|
315
|
+
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
|
+
|
|
324
|
+
async def delete_file(self, file_id: str) -> bool:
|
|
325
|
+
"""Delete a file and all its chunks."""
|
|
326
|
+
if not self._channel_id:
|
|
327
|
+
raise ValueError("No channel set")
|
|
328
|
+
|
|
329
|
+
# Get index
|
|
330
|
+
index = await self.get_index()
|
|
331
|
+
|
|
332
|
+
if file_id not in index.files:
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
metadata_msg_id = index.files[file_id]
|
|
336
|
+
|
|
337
|
+
# Collect all message IDs to delete
|
|
338
|
+
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
|
+
|
|
346
|
+
# Update index
|
|
347
|
+
index.remove_file(file_id)
|
|
348
|
+
await self.save_index(index)
|
|
349
|
+
|
|
350
|
+
return True
|
|
351
|
+
|
|
352
|
+
# === Listing ===
|
|
353
|
+
|
|
354
|
+
async def list_files(self) -> list[FileMetadata]:
|
|
355
|
+
"""List all files in the vault."""
|
|
356
|
+
index = await self.get_index()
|
|
357
|
+
files = []
|
|
358
|
+
|
|
359
|
+
for file_id, msg_id in index.files.items():
|
|
360
|
+
try:
|
|
361
|
+
metadata = await self.get_metadata(msg_id)
|
|
362
|
+
metadata.message_id = msg_id
|
|
363
|
+
files.append(metadata)
|
|
364
|
+
except Exception:
|
|
365
|
+
# Skip corrupted entries
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
return files
|
|
369
|
+
|
|
370
|
+
async def search_files(self, query: str) -> list[FileMetadata]:
|
|
371
|
+
"""Search files by name."""
|
|
372
|
+
files = await self.list_files()
|
|
373
|
+
query_lower = query.lower()
|
|
374
|
+
|
|
375
|
+
return [f for f in files if query_lower in f.name.lower()]
|