totelegram 0.9.10__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.
totelegram/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """
2
+ totelegram nació como un gestor de archivos para administrar la memoria digital en Telegram, construido sobre una premisa central:
3
+
4
+ La propiedad no es un registro, es una posibilidad.
5
+
6
+ No funciona solo como un uploader, sino como un índice de disponibilidad.
7
+ En este modelo, los registros son globales, porque la propiedad no se define por la cuenta que realiza la acción, sino por el acceso efectivo a la información.
8
+
9
+ Ver: docs/VISION.md
10
+ """
11
+
12
+ __version__ = "0.9.10"
13
+ CURRENT_DB_VERSION = 1
File without changes
@@ -0,0 +1,108 @@
1
+ import logging
2
+ import re
3
+ import sys
4
+ from datetime import datetime
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from totelegram.cli.commands import backup, config, profile
10
+ from totelegram.schemas import CLIState
11
+ from totelegram.utils import APP_NAME, get_user_config_dir
12
+
13
+ logging.getLogger("dotenv").setLevel(logging.CRITICAL)
14
+
15
+
16
+ from totelegram import __version__
17
+ from totelegram.cli.commands import send
18
+ from totelegram.cli.ui import console
19
+ from totelegram.identity import SettingsManager
20
+ from totelegram.logging_config import setup_logging
21
+
22
+ logger = logging.getLogger(__name__)
23
+ COMMANDS_IGNORING_USE = ["profile", "version"]
24
+
25
+ app = typer.Typer(
26
+ help="Herramienta para subir archivos a Telegram sin límite de tamaño.",
27
+ add_completion=False,
28
+ no_args_is_help=True,
29
+ )
30
+
31
+ app.add_typer(config.app, name="config")
32
+ app.add_typer(profile.app, name="profile")
33
+ app.command(name="send")(send.send_files)
34
+ app.command(name="backup")(backup.backup_folders)
35
+
36
+
37
+ def version_callback(value: bool):
38
+ if value:
39
+ console.print(f"toTelegram [bold cyan]v{__version__}[/bold cyan]")
40
+ raise typer.Exit()
41
+
42
+
43
+ @app.callback()
44
+ def main(
45
+ ctx: typer.Context,
46
+ use: Optional[str] = typer.Option(
47
+ None,
48
+ "--use",
49
+ "-u",
50
+ help="Perfil a utilizar para esta operación (ignora el perfil activo).",
51
+ is_eager=True,
52
+ ),
53
+ version: Optional[bool] = typer.Option(
54
+ None,
55
+ "--version",
56
+ "-V",
57
+ callback=version_callback,
58
+ is_eager=True,
59
+ help="Muestra la versión de la aplicación.",
60
+ ),
61
+ debug: bool = typer.Option(
62
+ False,
63
+ "--debug",
64
+ help="Activa el modo debug (DB independiente y logs detallados).",
65
+ ),
66
+ ):
67
+ """
68
+ Se ejecuta antes que cualquier comando.
69
+ """
70
+ worktable = get_user_config_dir(APP_NAME)
71
+ config_manager = SettingsManager(worktable)
72
+
73
+ cmd_name = ctx.invoked_subcommand or "sys"
74
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
75
+
76
+ log_dir = worktable / "logs"
77
+ log_path = log_dir / f"{timestamp}_{cmd_name}.log"
78
+
79
+ setup_logging(log_path, debug)
80
+
81
+ # main resuelve la intencion del nombre del perfil a usar; las validaciones dependen del contexto del comando.
82
+ profile_name = use or config_manager.get_active_profile_name()
83
+ ctx.obj = CLIState(
84
+ manager=config_manager, profile_name=profile_name, is_debug=debug
85
+ )
86
+ logger.debug(f"--- Nueva Ejecución: {sys.argv} ---")
87
+
88
+
89
+ def run_script():
90
+ # Buscamos argumentos que sean exactamente números negativos (IDs de Telegram)
91
+ # "-1001309586477" -> "ID:-1001309586477"
92
+ regex = re.compile(r"^-\d+$")
93
+ if len(sys.argv) > 1:
94
+ sys.argv = [f"ID:{arg}" if regex.match(arg) else arg for arg in sys.argv]
95
+
96
+ try:
97
+ from totelegram.cli.__main__ import app
98
+
99
+ app()
100
+ except Exception as e:
101
+ from totelegram.cli.ui import UI
102
+
103
+ UI.error(str(e))
104
+ sys.exit(1)
105
+
106
+
107
+ if __name__ == "__main__":
108
+ app()
File without changes
@@ -0,0 +1,107 @@
1
+ import time
2
+ from pathlib import Path
3
+ from typing import List
4
+
5
+ import typer
6
+
7
+ from totelegram.cli.commands.config import _get_config_tools
8
+ from totelegram.cli.logic import (
9
+ InventoryEngine,
10
+ get_or_create_job,
11
+ prepare_upload_context,
12
+ )
13
+ from totelegram.cli.ui import UI, DisplayUpload, console
14
+ from totelegram.schemas import VALUE_NOT_SET, CLIState, Commands
15
+ from totelegram.uploader import UploadService
16
+
17
+
18
+ def backup_folders(
19
+ ctx: typer.Context,
20
+ paths: List[Path] = typer.Argument(
21
+ ...,
22
+ exists=True,
23
+ help="Lista de carpetas a archivar",
24
+ ),
25
+ force: bool = typer.Option(
26
+ False,
27
+ "--force",
28
+ "-f",
29
+ help="Fuerza ignorando el estado del archivo en el sistema",
30
+ ),
31
+ ):
32
+ """
33
+ Convierte una carpeta en una Cinta de Datos (TAR) y la distribuye en volúmenes.
34
+ """
35
+ state: CLIState = ctx.obj
36
+ profile_name, _ = _get_config_tools(ctx)
37
+
38
+ settings = state.manager.get_settings(profile_name)
39
+
40
+ if settings.chat_id == VALUE_NOT_SET:
41
+ UI.error("El chat destino no está configurado.")
42
+ commands = [
43
+ f"{Commands.CONFIG_SET} chat_id <ID>",
44
+ f"{Commands.CONFIG_SEARCH} <QUERY>",
45
+ ]
46
+ UI.tip("puedes configurarlo usando uno de estos comandos:", commands)
47
+ raise typer.Exit(1)
48
+
49
+ if force:
50
+ UI.warn("Forzando la subida de carpetas sin comprobar el estado del archivo.")
51
+
52
+ # --- Scaneo y Informe ---
53
+ with console.status("[dim]Escaneando directorios...[/]"):
54
+ scan_report = InventoryEngine(settings, force).scan_backup_inventory(paths)
55
+
56
+ DisplayUpload.show_skip_report(scan_report, "carpeta", force_verbose=False)
57
+
58
+ candidates = scan_report.found
59
+ if not candidates:
60
+ UI.warn("No se encontraron carpetas válidas para procesar.")
61
+ UI.print(
62
+ "[dim]Asegúrate de que las rutas existan y no estén excluidas por tus patrones de configuración.[/]"
63
+ )
64
+ raise typer.Exit(0)
65
+
66
+ # --- Subida de lo encontrado ---
67
+
68
+ UI.separator()
69
+
70
+ with state.scope() as (client, db):
71
+ u_ctx = prepare_upload_context(state, client, db, settings)
72
+ uploader = UploadService(u_ctx)
73
+
74
+ from totelegram.telegram.patches import get_patch_status
75
+
76
+ status = get_patch_status()
77
+ if status["applied"]:
78
+ UI.success("Core Engine: Pyrogram Runtime Patches [ACTIVE]")
79
+ else:
80
+ UI.error("Core Engine: Pyrogram Runtime Patches [FAILED]")
81
+
82
+ user = u_ctx.owner.first_name or u_ctx.owner.username
83
+ chat_n = u_ctx.tg_chat.title or u_ctx.tg_chat.username
84
+ UI.success(f"Conectado como [bold]{user}[/]")
85
+ UI.info(f"Destino: [bold cyan]{chat_n}[/] [dim](ID: {u_ctx.tg_chat.id})[/]")
86
+ UI.print("", indent=False)
87
+
88
+ for index, folder in enumerate(candidates, 1):
89
+ is_last = index == len(candidates)
90
+ DisplayUpload.show_backup_header(folder.name, index, len(candidates))
91
+
92
+ with UI.loading("Analizando contenido..."):
93
+ report_internal = InventoryEngine(settings).scan_backup_internal(folder)
94
+ time.sleep(0.3)
95
+
96
+ DisplayUpload.show_internal_scan_result(report_internal)
97
+
98
+ job = get_or_create_job(folder, u_ctx, force, is_last)
99
+ if job is None:
100
+ continue
101
+
102
+ if uploader.process_job(job, folder):
103
+ UI.success(f"Carpeta [bold]{folder.name}[/] procesada exitosamente.")
104
+
105
+ if scan_report.skipped_by_integrity:
106
+ UI.separator()
107
+ DisplayUpload.show_integrity_advice(scan_report)
@@ -0,0 +1,343 @@
1
+ import json
2
+ import time
3
+ from functools import wraps
4
+ from typing import TYPE_CHECKING, List, Literal, Tuple, cast
5
+
6
+ import typer
7
+
8
+ from totelegram.cli.ui import UI, DisplayConfig, DisplayGeneric, DisplayProfile
9
+ from totelegram.identity import ConfigService
10
+ from totelegram.schemas import VALUE_NOT_SET, CLIState, Commands
11
+ from totelegram.telegram.access import ChatAccessService
12
+ from totelegram.telegram.client import TelegramSession
13
+ from totelegram.telegram.search import ChatSearchService
14
+ from totelegram.utils import (
15
+ is_direct_identifier,
16
+ is_potential_username,
17
+ is_suspected_glob_expansion,
18
+ normalize_chat_id,
19
+ validate_item,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from pyrogram import Client # type: ignore
24
+ from pyrogram.types import User
25
+
26
+ app = typer.Typer(help="Configuración del perfil actual.")
27
+
28
+
29
+ def handle_config_errors(func):
30
+ @wraps(func)
31
+ def wrapper(ctx: typer.Context, *args, **kwargs):
32
+ try:
33
+ return func(ctx, *args, **kwargs)
34
+ except ValueError as e:
35
+ UI.error(str(e))
36
+ raise typer.Exit(1)
37
+
38
+ return wrapper
39
+
40
+
41
+ def _get_config_tools(ctx: typer.Context) -> Tuple[str, ConfigService]:
42
+ """Extrae las herramientas necesarias para cualquier comando de configuracion que requieran un perfil."""
43
+ state: CLIState = ctx.obj
44
+ settings_name = cast(str, state.manager.resolve_profile_name(state.profile_name))
45
+ DisplayProfile.announce_profile_used(settings_name)
46
+ service = ConfigService(state.manager, state.is_debug)
47
+ return settings_name, service
48
+
49
+
50
+ @handle_config_errors
51
+ def _command_modify_list(
52
+ settings_name: str,
53
+ service: ConfigService,
54
+ key: str,
55
+ values: List[str],
56
+ action: Literal["add", "remove"],
57
+ ):
58
+ updates = service.prepare_updates([key, values]) # type: ignore
59
+ verb = "agregar" if action == "add" else "eliminar"
60
+
61
+ if is_suspected_glob_expansion(values):
62
+ if not DisplayConfig.confirm_expanded_pattern(verb, key, values):
63
+ UI.info("Operacion cancelada.")
64
+ raise typer.Exit()
65
+
66
+ for k, val in updates.items():
67
+ changed, final_val = service.apply_update(settings_name, k, val, action=action)
68
+ if changed:
69
+ UI.success(
70
+ f"Configuracion [bold]{k}[/] actualizada: {json.dumps(final_val)}"
71
+ )
72
+ else:
73
+ UI.info(f"Configuracion [bold]{k}[/] no sufrio cambios.")
74
+
75
+
76
+ @app.callback(invoke_without_command=True)
77
+ def main(ctx: typer.Context):
78
+ """Muestra la configuración actual si no se pasa un subcomando."""
79
+ if ctx.invoked_subcommand is not None:
80
+ return
81
+
82
+ state: CLIState = ctx.obj
83
+ manager = state.manager
84
+ profile_name = manager.resolve_profile_name(state.profile_name, strict=False)
85
+ if profile_name:
86
+ settings = manager.get_settings(profile_name)
87
+ DisplayConfig.show_config_table(profile_name, manager, state.is_debug, settings)
88
+
89
+ commands = [
90
+ f"{Commands.CONFIG_SET} <KEY> <VALUE>",
91
+ f"{Commands.CONFIG_EDIT_LIST} <KEY> <VALUE1>",
92
+ ]
93
+ UI.tip(
94
+ "Puedes modificar cualquier configuración usando los siguientes comandos:",
95
+ commands=commands,
96
+ spacing="top",
97
+ )
98
+ else:
99
+ UI.warn("No hay un perfil activo para mostrar valores de configuracion.")
100
+
101
+
102
+ @app.command(name="set")
103
+ @handle_config_errors
104
+ def set_configs(
105
+ ctx: typer.Context,
106
+ args: List[str] = typer.Argument(
107
+ None, help="Pares de CLAVE VALOR (ej: chat_id 12345 upload_limit_rate_kbps 500)"
108
+ ),
109
+ ):
110
+ """
111
+ Modifica una o varias configuraciones al mismo tiempo.
112
+ Uso: totelegram config set chat_id 999999 upload_limit_rate_kbps 1000
113
+
114
+ NOTA:
115
+ Evitar implementar lógica de red o resolución de Telegram (como 'config resolve')
116
+ dentro de este comando por las siguientes razones:
117
+
118
+ 1. INDEPENDENCIA: 'config set' debe funcionar 100% offline. Su única responsabilidad
119
+ es la persistencia en disco (.env). No debe depender de una sesión de Pyrogram.
120
+ 2. ATOMICIDAD: Este comando puede recibir múltiples pares clave-valor.
121
+ Añadir banderas como '--verify' o '--resolve' crearía ambigüedad sobre qué
122
+ campo se está verificando o resolviendo.
123
+ 3. PREDICTIBILIDAD: Para automatización, 'set' debe ser instantáneo.
124
+ Cualquier validación de red debe delegarse al comando 'config check' o
125
+ 'config resolve'.
126
+ """
127
+
128
+ settings_name, service = _get_config_tools(ctx)
129
+ updates = service.prepare_updates(args)
130
+ for key, val in updates.items():
131
+ changed, final_val = service.apply_update(settings_name, key, val, action="set")
132
+ if changed:
133
+ UI.success(
134
+ f"Configuracion [bold]{key}[/] actualizada con [bold]{final_val}[/]."
135
+ )
136
+ else:
137
+ UI.info(f"Configuracion [bold]{key}[/] ya tiene ese valor.")
138
+
139
+
140
+ @app.command("unset")
141
+ @handle_config_errors
142
+ def unset_config(
143
+ ctx: typer.Context,
144
+ key: str = typer.Argument(..., help="Clave a restaurar a su valor por defecto"),
145
+ ):
146
+ """Quita una configuración personalizada para usar el valor por defecto."""
147
+
148
+ settings_name, service = _get_config_tools(ctx)
149
+ default_val = service.restore_default(settings_name, key)
150
+ UI.success(f"Restaurado [bold]{key}[/] al valor por defecto: {default_val}")
151
+
152
+
153
+ @app.command("add")
154
+ def add_config(
155
+ ctx: typer.Context,
156
+ key: str,
157
+ values: List[str] = typer.Argument(
158
+ ..., min=1, callback=lambda x: [validate_item(i) for i in x]
159
+ ),
160
+ ):
161
+ """Agrega valores a una lista (ej. exclude_files)."""
162
+ settings_name, service = _get_config_tools(ctx)
163
+ _command_modify_list(settings_name, service, key, values, "add")
164
+
165
+
166
+ @app.command("remove")
167
+ def remove_config(
168
+ ctx: typer.Context,
169
+ key: str,
170
+ values: List[str] = typer.Argument(
171
+ ..., min=1, callback=lambda x: [validate_item(i) for i in x]
172
+ ),
173
+ ):
174
+ """Quita valores a una lista (ej. exclude_files)."""
175
+ settings_name, service = _get_config_tools(ctx)
176
+ _command_modify_list(settings_name, service, key, values, "remove")
177
+
178
+
179
+ @app.command("check")
180
+ @handle_config_errors
181
+ def check_config(ctx: typer.Context):
182
+ """Verifica que el perfil actual esté listo para subir archivos."""
183
+ state: CLIState = ctx.obj
184
+ manager = state.manager
185
+
186
+ profile_name = manager.resolve_profile_name(state.profile_name, strict=False)
187
+ if not profile_name:
188
+ UI.error("No hay un perfil activo o el perfil especificado no existe.")
189
+ raise typer.Exit(1)
190
+
191
+ with UI.loading(
192
+ f"Comprobando integridad local del perfil: [bold]{profile_name}[/]"
193
+ ):
194
+ time.sleep(0.2)
195
+ profile = manager.get_profile(profile_name)
196
+
197
+ if not profile:
198
+ UI.error(f"Perfil '{profile_name}' no encontrado.")
199
+ raise typer.Exit(1)
200
+
201
+ if not profile.is_trinity:
202
+ UI.error(f"El perfil '{profile_name}' está incompleto.")
203
+ if not profile.has_env:
204
+ UI.info("Falta el archivo de configuración (.env).")
205
+ if not profile.has_session:
206
+ UI.info("Falta el archivo de sesión de Telegram (.session).")
207
+
208
+ command = f"{Commands.PROFILE_CREATE} --force"
209
+ UI.tip(
210
+ f"Créalo de nuevo usando el siguiente comando:",
211
+ commands=command,
212
+ spacing="top",
213
+ )
214
+ raise typer.Exit(1)
215
+
216
+ settings = manager.get_settings(profile_name)
217
+
218
+ if settings.chat_id == VALUE_NOT_SET:
219
+ UI.warn("El destino (chat_id) no está configurado.")
220
+ UI.tip(
221
+ "Configura el destino usando uno de estos comandos:",
222
+ commands=[
223
+ f"{Commands.CONFIG_SET} chat_id <ID>",
224
+ f"{Commands.CONFIG_SEARCH} <NOMBRE>",
225
+ ],
226
+ )
227
+ raise typer.Exit(code=1)
228
+
229
+ with TelegramSession.from_profile(profile_name, manager) as client:
230
+ with UI.loading("Verificando conexión y permisos en Telegram..."):
231
+ validator = ChatAccessService(client)
232
+ access_report = validator.verify_access(settings.chat_id)
233
+
234
+ if not access_report.is_ready:
235
+ UI.warn("No tienes permisos de escritura en el chat destino.")
236
+ UI.info(f"Motivo: {access_report.reason}")
237
+ if access_report.hint:
238
+ UI.info(access_report.hint)
239
+ raise typer.Exit(code=1)
240
+
241
+ UI.success(f"Perfil '{profile_name}' verificado y listo para operar.")
242
+ if access_report.chat:
243
+ UI.info(
244
+ f"Destino confirmado: [bold]{access_report.chat.title}[/] "
245
+ f"[dim](ID: {access_report.chat.id})[/dim]"
246
+ )
247
+
248
+
249
+ @app.command("search")
250
+ def search_config(
251
+ ctx: typer.Context,
252
+ query: str = typer.Argument(..., help="Nombre, @username o ID."),
253
+ contains: bool = typer.Option(False, "--contains", "-c"),
254
+ depth: int = typer.Option(100, "--depth", "-d"),
255
+ apply: bool = typer.Option(False, "--apply", "-a"),
256
+ ):
257
+ """Busca y establece el chat de destino."""
258
+ state: CLIState = ctx.obj
259
+ manager = state.manager
260
+ profile_name = cast(str, manager.resolve_profile_name(state.profile_name))
261
+
262
+ DisplayProfile.announce_profile_used(profile_name)
263
+
264
+ # Obtener el ID del chat destino
265
+ with TelegramSession.from_profile(profile_name, manager) as client:
266
+ chat_access = ChatAccessService(client)
267
+ chat_searcher = ChatSearchService(client)
268
+
269
+ me = cast("User", client.get_me())
270
+ UI.info(f"Telegram Session: [bold]{me.username or me.first_name}[/]")
271
+
272
+ chat_id = None
273
+
274
+ if is_direct_identifier(query):
275
+ chat_id = normalize_chat_id(query)
276
+ else:
277
+ is_exact = not contains
278
+ search_desc = (
279
+ f"llamado exactamente '[bold]{query}[/]'"
280
+ if is_exact
281
+ else f"que contenga '[bold]{query}[/]' (sin distinción de mayúsculas y minúsculas)"
282
+ )
283
+ UI.info(f"Buscando chat {search_desc}")
284
+ with UI.loading("Explorando los chats recientes..."):
285
+ result = chat_searcher.search_by_name(query, depth, is_exact)
286
+
287
+ if result.is_resolved and result.winner:
288
+ chat_id = result.winner.id
289
+
290
+ elif result.is_ambiguous:
291
+ conflicts = len(result.conflicts)
292
+ UI.warn(f"Ambigüedad: Hay {conflicts} chats con ese nombre.")
293
+ DisplayGeneric.show_chat_table(
294
+ result.conflicts, "Conflictos Encontrados"
295
+ )
296
+ UI.info("Usa el ID exacto: [bold]config set chat_id <ID>[/]")
297
+ raise typer.Exit(1)
298
+
299
+ elif result.needs_help:
300
+
301
+ DisplayGeneric.show_chat_table(
302
+ result.suggestions, "Quizás quisiste decir:"
303
+ )
304
+ UI.info(
305
+ "Tip: Si no encuentras lo que buscas, intenta aumentar la profundidad con --depth."
306
+ )
307
+ raise typer.Exit(1)
308
+ else:
309
+ UI.error(
310
+ f"No se encontró ningún chat relacionado con '{result.query}' en los primeros {result.search_depth} chats recientes."
311
+ )
312
+ if is_potential_username(result.query):
313
+ UI.tip(
314
+ message="Puedes hacer una busqueda más profunda con [bold]--depth[/] o especificar el chat directamente con @username o t.me/username.",
315
+ commands=[
316
+ f"{Commands.CONFIG_SEARCH} {result.query} --depth 200",
317
+ f"{Commands.CONFIG_SEARCH} @{result.query}",
318
+ ],
319
+ )
320
+
321
+ raise typer.Exit(1)
322
+
323
+ # Verificación de permisos
324
+ with UI.loading("Verificando permisos..."):
325
+ report = chat_access.verify_access(chat_id)
326
+
327
+ if not (report.is_ready and report.chat):
328
+ UI.warn("No tienes permisos de escritura en el chat.")
329
+ UI.warn("Corrige los permisos antes de intentar cualquier subida.")
330
+ UI.warn("Configuración 'chat_id' no actualizada.")
331
+ raise typer.Exit(1)
332
+
333
+ # Si llegamos aquí, todo salió bien
334
+ UI.success(
335
+ f"¡Encontrado! [bold]{report.chat.title}[/] [dim](ID: {report.chat.id})[/]"
336
+ )
337
+ UI.success("Tienes permisos de escritura.")
338
+
339
+ if apply:
340
+ manager.set_setting(profile_name, "chat_id", report.chat.id)
341
+ UI.success("Configuración 'chat_id' actualizada.")
342
+
343
+ raise typer.Exit(0)