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
|
*.session
|
||||||
example.py
|
example.py
|
||||||
test*
|
test*
|
||||||
|
MPV/portable_config/watch_later*
|
||||||
8
CLI.py
8
CLI.py
@@ -1047,7 +1047,9 @@ class CmdletExecutor:
|
|||||||
nonlocal progress_ui, pipe_idx
|
nonlocal progress_ui, pipe_idx
|
||||||
|
|
||||||
# Keep behavior consistent with pipeline runner exclusions.
|
# 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
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -2038,6 +2040,10 @@ class PipelineExecutor:
|
|||||||
# progress can linger across those phases and interfere with interactive output.
|
# progress can linger across those phases and interfere with interactive output.
|
||||||
if name == ".matrix":
|
if name == ".matrix":
|
||||||
continue
|
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_stage_indices.append(idx)
|
||||||
pipe_labels.append(name)
|
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)
|
_osd_overlay_clear(client)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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_idx = None
|
||||||
last_text = None
|
last_text = None
|
||||||
last_visible = visible
|
last_visible = visible
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ class Matrix(Provider):
|
|||||||
raise Exception("Matrix homeserver missing")
|
raise Exception("Matrix homeserver missing")
|
||||||
return base, str(access_token)
|
return base, str(access_token)
|
||||||
|
|
||||||
def list_rooms(self) -> List[Dict[str, Any]]:
|
def list_joined_room_ids(self) -> List[str]:
|
||||||
"""Return the rooms the current user has joined.
|
"""Return joined room IDs for the current user.
|
||||||
|
|
||||||
Uses `GET /_matrix/client/v3/joined_rooms`.
|
Uses `GET /_matrix/client/v3/joined_rooms`.
|
||||||
"""
|
"""
|
||||||
@@ -120,11 +120,41 @@ class Matrix(Provider):
|
|||||||
raise Exception(f"Matrix joined_rooms failed: {resp.text}")
|
raise Exception(f"Matrix joined_rooms failed: {resp.text}")
|
||||||
data = resp.json() or {}
|
data = resp.json() or {}
|
||||||
rooms = data.get("joined_rooms") or []
|
rooms = data.get("joined_rooms") or []
|
||||||
out: List[Dict[str, Any]] = []
|
out: List[str] = []
|
||||||
for rid in rooms:
|
for rid in rooms:
|
||||||
if not isinstance(rid, str) or not rid.strip():
|
if not isinstance(rid, str) or not rid.strip():
|
||||||
continue
|
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 = ""
|
name = ""
|
||||||
# Best-effort room name lookup (safe to fail).
|
# Best-effort room name lookup (safe to fail).
|
||||||
try:
|
try:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -110,7 +110,31 @@ class Delete_File(sh.Cmdlet):
|
|||||||
store = sh.get_field(item, "store")
|
store = sh.get_field(item, "store")
|
||||||
|
|
||||||
store_lower = str(store).lower() if store else ""
|
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"
|
store_label = str(store) if store else "default"
|
||||||
hydrus_prefix = f"[hydrusnetwork:{store_label}]"
|
hydrus_prefix = f"[hydrusnetwork:{store_label}]"
|
||||||
|
|
||||||
@@ -128,9 +152,13 @@ class Delete_File(sh.Cmdlet):
|
|||||||
# via the backend API. This supports store items where `path`/`target` is the hash.
|
# via the backend API. This supports store items where `path`/`target` is the hash.
|
||||||
if conserve != "local" and store and (not is_hydrus_store):
|
if conserve != "local" and store and (not is_hydrus_store):
|
||||||
try:
|
try:
|
||||||
registry = Store(config)
|
# Re-use an already resolved backend when available.
|
||||||
if registry.is_available(str(store)):
|
if backend is None:
|
||||||
backend = registry[str(store)]
|
registry = Store(config)
|
||||||
|
if registry.is_available(str(store)):
|
||||||
|
backend = registry[str(store)]
|
||||||
|
|
||||||
|
if backend is not None:
|
||||||
|
|
||||||
# Prefer hash when available.
|
# Prefer hash when available.
|
||||||
hash_candidate = sh.normalize_hash(hash_hex_raw) if hash_hex_raw else None
|
hash_candidate = sh.normalize_hash(hash_hex_raw) if hash_hex_raw else None
|
||||||
@@ -140,7 +168,8 @@ class Delete_File(sh.Cmdlet):
|
|||||||
resolved_path = None
|
resolved_path = None
|
||||||
try:
|
try:
|
||||||
if hash_candidate and hasattr(backend, "get_file"):
|
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:
|
except Exception:
|
||||||
resolved_path = None
|
resolved_path = None
|
||||||
|
|
||||||
|
|||||||
170
cmdnat/matrix.py
170
cmdnat/matrix.py
@@ -18,6 +18,108 @@ _MATRIX_PENDING_ITEMS_KEY = "matrix_pending_items"
|
|||||||
_MATRIX_PENDING_TEXT_KEY = "matrix_pending_text"
|
_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:
|
def _extract_text_arg(args: Sequence[str]) -> str:
|
||||||
"""Extract a `-text <value>` argument from a cmdnat args list."""
|
"""Extract a `-text <value>` argument from a cmdnat args list."""
|
||||||
if not args:
|
if not args:
|
||||||
@@ -378,17 +480,50 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
except Exception:
|
except Exception:
|
||||||
text_value = ""
|
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
|
any_failed = 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:
|
||||||
|
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:
|
for rid in room_ids:
|
||||||
sent_any_for_room = False
|
sent_any_for_room = False
|
||||||
for item in items:
|
for job in upload_jobs:
|
||||||
file_path = _resolve_upload_path(item, config)
|
file_path = str(job.get("path") or "")
|
||||||
if not file_path:
|
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
|
continue
|
||||||
try:
|
try:
|
||||||
link = provider.upload_to_room(file_path, rid, pipe_obj=item)
|
link = provider.upload_to_room(file_path, rid, pipe_obj=job.get("pipe_obj"))
|
||||||
debug(f"✓ Sent {Path(file_path).name} -> {rid}")
|
debug(f"✓ Sent {Path(file_path).name} -> {rid}")
|
||||||
if link:
|
if link:
|
||||||
log(link)
|
log(link)
|
||||||
@@ -433,13 +568,33 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
log(f"Failed to list Matrix rooms: {exc}", file=sys.stderr)
|
log(f"Failed to list Matrix rooms: {exc}", file=sys.stderr)
|
||||||
return 1
|
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 not rooms:
|
||||||
log("No joined rooms found.", file=sys.stderr)
|
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
|
return 0
|
||||||
|
|
||||||
table = ResultTable("Matrix Rooms (select with @N)")
|
table = ResultTable("Matrix Rooms (select with @N)")
|
||||||
@@ -482,6 +637,7 @@ CMDLET = Cmdlet(
|
|||||||
usage="@N | .matrix",
|
usage="@N | .matrix",
|
||||||
arg=[
|
arg=[
|
||||||
CmdletArg(name="send", type="bool", description="(internal) Send to selected room(s)", required=False),
|
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),
|
CmdletArg(name="text", type="string", description="Send a follow-up text message after each upload (caption-like)", required=False),
|
||||||
],
|
],
|
||||||
exec=_run
|
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