kh
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled

This commit is contained in:
2025-12-26 18:58:48 -08:00
parent 436089ca0c
commit 9310478a37
8 changed files with 1242 additions and 242 deletions

1
.gitignore vendored
View File

@@ -227,3 +227,4 @@ Log/medeia_macina/telegram.session
*.session
example.py
test*
MPV/portable_config/watch_later*

8
CLI.py
View File

@@ -1047,7 +1047,9 @@ class CmdletExecutor:
nonlocal progress_ui, pipe_idx
# Keep behavior consistent with pipeline runner exclusions.
if cmd_name_norm in {"get-relationship", "get-rel", ".pipe", ".matrix"}:
# Some commands render their own Rich UI (tables/panels) and don't
# play nicely with Live cursor control.
if cmd_name_norm in {"get-relationship", "get-rel", ".pipe", ".matrix", ".telegram", "telegram", "delete-file", "del-file"}:
return
try:
@@ -2038,6 +2040,10 @@ class PipelineExecutor:
# progress can linger across those phases and interfere with interactive output.
if name == ".matrix":
continue
# `delete-file` prints a Rich table directly; Live progress interferes and
# can truncate/overwrite the output.
if name in {"delete-file", "del-file"}:
continue
pipe_stage_indices.append(idx)
pipe_labels.append(name)

View File

@@ -843,6 +843,12 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
_osd_overlay_clear(client)
except Exception:
pass
# Also remove any external subtitle that may be showing lyrics so
# turning lyrics "off" leaves no text on screen.
try:
_try_remove_selected_external_sub(client)
except Exception:
pass
last_idx = None
last_text = None
last_visible = visible

View File

@@ -108,8 +108,8 @@ class Matrix(Provider):
raise Exception("Matrix homeserver missing")
return base, str(access_token)
def list_rooms(self) -> List[Dict[str, Any]]:
"""Return the rooms the current user has joined.
def list_joined_room_ids(self) -> List[str]:
"""Return joined room IDs for the current user.
Uses `GET /_matrix/client/v3/joined_rooms`.
"""
@@ -120,11 +120,41 @@ class Matrix(Provider):
raise Exception(f"Matrix joined_rooms failed: {resp.text}")
data = resp.json() or {}
rooms = data.get("joined_rooms") or []
out: List[Dict[str, Any]] = []
out: List[str] = []
for rid in rooms:
if not isinstance(rid, str) or not rid.strip():
continue
room_id = rid.strip()
out.append(rid.strip())
return out
def list_rooms(self, *, room_ids: Optional[List[str]] = None) -> List[Dict[str, Any]]:
"""Return joined rooms, optionally limited to a subset.
Performance note: room names require additional per-room HTTP requests.
If `room_ids` is provided, only those rooms will have name lookups.
"""
base, token = self._get_homeserver_and_token()
headers = {"Authorization": f"Bearer {token}"}
joined = self.list_joined_room_ids()
if room_ids:
allowed = {str(v).strip().casefold() for v in room_ids if str(v).strip()}
if allowed:
# Accept either full IDs (!id:hs) or short IDs (!id).
def _is_allowed(rid: str) -> bool:
r = str(rid or "").strip()
if not r:
return False
rc = r.casefold()
if rc in allowed:
return True
short = r.split(":", 1)[0].strip().casefold()
return bool(short) and short in allowed
joined = [rid for rid in joined if _is_allowed(rid)]
out: List[Dict[str, Any]] = []
for room_id in joined:
name = ""
# Best-effort room name lookup (safe to fail).
try:

View File

@@ -5,8 +5,9 @@ import re
import shutil
import sys
import time
import threading
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from typing import Any, Dict, Optional, Sequence, Tuple
from urllib.parse import urlparse
from ProviderCore.base import Provider, SearchResult
@@ -81,6 +82,7 @@ class Telegram(Provider):
self._app_id = telegram_conf.get("app_id")
self._api_hash = telegram_conf.get("api_hash")
self._bot_token = telegram_conf.get("bot_token")
self._last_login_error: Optional[str] = None
# Telethon downloads are chunked; larger parts mean fewer round-trips.
# Telethon typically expects 4..1024 KB and divisible by 4.
self._part_size_kb = telegram_conf.get("part_size_kb")
@@ -89,8 +91,85 @@ class Telegram(Provider):
if self._part_size_kb is None:
self._part_size_kb = telegram_conf.get("download_part_kb")
# Avoid repeatedly prompting during startup where validate() may be called multiple times.
_startup_auth_attempted: bool = False
def _has_running_event_loop(self) -> bool:
try:
asyncio.get_running_loop()
return True
except RuntimeError:
return False
except Exception:
return False
def _run_async_blocking(self, coro):
"""Run an awaitable to completion using a fresh event loop.
If an event loop is already running in this thread (common in REPL/TUI),
runs the coroutine in a worker thread with its own loop.
"""
result: Dict[str, Any] = {}
err: Dict[str, Any] = {}
def _runner() -> None:
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
result["value"] = loop.run_until_complete(coro)
except BaseException as exc:
# Ensure we don't leave Telethon tasks pending when the user hits Ctrl+C.
err["error"] = exc
try:
try:
pending = asyncio.all_tasks(loop) # py3.8+
except TypeError:
pending = asyncio.all_tasks() # type: ignore
pending = [t for t in pending if t is not None and not t.done()]
for t in pending:
try:
t.cancel()
except Exception:
pass
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
try:
loop.run_until_complete(loop.shutdown_asyncgens())
except Exception:
pass
except Exception:
pass
finally:
try:
loop.close()
except Exception:
pass
if self._has_running_event_loop():
th = threading.Thread(target=_runner, daemon=True)
th.start()
th.join()
else:
_runner()
if "error" in err:
raise err["error"]
return result.get("value")
def _stdin_is_interactive(self) -> bool:
"""Best-effort check for whether we can safely prompt the user.
Some environments (e.g. prompt_toolkit) may wrap `sys.stdin` such that
`sys.stdin.isatty()` is False even though interactive prompting works.
"""
try:
streams = [sys.stdin, getattr(sys, "__stdin__", None)]
for stream in streams:
if stream is None:
continue
isatty = getattr(stream, "isatty", None)
if callable(isatty) and bool(isatty()):
return True
except Exception:
return False
return False
def _legacy_session_base_path(self) -> Path:
# Older versions stored sessions under Log/medeia_macina.
@@ -133,24 +212,59 @@ class Telegram(Provider):
except Exception:
return False
def _session_is_authorized(self) -> bool:
"""Return True if the current session file represents an authorized login.
This must never prompt.
"""
self._migrate_legacy_session_if_needed()
if not self._has_session():
return False
try:
from telethon import TelegramClient
except Exception:
return False
try:
app_id, api_hash = self._credentials()
except Exception:
return False
session_base = self._session_base_path()
async def _check_async() -> bool:
client = TelegramClient(str(session_base), app_id, api_hash)
try:
await client.connect()
return bool(await client.is_user_authorized())
finally:
try:
await client.disconnect()
except Exception:
pass
try:
return bool(self._run_async_blocking(_check_async()))
except Exception:
return False
def _ensure_session_interactive(self) -> bool:
"""Best-effort interactive auth to create a Telethon session file.
Returns True if a session exists after the attempt.
Returns True if a session exists and is authorized after the attempt.
"""
if self._has_session():
self._last_login_error = None
if self._session_is_authorized():
return True
# Never prompt in non-interactive contexts.
try:
if not bool(getattr(sys.stdin, "isatty", lambda: False)()):
return False
except Exception:
if not self._stdin_is_interactive():
self._last_login_error = "stdin is not interactive"
return False
try:
from telethon.sync import TelegramClient
except Exception:
from telethon import TelegramClient
except Exception as exc:
self._last_login_error = f"Telethon not available: {exc}"
return False
try:
@@ -158,17 +272,6 @@ class Telegram(Provider):
except Exception:
return False
self._ensure_event_loop()
loop = asyncio.get_event_loop()
if getattr(loop, "is_running", lambda: False)():
# Can't safely prompt/auth while another loop is running.
return False
def _resolve(value):
if asyncio.iscoroutine(value):
return loop.run_until_complete(value)
return value
try:
sys.stderr.write("[telegram] No session found; login required.\n")
sys.stderr.write("[telegram] Choose login method: 1) phone 2) bot token\n")
@@ -190,6 +293,7 @@ class Telegram(Provider):
except EOFError:
bot_token = ""
if not bot_token:
self._last_login_error = "bot token was empty"
return False
self._bot_token = bot_token
else:
@@ -197,17 +301,59 @@ class Telegram(Provider):
sys.stderr.flush()
session_base = self._session_base_path()
async def _auth_async() -> None:
client = TelegramClient(str(session_base), app_id, api_hash)
try:
if use_bot:
_resolve(client.start(bot_token=bot_token))
await client.start(bot_token=bot_token)
else:
_resolve(client.start())
await client.start()
finally:
try:
_resolve(client.disconnect())
await client.disconnect()
except Exception:
pass
def _run_in_new_loop() -> None:
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
loop.run_until_complete(_auth_async())
finally:
try:
loop.close()
except Exception:
pass
# If some framework is already running an event loop in this thread,
# do the auth flow in a worker thread with its own loop.
try:
self._ensure_event_loop()
main_loop = asyncio.get_event_loop()
loop_running = bool(getattr(main_loop, "is_running", lambda: False)())
except Exception:
loop_running = False
if loop_running:
err: list[str] = []
def _worker() -> None:
try:
_run_in_new_loop()
except Exception as exc:
err.append(str(exc))
th = threading.Thread(target=_worker, daemon=True)
th.start()
th.join()
if err:
self._last_login_error = err[0]
return False
else:
try:
_run_in_new_loop()
except Exception as exc:
self._last_login_error = str(exc)
return False
finally:
try:
sys.stderr.write("\n")
@@ -215,48 +361,65 @@ class Telegram(Provider):
except Exception:
pass
return self._has_session()
ok = self._has_session()
if not ok:
if not self._last_login_error:
self._last_login_error = "session was not created"
return False
if not self._session_is_authorized():
if not self._last_login_error:
self._last_login_error = "session exists but is not authorized"
return False
return True
def _ensure_session_with_bot_token(self, bot_token: str) -> bool:
"""Create a Telethon session using a bot token without prompting.
Returns True if a session exists after the attempt.
Returns True if a session exists and is authorized after the attempt.
"""
if self._has_session():
self._last_login_error = None
if self._session_is_authorized():
return True
bot_token = str(bot_token or "").strip()
if not bot_token:
return False
try:
from telethon.sync import TelegramClient
except Exception:
from telethon import TelegramClient
except Exception as exc:
self._last_login_error = f"Telethon not available: {exc}"
return False
try:
app_id, api_hash = self._credentials()
except Exception:
except Exception as exc:
self._last_login_error = str(exc)
return False
self._ensure_event_loop()
loop = asyncio.get_event_loop()
if getattr(loop, "is_running", lambda: False)():
return False
def _resolve(value):
if asyncio.iscoroutine(value):
return loop.run_until_complete(value)
return value
session_base = self._session_base_path()
async def _auth_async() -> None:
client = TelegramClient(str(session_base), app_id, api_hash)
try:
_resolve(client.start(bot_token=bot_token))
await client.start(bot_token=bot_token)
finally:
try:
_resolve(client.disconnect())
await client.disconnect()
except Exception:
pass
return self._has_session()
try:
self._run_async_blocking(_auth_async())
except Exception as exc:
self._last_login_error = str(exc)
return False
if not self._has_session():
self._last_login_error = "bot login did not create a session"
return False
if not self._session_is_authorized():
self._last_login_error = "bot session exists but is not authorized"
return False
return True
def _resolve_part_size_kb(self, file_size: Optional[int]) -> int:
# Default: bias to max throughput.
@@ -291,6 +454,11 @@ class Telegram(Provider):
return ps
def validate(self) -> bool:
"""Return True when Telegram can be used in the current context.
Important behavior: `validate()` must be side-effect free (no prompts).
Session creation happens on first use.
"""
try:
__import__("telethon")
except Exception:
@@ -304,20 +472,322 @@ class Telegram(Provider):
if not bool(app_id and api_hash):
return False
# Consider Telegram "configured" only if a persisted session exists.
if self._has_session():
# Consider the provider "available" when configured.
# Authentication/session creation is handled on first use.
return True
# If a bot token is already configured, attempt a non-interactive login.
def ensure_session(self, *, prompt: bool = False) -> bool:
"""Ensure a Telethon session exists.
- If an authorized session already exists: returns True.
- If a bot token is configured: tries to create a session without prompting.
- If `prompt=True`: attempts interactive login.
"""
# Treat "session exists" as insufficient; we need authorization.
if self._session_is_authorized():
return True
bot_token = str(self._bot_token or "").strip()
if bot_token:
return bool(self._ensure_session_with_bot_token(bot_token))
# Interactive startup prompt (only once per process).
if Telegram._startup_auth_attempted:
return bool(self._ensure_session_with_bot_token(bot_token) and self._session_is_authorized())
if prompt:
return bool(self._ensure_session_interactive() and self._session_is_authorized())
return False
Telegram._startup_auth_attempted = True
return bool(self._ensure_session_interactive())
def list_chats(self, *, limit: int = 200) -> list[Dict[str, Any]]:
"""List dialogs/chats available to the authenticated account.
Returns a list of dicts with keys: id, title, username, type.
"""
# Do not prompt implicitly.
if not self.ensure_session(prompt=False):
return []
try:
from telethon import TelegramClient
from telethon.tl.types import Channel, Chat, User
except Exception:
return []
try:
app_id, api_hash = self._credentials()
except Exception:
return []
session_base = self._session_base_path()
async def _list_async() -> list[Dict[str, Any]]:
client = TelegramClient(str(session_base), app_id, api_hash)
rows: list[Dict[str, Any]] = []
try:
await client.connect()
if not bool(await client.is_user_authorized()):
return []
try:
dialogs = await client.get_dialogs(limit=int(limit))
except TypeError:
dialogs = await client.get_dialogs()
for d in (dialogs or []):
entity = getattr(d, "entity", None)
title = ""
username = ""
chat_id = None
kind = ""
try:
title = str(getattr(d, "name", "") or "").strip()
except Exception:
title = ""
try:
if entity is not None:
maybe_id = getattr(entity, "id", None)
if maybe_id is not None:
chat_id = int(maybe_id)
maybe_username = getattr(entity, "username", None)
if isinstance(maybe_username, str):
username = maybe_username.strip()
except Exception:
pass
try:
if not title and entity is not None:
for attr in ("title", "first_name", "last_name"):
v = getattr(entity, attr, None)
if isinstance(v, str) and v.strip():
title = v.strip()
break
except Exception:
pass
try:
if isinstance(entity, Channel):
if bool(getattr(entity, "broadcast", False)):
kind = "channel"
elif bool(getattr(entity, "megagroup", False)):
kind = "group"
else:
kind = "channel"
elif isinstance(entity, Chat):
kind = "group"
elif isinstance(entity, User):
kind = "user"
else:
kind = type(entity).__name__.lower() if entity is not None else "unknown"
except Exception:
kind = "unknown"
rows.append({"id": chat_id, "title": title, "username": username, "type": kind})
return rows
finally:
try:
await client.disconnect()
except Exception:
pass
try:
rows = self._run_async_blocking(_list_async())
except Exception:
rows = []
# Sort for stable display.
try:
rows.sort(key=lambda r: (str(r.get("type") or ""), str(r.get("title") or "")))
except Exception:
pass
return rows
def send_files_to_chats(
self,
*,
chat_ids: Sequence[int],
usernames: Sequence[str],
files: Optional[Sequence[Dict[str, Any]]] = None,
file_paths: Optional[Sequence[str]] = None,
) -> None:
"""Send local file(s) to one or more chats.
This must never prompt. Requires an authorized session (run: .telegram -login).
Uses Rich ProgressBar for upload progress.
"""
# Never prompt implicitly.
if not self.ensure_session(prompt=False):
raise Exception("Telegram login required. Run: .telegram -login")
try:
from telethon import TelegramClient
from telethon.tl.types import DocumentAttributeFilename
except Exception as exc:
raise Exception(f"Telethon not available: {exc}")
try:
from SYS.progress import print_progress, print_final_progress
except Exception:
print_progress = None # type: ignore
print_final_progress = None # type: ignore
try:
app_id, api_hash = self._credentials()
except Exception as exc:
raise Exception(str(exc))
# Back-compat: allow callers to pass `file_paths=`.
if files is None:
files = [{"path": str(p), "title": ""} for p in (file_paths or [])]
def _sanitize_filename(text: str) -> str:
# Windows-safe plus generally safe for Telegram.
name = str(text or "").strip()
if not name:
return "file"
name = name.replace("\x00", " ")
# Strip characters illegal on Windows filenames.
name = re.sub(r'[<>:"/\\|?*]', " ", name)
# Collapse whitespace.
name = re.sub(r"\s+", " ", name).strip(" .")
if not name:
return "file"
# Keep it reasonable.
if len(name) > 120:
name = name[:120].rstrip(" .")
return name or "file"
# Normalize and validate file paths + titles.
jobs: list[Dict[str, Any]] = []
seen_paths: set[str] = set()
for f in (files or []):
try:
path_text = str((f or {}).get("path") or "").strip()
except Exception:
path_text = ""
if not path_text:
continue
path_obj = Path(path_text).expanduser()
if not path_obj.exists():
raise Exception(f"File not found: {path_obj}")
key = str(path_obj).lower()
if key in seen_paths:
continue
seen_paths.add(key)
title_text = ""
try:
title_text = str((f or {}).get("title") or "").strip()
except Exception:
title_text = ""
jobs.append({"path": str(path_obj), "title": title_text})
if not jobs:
raise Exception("No files to send")
session_base = self._session_base_path()
ids = [int(x) for x in (chat_ids or []) if x is not None]
try:
ids = list(dict.fromkeys(ids))
except Exception:
pass
uns = [str(u or "").strip() for u in (usernames or []) if str(u or "").strip()]
try:
uns = list(dict.fromkeys([u.strip().lower() for u in uns if u.strip()]))
except Exception:
pass
# Prefer IDs when available; avoid sending twice when both id and username exist.
if ids:
uns = []
if not ids and not uns:
raise Exception("No chat selected")
async def _send_async() -> None:
client = TelegramClient(str(session_base), app_id, api_hash)
try:
await client.connect()
if not bool(await client.is_user_authorized()):
raise Exception("Telegram session is not authorized. Run: .telegram -login")
# Resolve entities: prefer IDs. Only fall back to usernames when IDs are absent.
entities: list[Any] = []
if ids:
for cid in ids:
try:
e = await client.get_input_entity(int(cid))
entities.append(e)
except Exception:
continue
else:
seen_u: set[str] = set()
for u in uns:
key = str(u).strip().lower()
if not key or key in seen_u:
continue
seen_u.add(key)
try:
e = await client.get_input_entity(str(u))
entities.append(e)
except Exception:
continue
if not entities:
raise Exception("Unable to resolve selected chat(s)")
for entity in entities:
for job in jobs:
try:
p = str(job.get("path") or "").strip()
if not p:
continue
path_obj = Path(p)
file_size = None
try:
file_size = int(path_obj.stat().st_size)
except Exception:
file_size = None
ps = self._resolve_part_size_kb(file_size)
title_raw = str(job.get("title") or "").strip()
fallback = path_obj.stem
base = _sanitize_filename(title_raw) if title_raw else _sanitize_filename(fallback)
ext = path_obj.suffix
send_name = f"{base}{ext}" if ext else base
attributes = [DocumentAttributeFilename(send_name)]
def _progress(sent: int, total: int) -> None:
if print_progress is None:
return
try:
print_progress(send_name, int(sent or 0), int(total or 0))
except Exception:
return
# Start the progress UI immediately (even if Telethon delays the first callback).
if print_progress is not None:
try:
print_progress(send_name, 0, int(file_size or 0))
except Exception:
pass
try:
await client.send_file(
entity,
str(path_obj),
part_size_kb=ps,
progress_callback=_progress,
attributes=attributes,
)
finally:
if print_final_progress is not None:
try:
print_final_progress(send_name, int(file_size or 0), 0.0)
except Exception:
pass
except Exception as exc:
raise Exception(str(exc))
finally:
try:
await client.disconnect()
except Exception:
pass
self._run_async_blocking(_send_async())
def _session_base_path(self) -> Path:
# Store session alongside cookies.txt at repo root.
@@ -347,46 +817,32 @@ class Telegram(Provider):
asyncio.set_event_loop(loop)
def _download_message_media_sync(self, *, url: str, output_dir: Path) -> Tuple[Path, Dict[str, Any]]:
# Ensure we have an authorized session before attempting API calls.
# Never prompt during downloads.
if not self.ensure_session(prompt=False):
raise Exception("Telegram login required. Run: .telegram -login")
try:
from telethon import errors
from telethon.sync import TelegramClient
from telethon import TelegramClient, errors
from telethon.tl.types import PeerChannel
except Exception as exc:
raise Exception(f"Telethon not available: {exc}")
self._ensure_event_loop()
loop = asyncio.get_event_loop()
if getattr(loop, "is_running", lambda: False)():
raise Exception("Telegram provider cannot run while an event loop is already running")
def _resolve(value):
if asyncio.iscoroutine(value):
return loop.run_until_complete(value)
return value
try:
output_dir.mkdir(parents=True, exist_ok=True)
except Exception:
pass
app_id, api_hash = self._credentials()
session_base = self._session_base_path()
chat, message_id = _parse_telegram_message_url(url)
def _format_bytes(num: Optional[int]) -> str:
try:
if num is None:
return "?B"
n = float(num)
suffixes = ["B", "KB", "MB", "GB", "TB"]
for s in suffixes:
if n < 1024 or s == suffixes[-1]:
if s == "B":
return f"{int(n)}{s}"
return f"{n:.1f}{s}"
n /= 1024
except Exception:
return "?B"
async def _download_async() -> Tuple[Path, Dict[str, Any]]:
client = TelegramClient(str(session_base), app_id, api_hash)
try:
# This prompts on first run for phone/code and persists the session.
_resolve(client.start())
await client.connect()
if not bool(await client.is_user_authorized()):
raise Exception("Telegram session is not authorized. Run: .telegram -login")
if chat.startswith("c:"):
channel_id = int(chat.split(":", 1)[1])
@@ -396,14 +852,12 @@ class Telegram(Provider):
if isinstance(entity, str) and entity and not entity.startswith("@"):
entity = "@" + entity
# Use the list form to be robust across Telethon sync/async stubs.
messages = _resolve(client.get_messages(entity, ids=[message_id]))
messages = await client.get_messages(entity, ids=[message_id])
message = None
if isinstance(messages, (list, tuple)):
message = messages[0] if messages else None
else:
try:
# TotalList is list-like
message = messages[0] # type: ignore[index]
except Exception:
message = None
@@ -466,13 +920,11 @@ class Telegram(Provider):
except Exception:
pass
# Progress callback: prints to stderr so it doesn't interfere with pipeline stdout.
from models import ProgressBar
progress_bar = ProgressBar()
last_print = {"t": 0.0}
def _progress(current: int, total: int) -> None:
now = time.monotonic()
# Throttle to avoid spamming.
if now - float(last_print.get("t", 0.0)) < 0.25 and current < total:
return
last_print["t"] = now
@@ -480,21 +932,19 @@ class Telegram(Provider):
part_kb = self._resolve_part_size_kb(file_size)
try:
downloaded = _resolve(
client.download_media(
downloaded = await client.download_media(
message,
file=str(output_dir),
progress_callback=_progress,
part_size_kb=part_kb,
)
)
except TypeError:
# Older/newer Telethon versions may not accept part_size_kb on download_media.
downloaded = _resolve(client.download_media(message, file=str(output_dir), progress_callback=_progress))
downloaded = await client.download_media(message, file=str(output_dir), progress_callback=_progress)
progress_bar.finish()
if not downloaded:
raise Exception("Telegram download returned no file")
downloaded_path = Path(str(downloaded))
date_iso = None
try:
if msg_date is not None and hasattr(msg_date, "isoformat"):
@@ -528,10 +978,12 @@ class Telegram(Provider):
raise Exception(f"Telegram RPC error: {exc}")
finally:
try:
_resolve(client.disconnect())
await client.disconnect()
except Exception:
pass
return self._run_async_blocking(_download_async())
def download_url(self, url: str, output_dir: Path) -> Tuple[Path, Dict[str, Any]]:
"""Download a Telegram message URL and return (path, metadata)."""
if not _looks_like_telegram_message_url(url):

View File

@@ -110,7 +110,31 @@ class Delete_File(sh.Cmdlet):
store = sh.get_field(item, "store")
store_lower = str(store).lower() if store else ""
is_hydrus_store = bool(store_lower) and ("hydrus" in store_lower or store_lower in {"home", "work"})
backend = None
try:
if store:
registry = Store(config)
if registry.is_available(str(store)):
backend = registry[str(store)]
except Exception:
backend = None
# Determine whether the store backend is HydrusNetwork.
# IMPORTANT: Hydrus instances are named by the user (e.g. 'home', 'rpi'),
# so checking only the store name is unreliable.
is_hydrus_store = False
try:
if backend is not None:
from Store.HydrusNetwork import HydrusNetwork as HydrusStore
is_hydrus_store = isinstance(backend, HydrusStore)
except Exception:
is_hydrus_store = False
# Backwards-compatible fallback heuristic (older items might only carry a name).
if (not is_hydrus_store) and bool(store_lower) and ("hydrus" in store_lower or store_lower in {"home", "work"}):
is_hydrus_store = True
store_label = str(store) if store else "default"
hydrus_prefix = f"[hydrusnetwork:{store_label}]"
@@ -128,10 +152,14 @@ class Delete_File(sh.Cmdlet):
# via the backend API. This supports store items where `path`/`target` is the hash.
if conserve != "local" and store and (not is_hydrus_store):
try:
# Re-use an already resolved backend when available.
if backend is None:
registry = Store(config)
if registry.is_available(str(store)):
backend = registry[str(store)]
if backend is not None:
# Prefer hash when available.
hash_candidate = sh.normalize_hash(hash_hex_raw) if hash_hex_raw else None
if not hash_candidate and isinstance(target, str):
@@ -140,7 +168,8 @@ class Delete_File(sh.Cmdlet):
resolved_path = None
try:
if hash_candidate and hasattr(backend, "get_file"):
resolved_path = backend.get_file(hash_candidate)
candidate_path = backend.get_file(hash_candidate)
resolved_path = candidate_path if isinstance(candidate_path, Path) else None
except Exception:
resolved_path = None

View File

@@ -18,6 +18,108 @@ _MATRIX_PENDING_ITEMS_KEY = "matrix_pending_items"
_MATRIX_PENDING_TEXT_KEY = "matrix_pending_text"
def _has_flag(args: Sequence[str], flag: str) -> bool:
try:
want = str(flag or "").strip().lower()
if not want:
return False
return any(str(a).strip().lower() == want for a in (args or []))
except Exception:
return False
def _parse_config_room_filter_ids(config: Dict[str, Any]) -> List[str]:
try:
if not isinstance(config, dict):
return []
providers = config.get("provider")
if not isinstance(providers, dict):
return []
matrix_conf = providers.get("matrix")
if not isinstance(matrix_conf, dict):
return []
raw = None
# Support a few common spellings; `room` is the documented key.
for key in ("room", "room_id", "rooms", "room_ids"):
if key in matrix_conf:
raw = matrix_conf.get(key)
break
if raw is None:
return []
# Allow either a string or a list-like value.
if isinstance(raw, (list, tuple, set)):
items = [str(v).strip() for v in raw if str(v).strip()]
return items
text = str(raw or "").strip()
if not text:
return []
# Comma-separated list of room IDs, but be tolerant of whitespace/newlines.
items = [p.strip() for p in re.split(r"[,\s]+", text) if p and p.strip()]
return items
except Exception:
return []
def _get_matrix_size_limit_bytes(config: Dict[str, Any]) -> Optional[int]:
"""Return max allowed per-file size in bytes for Matrix uploads.
Config: [provider=Matrix] size_limit=50 # MB
"""
try:
if not isinstance(config, dict):
return None
providers = config.get("provider")
if not isinstance(providers, dict):
return None
matrix_conf = providers.get("matrix")
if not isinstance(matrix_conf, dict):
return None
raw = None
for key in ("size_limit", "size_limit_mb", "max_mb"):
if key in matrix_conf:
raw = matrix_conf.get(key)
break
if raw is None:
return None
mb: Optional[float] = None
if isinstance(raw, (int, float)):
mb = float(raw)
else:
text = str(raw or "").strip().lower()
if not text:
return None
m = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(mb|mib|m)?", text)
if not m:
return None
mb = float(m.group(1))
if mb is None or mb <= 0:
return None
# Use MiB semantics for predictable limits.
return int(mb * 1024 * 1024)
except Exception:
return None
def _room_id_matches_filter(room_id: str, allowed_ids_canon: set[str]) -> bool:
rid = str(room_id or "").strip()
if not rid or not allowed_ids_canon:
return False
rid_canon = rid.casefold()
if rid_canon in allowed_ids_canon:
return True
# Allow matching when config omits the homeserver part: "!abc" matches "!abc:server".
base = rid.split(":", 1)[0].strip().casefold()
return bool(base) and base in allowed_ids_canon
def _extract_text_arg(args: Sequence[str]) -> str:
"""Extract a `-text <value>` argument from a cmdnat args list."""
if not args:
@@ -378,17 +480,50 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
except Exception:
text_value = ""
size_limit_bytes = _get_matrix_size_limit_bytes(config)
size_limit_mb = (size_limit_bytes / (1024 * 1024)) if size_limit_bytes else None
# Resolve upload paths once (also avoids repeated downloads when sending to multiple rooms).
upload_jobs: List[Dict[str, Any]] = []
any_failed = False
for rid in room_ids:
sent_any_for_room = False
for item in items:
file_path = _resolve_upload_path(item, config)
if not file_path:
any_failed = True
log("Matrix upload requires a local file (path) or a direct URL on the selected item", file=sys.stderr)
continue
media_path = Path(file_path)
if not media_path.exists():
any_failed = True
log(f"Matrix upload file missing: {file_path}", file=sys.stderr)
continue
if size_limit_bytes is not None:
try:
link = provider.upload_to_room(file_path, rid, pipe_obj=item)
byte_size = int(media_path.stat().st_size)
except Exception:
byte_size = -1
if byte_size >= 0 and byte_size > size_limit_bytes:
any_failed = True
actual_mb = byte_size / (1024 * 1024)
lim = float(size_limit_mb or 0)
log(
f"ERROR: file is too big, skipping: {media_path.name} ({actual_mb:.1f} MB > {lim:.1f} MB)",
file=sys.stderr,
)
continue
upload_jobs.append({"path": str(media_path), "pipe_obj": item})
for rid in room_ids:
sent_any_for_room = False
for job in upload_jobs:
file_path = str(job.get("path") or "")
if not file_path:
continue
try:
link = provider.upload_to_room(file_path, rid, pipe_obj=job.get("pipe_obj"))
debug(f"✓ Sent {Path(file_path).name} -> {rid}")
if link:
log(link)
@@ -433,12 +568,32 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
return 1
try:
rooms = provider.list_rooms()
configured_ids = None
if not _has_flag(args, "-all"):
ids = [str(v).strip() for v in _parse_config_room_filter_ids(config) if str(v).strip()]
if ids:
configured_ids = ids
rooms = provider.list_rooms(room_ids=configured_ids)
except Exception as exc:
log(f"Failed to list Matrix rooms: {exc}", file=sys.stderr)
return 1
# Diagnostics if a configured filter yields no rows (provider filtered before name lookups for speed).
if not rooms and not _has_flag(args, "-all"):
configured_ids_dbg = [str(v).strip() for v in _parse_config_room_filter_ids(config) if str(v).strip()]
if configured_ids_dbg:
try:
joined_ids = provider.list_joined_room_ids()
debug(f"[matrix] Configured room filter IDs: {configured_ids_dbg}")
debug(f"[matrix] Joined room IDs (from Matrix): {joined_ids}")
except Exception:
pass
if not rooms:
if _parse_config_room_filter_ids(config) and not _has_flag(args, "-all"):
log("No joined rooms matched the configured Matrix room filter (use: .matrix -all)", file=sys.stderr)
else:
log("No joined rooms found.", file=sys.stderr)
return 0
@@ -482,6 +637,7 @@ CMDLET = Cmdlet(
usage="@N | .matrix",
arg=[
CmdletArg(name="send", type="bool", description="(internal) Send to selected room(s)", required=False),
CmdletArg(name="all", type="bool", description="Ignore config room filter and show all joined rooms", required=False),
CmdletArg(name="text", type="string", description="Send a follow-up text message after each upload (caption-like)", required=False),
],
exec=_run

320
cmdnat/telegram.py Normal file
View File

@@ -0,0 +1,320 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
from cmdlet._shared import Cmdlet, CmdletArg
from SYS.logger import log
from result_table import ResultTable
import pipeline as ctx
_TELEGRAM_PENDING_ITEMS_KEY = "telegram_pending_items"
def _has_flag(args: Sequence[str], flag: str) -> bool:
try:
want = str(flag or "").strip().lower()
if not want:
return False
return any(str(a).strip().lower() == want for a in (args or []))
except Exception:
return False
def _normalize_to_list(value: Any) -> List[Any]:
if value is None:
return []
if isinstance(value, list):
return value
return [value]
def _extract_chat_id(chat_obj: Any) -> Optional[int]:
try:
if isinstance(chat_obj, dict):
maybe_id = chat_obj.get("id")
if maybe_id is not None:
return int(maybe_id)
extra = chat_obj.get("extra")
if isinstance(extra, dict):
v = extra.get("id")
if v is not None:
return int(v)
v = extra.get("chat_id")
if v is not None:
return int(v)
# PipeObject stores unknown fields in .extra
if hasattr(chat_obj, "extra"):
extra = getattr(chat_obj, "extra")
if isinstance(extra, dict):
v = extra.get("id")
if v is not None:
return int(v)
v = extra.get("chat_id")
if v is not None:
return int(v)
if hasattr(chat_obj, "id"):
maybe_id = getattr(chat_obj, "id")
if maybe_id is not None:
return int(maybe_id)
except Exception:
return None
return None
def _extract_chat_username(chat_obj: Any) -> str:
try:
if isinstance(chat_obj, dict):
u = chat_obj.get("username")
return str(u or "").strip()
if hasattr(chat_obj, "extra"):
extra = getattr(chat_obj, "extra")
if isinstance(extra, dict):
u = extra.get("username")
if isinstance(u, str) and u.strip():
return u.strip()
if hasattr(chat_obj, "username"):
return str(getattr(chat_obj, "username") or "").strip()
except Exception:
return ""
return ""
def _extract_title(item: Any) -> str:
try:
if isinstance(item, dict):
return str(item.get("title") or "").strip()
if hasattr(item, "title"):
return str(getattr(item, "title") or "").strip()
# PipeObject stores some fields in .extra
if hasattr(item, "extra"):
extra = getattr(item, "extra")
if isinstance(extra, dict):
v = extra.get("title")
if isinstance(v, str) and v.strip():
return v.strip()
except Exception:
return ""
return ""
def _extract_file_path(item: Any) -> Optional[str]:
def _maybe(value: Any) -> Optional[str]:
if value is None:
return None
text = str(value).strip()
if not text:
return None
if text.startswith("http://") or text.startswith("https://"):
return None
try:
p = Path(text).expanduser()
if p.exists():
return str(p)
except Exception:
return None
return None
try:
if hasattr(item, "path"):
found = _maybe(getattr(item, "path"))
if found:
return found
if hasattr(item, "file_path"):
found = _maybe(getattr(item, "file_path"))
if found:
return found
if isinstance(item, dict):
for key in ("path", "file_path", "target"):
found = _maybe(item.get(key))
if found:
return found
except Exception:
return None
return None
def _run(_result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
from Provider.telegram import Telegram
try:
provider = Telegram(config)
except Exception as exc:
log(f"Telegram not available: {exc}", file=sys.stderr)
return 1
if _has_flag(args, "-login"):
ok = False
try:
ok = provider.ensure_session(prompt=True)
except Exception:
ok = False
if not ok:
err = getattr(provider, "_last_login_error", None)
if isinstance(err, str) and err.strip():
log(f"Telegram login failed: {err}", file=sys.stderr)
else:
log("Telegram login failed (no session created).", file=sys.stderr)
return 1
log("Telegram login OK (authorized session ready).", file=sys.stderr)
return 0
# Internal stage: send previously selected pipeline items to selected chats.
if _has_flag(args, "-send"):
# Ensure we don't keep showing the picker table on the send stage.
try:
if hasattr(ctx, "set_last_result_table_overlay"):
ctx.set_last_result_table_overlay(None, None, None)
except Exception:
pass
try:
if hasattr(ctx, "set_current_stage_table"):
ctx.set_current_stage_table(None)
except Exception:
pass
selected_chats = _normalize_to_list(_result)
chat_ids: List[int] = []
chat_usernames: List[str] = []
for c in selected_chats:
cid = _extract_chat_id(c)
if cid is not None:
chat_ids.append(cid)
else:
u = _extract_chat_username(c)
if u:
chat_usernames.append(u)
# De-dupe chat identifiers (preserve order).
try:
chat_ids = list(dict.fromkeys([int(x) for x in chat_ids]))
except Exception:
pass
try:
chat_usernames = list(dict.fromkeys([str(u).strip() for u in chat_usernames if str(u).strip()]))
except Exception:
pass
if not chat_ids and not chat_usernames:
log("No Telegram chat selected (use @N on the Telegram table)", file=sys.stderr)
return 1
pending_items = ctx.load_value(_TELEGRAM_PENDING_ITEMS_KEY, default=[])
items = _normalize_to_list(pending_items)
if not items:
log("No pending items to send (use: @N | .telegram)", file=sys.stderr)
return 1
file_jobs: List[Dict[str, str]] = []
any_failed = False
for item in items:
p = _extract_file_path(item)
if not p:
any_failed = True
log("Telegram send requires local file path(s) on the piped item(s)", file=sys.stderr)
continue
title = _extract_title(item)
file_jobs.append({"path": p, "title": title})
# De-dupe file paths (preserve order).
try:
seen: set[str] = set()
unique_jobs: List[Dict[str, str]] = []
for j in file_jobs:
k = str(j.get("path") or "").strip().lower()
if not k or k in seen:
continue
seen.add(k)
unique_jobs.append(j)
file_jobs = unique_jobs
except Exception:
pass
if not file_jobs:
return 1
try:
provider.send_files_to_chats(chat_ids=chat_ids, usernames=chat_usernames, files=file_jobs)
except Exception as exc:
log(f"Telegram send failed: {exc}", file=sys.stderr)
any_failed = True
ctx.store_value(_TELEGRAM_PENDING_ITEMS_KEY, [])
return 1 if any_failed else 0
selected_items = _normalize_to_list(_result)
if selected_items:
ctx.store_value(_TELEGRAM_PENDING_ITEMS_KEY, selected_items)
else:
# Avoid stale sends if the user just wants to browse chats.
try:
ctx.store_value(_TELEGRAM_PENDING_ITEMS_KEY, [])
except Exception:
pass
try:
if hasattr(ctx, "clear_pending_pipeline_tail"):
ctx.clear_pending_pipeline_tail()
except Exception:
pass
# Default: list available chats/channels (requires an existing session or bot_token).
try:
rows = provider.list_chats(limit=200)
except Exception as exc:
log(f"Failed to list Telegram chats: {exc}", file=sys.stderr)
return 1
# Only show dialogs you can typically post to.
try:
rows = [r for r in (rows or []) if str(r.get("type") or "").strip().lower() in {"group", "user"}]
except Exception:
pass
if not rows:
log("No Telegram groups/users available (or not logged in). Run: .telegram -login", file=sys.stderr)
return 0
table = ResultTable("Telegram Chats")
table.set_table("telegram")
table.set_source_command(".telegram", [])
chat_items: List[Dict[str, Any]] = []
for item in rows:
row = table.add_row()
title = str(item.get("title") or "").strip()
username = str(item.get("username") or "").strip()
chat_id = item.get("id")
kind = str(item.get("type") or "").strip()
row.add_column("Type", kind)
row.add_column("Title", title)
row.add_column("Username", username)
row.add_column("Id", str(chat_id) if chat_id is not None else "")
chat_items.append(
{
**item,
"store": "telegram",
"title": title or username or str(chat_id) or "Telegram",
}
)
# Overlay table: ensures @N selection targets this Telegram picker, not a previous table.
ctx.set_last_result_table_overlay(table, chat_items)
ctx.set_current_stage_table(table)
if selected_items:
ctx.set_pending_pipeline_tail([[".telegram", "-send"]], ".telegram")
return 0
CMDLET = Cmdlet(
name=".telegram",
alias=["telegram"],
summary="Telegram login and chat listing",
usage="@N | .telegram (pick a chat, then send piped files)",
arg=[
CmdletArg(name="login", type="bool", description="Create/refresh a Telegram session (prompts)", required=False),
CmdletArg(name="send", type="bool", description="(internal) Send to selected chat(s)", required=False),
],
exec=_run,
)