kh
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -227,3 +227,4 @@ Log/medeia_macina/telegram.session
|
||||
*.session
|
||||
example.py
|
||||
test*
|
||||
MPV/portable_config/watch_later*
|
||||
8
CLI.py
8
CLI.py
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
164
cmdnat/matrix.py
164
cmdnat/matrix.py
@@ -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
320
cmdnat/telegram.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user