This commit is contained in:
nose
2025-12-19 03:25:52 -08:00
parent 52cf3f5c9f
commit d3edd6420c
9 changed files with 221 additions and 35 deletions

4
.gitignore vendored
View File

@@ -221,4 +221,6 @@ luac.out
config.conf config.conf
config.d/ config.d/
MPV/ffmpeg/* MPV/ffmpeg/*
MPV/portable_config/* MPV/portable_config/*
Log/
Log/medeia_macina/telegram.session

16
CLI.py
View File

@@ -1088,10 +1088,12 @@ def _create_cmdlet_cli():
if isinstance(provider_cfg, dict) and provider_cfg: if isinstance(provider_cfg, dict) and provider_cfg:
try: try:
from ProviderCore.registry import ( from ProviderCore.registry import (
list_providers,
list_search_providers, list_search_providers,
list_file_providers, list_file_providers,
) )
except Exception: except Exception:
list_providers = None # type: ignore
list_search_providers = None # type: ignore list_search_providers = None # type: ignore
list_file_providers = None # type: ignore list_file_providers = None # type: ignore
@@ -1103,6 +1105,13 @@ def _create_cmdlet_cli():
search_availability = {} search_availability = {}
file_availability = {} file_availability = {}
meta_availability = {} meta_availability = {}
provider_availability = {}
try:
if list_providers is not None:
provider_availability = list_providers(config) or {}
except Exception:
provider_availability = {}
try: try:
if list_search_providers is not None: if list_search_providers is not None:
@@ -1192,7 +1201,12 @@ def _create_cmdlet_cli():
is_known = False is_known = False
ok = None ok = None
if prov in search_availability: # Prefer unified provider registry for availability (covers providers that
# implement download-only behavior, like Telegram).
if prov in provider_availability:
is_known = True
ok = bool(provider_availability.get(prov))
elif prov in search_availability:
is_known = True is_known = True
ok = bool(search_availability.get(prov)) ok = bool(search_availability.get(prov))
elif prov in file_availability: elif prov in file_availability:

Binary file not shown.

View File

@@ -144,7 +144,7 @@ class Matrix(Provider):
out.append({"room_id": room_id, "name": name}) out.append({"room_id": room_id, "name": name})
return out return out
def upload_to_room(self, file_path: str, room_id: str) -> str: def upload_to_room(self, file_path: str, room_id: str, **kwargs: Any) -> str:
"""Upload a file and send it to a specific room.""" """Upload a file and send it to a specific room."""
path = Path(file_path) path = Path(file_path)
if not path.exists(): if not path.exists():
@@ -174,6 +174,22 @@ class Matrix(Provider):
if not content_uri: if not content_uri:
raise Exception("No content_uri returned") raise Exception("No content_uri returned")
# Build a fragment-free URL suitable for storage backends.
# `matrix.to` links use fragments (`#/...`) which some backends normalize away.
download_url_for_store = ""
try:
curi = str(content_uri or "").strip()
if curi.startswith("mxc://"):
rest = curi[len("mxc://"):]
if "/" in rest:
server_name, media_id = rest.split("/", 1)
server_name = str(server_name).strip()
media_id = str(media_id).strip()
if server_name and media_id:
download_url_for_store = f"{base}/_matrix/media/v3/download/{quote(server_name, safe='')}/{quote(media_id, safe='')}"
except Exception:
download_url_for_store = ""
# Determine message type # Determine message type
msgtype = "m.file" msgtype = "m.file"
ext = path.suffix.lower() ext = path.suffix.lower()
@@ -199,6 +215,44 @@ class Matrix(Provider):
if send_resp.status_code != 200: if send_resp.status_code != 200:
raise Exception(f"Matrix send message failed: {send_resp.text}") raise Exception(f"Matrix send message failed: {send_resp.text}")
event_id = (send_resp.json() or {}).get("event_id")
link = f"https://matrix.to/#/{room_id}/{event_id}" if event_id else f"https://matrix.to/#/{room_id}"
# Optional: if a PipeObject is provided and it already has store+hash,
# attach the uploaded URL back to the stored file.
try:
pipe_obj = kwargs.get("pipe_obj")
if pipe_obj is not None:
from Store import Store
# Prefer the direct media download URL for storage backends.
Store(self.config, suppress_debug=True).try_add_url_for_pipe_object(
pipe_obj,
download_url_for_store or link,
)
except Exception:
pass
return link
def send_text_to_room(self, text: str, room_id: str) -> str:
"""Send a plain text message to a specific room."""
message = str(text or "").strip()
if not message:
return ""
if not room_id:
raise Exception("Matrix room_id missing")
base, token = self._get_homeserver_and_token()
encoded_room = quote(str(room_id), safe="")
txn_id = f"mm_{int(time.time())}_{uuid.uuid4().hex[:8]}"
send_url = f"{base}/_matrix/client/v3/rooms/{encoded_room}/send/m.room.message/{txn_id}"
send_headers = {"Authorization": f"Bearer {token}"}
payload = {"msgtype": "m.text", "body": message}
send_resp = requests.put(send_url, headers=send_headers, json=payload)
if send_resp.status_code != 200:
raise Exception(f"Matrix send text failed: {send_resp.text}")
event_id = (send_resp.json() or {}).get("event_id") event_id = (send_resp.json() or {}).get("event_id")
return f"https://matrix.to/#/{room_id}/{event_id}" if event_id else f"https://matrix.to/#/{room_id}" return f"https://matrix.to/#/{room_id}/{event_id}" if event_id else f"https://matrix.to/#/{room_id}"
@@ -247,9 +301,11 @@ class Matrix(Provider):
try: try:
file_path = '' file_path = ''
delete_after = False delete_after = False
pipe_obj = None
if isinstance(payload, dict): if isinstance(payload, dict):
file_path = str(payload.get('path') or '') file_path = str(payload.get('path') or '')
delete_after = bool(payload.get('delete_after', False)) delete_after = bool(payload.get('delete_after', False))
pipe_obj = payload.get('pipe_obj')
else: else:
file_path = str(getattr(payload, 'path', '') or '') file_path = str(getattr(payload, 'path', '') or '')
if not file_path: if not file_path:
@@ -262,7 +318,7 @@ class Matrix(Provider):
print(f"Matrix upload file missing: {file_path}") print(f"Matrix upload file missing: {file_path}")
continue continue
link = self.upload_to_room(str(media_path), str(room_id)) link = self.upload_to_room(str(media_path), str(room_id), pipe_obj=pipe_obj)
if link: if link:
print(link) print(link)

View File

@@ -2,6 +2,8 @@ from __future__ import annotations
import asyncio import asyncio
import re import re
import sys
import time
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -143,6 +145,21 @@ class Telegram(Provider):
session_base = self._session_base_path() session_base = self._session_base_path()
chat, message_id = _parse_telegram_message_url(url) 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"
client = TelegramClient(str(session_base), app_id, api_hash) client = TelegramClient(str(session_base), app_id, api_hash)
try: try:
# This prompts on first run for phone/code and persists the session. # This prompts on first run for phone/code and persists the session.
@@ -226,7 +243,35 @@ class Telegram(Provider):
except Exception: except Exception:
pass pass
downloaded = _resolve(client.download_media(message, file=str(output_dir))) # Progress callback: prints to stderr so it doesn't interfere with pipeline stdout.
last_print = {"t": 0.0}
def _progress(current: int, total: int) -> None:
try:
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
pct = ""
try:
if total and total > 0:
pct = f" {min(100.0, (current / total) * 100.0):5.1f}%"
except Exception:
pct = ""
line = f"[telegram] Downloading{pct} ({_format_bytes(current)}/{_format_bytes(total)})"
sys.stderr.write("\r" + line)
sys.stderr.flush()
except Exception:
return
downloaded = _resolve(client.download_media(message, file=str(output_dir), progress_callback=_progress))
try:
sys.stderr.write("\n")
sys.stderr.flush()
except Exception:
pass
if not downloaded: if not downloaded:
raise Exception("Telegram download returned no file") raise Exception("Telegram download returned no file")
downloaded_path = Path(str(downloaded)) downloaded_path = Path(str(downloaded))

View File

@@ -24,7 +24,17 @@ class ZeroXZero(Provider):
response = client.post("https://0x0.st", files={"file": handle}) response = client.post("https://0x0.st", files={"file": handle})
if response.status_code == 200: if response.status_code == 200:
return response.text.strip() uploaded_url = response.text.strip()
try:
pipe_obj = kwargs.get("pipe_obj")
if pipe_obj is not None:
from Store import Store
Store(self.config, suppress_debug=True).try_add_url_for_pipe_object(pipe_obj, uploaded_url)
except Exception:
pass
return uploaded_url
raise Exception(f"Upload failed: {response.status_code} - {response.text}") raise Exception(f"Upload failed: {response.status_code} - {response.text}")

View File

@@ -14,6 +14,7 @@ from __future__ import annotations
import importlib import importlib
import inspect import inspect
import pkgutil import pkgutil
import re
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, Optional, Type from typing import Any, Dict, Iterable, Optional, Type
@@ -22,6 +23,9 @@ from SYS.logger import debug
from Store._base import Store as BaseStore from Store._base import Store as BaseStore
_SHA256_HEX_RE = re.compile(r"^[0-9a-fA-F]{64}$")
# Backends that failed to initialize earlier in the current process. # Backends that failed to initialize earlier in the current process.
# Keyed by (store_type, instance_key) where instance_key is the name used under config.store.<type>.<instance_key>. # Keyed by (store_type, instance_key) where instance_key is the name used under config.store.<type>.<instance_key>.
_FAILED_BACKEND_CACHE: Dict[tuple[str, str], str] = {} _FAILED_BACKEND_CACHE: Dict[tuple[str, str], str] = {}
@@ -237,3 +241,40 @@ class Store:
def is_available(self, backend_name: str) -> bool: def is_available(self, backend_name: str) -> bool:
return backend_name in self._backends return backend_name in self._backends
def try_add_url_for_pipe_object(self, pipe_obj: Any, url: str) -> bool:
"""Best-effort helper: if `pipe_obj` contains `store` + `hash`, add `url` to that store backend.
Intended for providers to attach newly generated/hosted URLs back to an existing stored file.
"""
try:
url_text = str(url or "").strip()
if not url_text:
return False
store_name = None
file_hash = None
if isinstance(pipe_obj, dict):
store_name = pipe_obj.get("store")
file_hash = pipe_obj.get("hash")
else:
store_name = getattr(pipe_obj, "store", None)
file_hash = getattr(pipe_obj, "hash", None)
store_name = str(store_name).strip() if store_name is not None else ""
file_hash = str(file_hash).strip() if file_hash is not None else ""
if not store_name or not file_hash:
return False
if not _SHA256_HEX_RE.fullmatch(file_hash):
return False
backend = self[store_name]
add_url = getattr(backend, "add_url", None)
if not callable(add_url):
return False
ok = add_url(file_hash.lower(), [url_text])
return bool(ok) if ok is not None else True
except Exception:
return False

View File

@@ -1554,22 +1554,10 @@ class Add_File(Cmdlet):
return 1 return 1
try: try:
hoster_url = provider.upload_to_room(str(media_path), str(room_id)) hoster_url = provider.upload_to_room(str(media_path), str(room_id), pipe_obj=pipe_obj)
log(f"File uploaded: {hoster_url}", file=sys.stderr) log(f"File uploaded: {hoster_url}", file=sys.stderr)
# Associate URL with Hydrus if possible
f_hash = Add_File._resolve_file_hash(None, media_path, pipe_obj, None) f_hash = Add_File._resolve_file_hash(None, media_path, pipe_obj, None)
if f_hash:
try:
store_name = getattr(pipe_obj, "store", None)
if store_name:
store = Store(config)
backend = store[str(store_name)]
client = getattr(backend, "_client", None)
if client is not None and hasattr(client, "associate_url"):
client.associate_url(str(f_hash), hoster_url)
except Exception:
pass
except Exception as exc: except Exception as exc:
log(f"Upload failed: {exc}", file=sys.stderr) log(f"Upload failed: {exc}", file=sys.stderr)
@@ -1676,24 +1664,10 @@ class Add_File(Cmdlet):
log(f"File provider '{provider_name}' not available", file=sys.stderr) log(f"File provider '{provider_name}' not available", file=sys.stderr)
return 1 return 1
hoster_url = file_provider.upload(str(media_path)) hoster_url = file_provider.upload(str(media_path), pipe_obj=pipe_obj)
log(f"File uploaded: {hoster_url}", file=sys.stderr) log(f"File uploaded: {hoster_url}", file=sys.stderr)
# Associate URL with Hydrus if possible
f_hash = Add_File._resolve_file_hash(None, media_path, pipe_obj, None) f_hash = Add_File._resolve_file_hash(None, media_path, pipe_obj, None)
if f_hash:
try:
# Only associate when we can target an explicit Hydrus store backend.
# Do not fall back to a global/default Hydrus client.
store_name = getattr(pipe_obj, "store", None)
if store_name:
store = Store(config)
backend = store[str(store_name)]
client = getattr(backend, "_client", None)
if client is not None and hasattr(client, "associate_url"):
client.associate_url(str(f_hash), hoster_url)
except Exception:
pass
except Exception as exc: except Exception as exc:
log(f"Upload failed: {exc}", file=sys.stderr) log(f"Upload failed: {exc}", file=sys.stderr)

View File

@@ -15,6 +15,24 @@ import pipeline as ctx
_MATRIX_PENDING_ITEMS_KEY = "matrix_pending_items" _MATRIX_PENDING_ITEMS_KEY = "matrix_pending_items"
_MATRIX_PENDING_TEXT_KEY = "matrix_pending_text"
def _extract_text_arg(args: Sequence[str]) -> str:
"""Extract a `-text <value>` argument from a cmdnat args list."""
if not args:
return ""
try:
tokens = list(args)
except Exception:
return ""
for i, tok in enumerate(tokens):
try:
if str(tok).lower() == "-text" and i + 1 < len(tokens):
return str(tokens[i + 1] or "").strip()
except Exception:
continue
return ""
def _normalize_to_list(value: Any) -> List[Any]: def _normalize_to_list(value: Any) -> List[Any]:
@@ -341,8 +359,16 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
log(f"Matrix not available: {exc}", file=sys.stderr) log(f"Matrix not available: {exc}", file=sys.stderr)
return 1 return 1
text_value = _extract_text_arg(args)
if not text_value:
try:
text_value = str(ctx.load_value(_MATRIX_PENDING_TEXT_KEY, default="") or "").strip()
except Exception:
text_value = ""
any_failed = False any_failed = False
for rid in room_ids: for rid in room_ids:
sent_any_for_room = False
for item in items: for item in items:
file_path = _resolve_upload_path(item, config) file_path = _resolve_upload_path(item, config)
if not file_path: if not file_path:
@@ -350,16 +376,29 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
log("Matrix upload requires a local file (path) or a direct URL on the selected item", file=sys.stderr) 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) link = provider.upload_to_room(file_path, rid, pipe_obj=item)
debug(f"✓ Sent {Path(file_path).name} -> {rid}") debug(f"✓ Sent {Path(file_path).name} -> {rid}")
if link: if link:
log(link) log(link)
sent_any_for_room = True
except Exception as exc: except Exception as exc:
any_failed = True any_failed = True
log(f"Matrix send failed for {Path(file_path).name}: {exc}", file=sys.stderr) log(f"Matrix send failed for {Path(file_path).name}: {exc}", file=sys.stderr)
# Optional caption-like follow-up message (sent once per room).
if text_value and sent_any_for_room:
try:
provider.send_text_to_room(text_value, rid)
except Exception as exc:
any_failed = True
log(f"Matrix text send failed: {exc}", file=sys.stderr)
# Clear pending items once we've attempted to send. # Clear pending items once we've attempted to send.
ctx.store_value(_MATRIX_PENDING_ITEMS_KEY, []) ctx.store_value(_MATRIX_PENDING_ITEMS_KEY, [])
try:
ctx.store_value(_MATRIX_PENDING_TEXT_KEY, "")
except Exception:
pass
return 1 if any_failed else 0 return 1 if any_failed else 0
# Default stage: show rooms, then wait for @N selection to resume sending. # Default stage: show rooms, then wait for @N selection to resume sending.
@@ -369,6 +408,10 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
return 1 return 1
ctx.store_value(_MATRIX_PENDING_ITEMS_KEY, selected_items) ctx.store_value(_MATRIX_PENDING_ITEMS_KEY, selected_items)
try:
ctx.store_value(_MATRIX_PENDING_TEXT_KEY, _extract_text_arg(args))
except Exception:
pass
from Provider.matrix import Matrix from Provider.matrix import Matrix
try: try:
@@ -431,6 +474,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="text", type="string", description="Send a follow-up text message after each upload (caption-like)", required=False),
], ],
exec=_run exec=_run
) )