dfd
This commit is contained in:
167
Provider/fileio.py
Normal file
167
Provider/fileio.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ProviderCore.base import Provider
|
||||
from SYS.logger import log
|
||||
|
||||
|
||||
def _pick_provider_config(config: Any) -> Dict[str, Any]:
|
||||
if not isinstance(config, dict):
|
||||
return {}
|
||||
provider = config.get("provider")
|
||||
if not isinstance(provider, dict):
|
||||
return {}
|
||||
entry = provider.get("file.io")
|
||||
if isinstance(entry, dict):
|
||||
return entry
|
||||
return {}
|
||||
|
||||
|
||||
def _extract_link(payload: Any) -> Optional[str]:
|
||||
if isinstance(payload, dict):
|
||||
for key in ("link", "url", "downloadLink", "download_url"):
|
||||
val = payload.get(key)
|
||||
if isinstance(val, str) and val.strip().startswith(("http://", "https://")):
|
||||
return val.strip()
|
||||
for nested_key in ("data", "file", "result"):
|
||||
nested = payload.get(nested_key)
|
||||
found = _extract_link(nested)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def _extract_key(payload: Any) -> Optional[str]:
|
||||
if isinstance(payload, dict):
|
||||
for key in ("key", "id", "uuid"):
|
||||
val = payload.get(key)
|
||||
if isinstance(val, str) and val.strip():
|
||||
return val.strip()
|
||||
for nested_key in ("data", "file", "result"):
|
||||
nested = payload.get(nested_key)
|
||||
found = _extract_key(nested)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
class FileIO(Provider):
|
||||
"""File provider for file.io."""
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
super().__init__(config)
|
||||
conf = _pick_provider_config(self.config)
|
||||
self._base_url = str(conf.get("base_url") or "https://file.io").strip().rstrip("/")
|
||||
self._api_key = conf.get("api_key")
|
||||
self._default_expires = conf.get("expires")
|
||||
self._default_max_downloads = conf.get("maxDownloads")
|
||||
if self._default_max_downloads is None:
|
||||
self._default_max_downloads = conf.get("max_downloads")
|
||||
self._default_auto_delete = conf.get("autoDelete")
|
||||
if self._default_auto_delete is None:
|
||||
self._default_auto_delete = conf.get("auto_delete")
|
||||
|
||||
def validate(self) -> bool:
|
||||
return True
|
||||
|
||||
def upload(self, file_path: str, **kwargs: Any) -> str:
|
||||
from API.HTTP import HTTPClient
|
||||
from models import ProgressFileReader
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
data: Dict[str, Any] = {}
|
||||
expires = kwargs.get("expires", self._default_expires)
|
||||
max_downloads = kwargs.get("maxDownloads", kwargs.get("max_downloads", self._default_max_downloads))
|
||||
auto_delete = kwargs.get("autoDelete", kwargs.get("auto_delete", self._default_auto_delete))
|
||||
|
||||
if expires not in (None, ""):
|
||||
data["expires"] = expires
|
||||
if max_downloads not in (None, ""):
|
||||
data["maxDownloads"] = max_downloads
|
||||
if auto_delete not in (None, ""):
|
||||
data["autoDelete"] = auto_delete
|
||||
|
||||
headers: Dict[str, str] = {"User-Agent": "Medeia-Macina/1.0", "Accept": "application/json"}
|
||||
if isinstance(self._api_key, str) and self._api_key.strip():
|
||||
# Some file.io plans use bearer tokens; keep optional.
|
||||
headers["Authorization"] = f"Bearer {self._api_key.strip()}"
|
||||
|
||||
try:
|
||||
with HTTPClient(headers=headers) as client:
|
||||
with open(file_path, "rb") as handle:
|
||||
filename = os.path.basename(file_path)
|
||||
try:
|
||||
total = os.path.getsize(file_path)
|
||||
except Exception:
|
||||
total = None
|
||||
wrapped = ProgressFileReader(handle, total_bytes=total, label="upload")
|
||||
response = client.request(
|
||||
"POST",
|
||||
f"{self._base_url}/upload",
|
||||
data=data or None,
|
||||
files={"file": (filename, wrapped)},
|
||||
follow_redirects=True,
|
||||
raise_for_status=False,
|
||||
)
|
||||
|
||||
if response.status_code >= 400:
|
||||
location = response.headers.get("location") or response.headers.get("Location")
|
||||
ct = response.headers.get("content-type") or response.headers.get("Content-Type")
|
||||
raise Exception(f"Upload failed: {response.status_code} (content-type={ct}, location={location}) - {response.text}")
|
||||
|
||||
payload: Any
|
||||
try:
|
||||
payload = response.json()
|
||||
except Exception:
|
||||
payload = None
|
||||
|
||||
# If the server ignored our Accept header and returned HTML, this is almost
|
||||
# certainly the wrong endpoint or an upstream block.
|
||||
ct = (response.headers.get("content-type") or response.headers.get("Content-Type") or "").lower()
|
||||
if (payload is None) and ("text/html" in ct):
|
||||
raise Exception("file.io returned HTML instead of JSON; expected API response from /upload")
|
||||
|
||||
if isinstance(payload, dict) and payload.get("success") is False:
|
||||
reason = payload.get("message") or payload.get("error") or payload.get("status")
|
||||
raise Exception(str(reason or "Upload failed"))
|
||||
|
||||
uploaded_url = _extract_link(payload)
|
||||
if not uploaded_url:
|
||||
# Some APIs may return the link as plain text.
|
||||
text = str(response.text or "").strip()
|
||||
if text.startswith(("http://", "https://")):
|
||||
uploaded_url = text
|
||||
|
||||
if not uploaded_url:
|
||||
key = _extract_key(payload)
|
||||
if key:
|
||||
uploaded_url = f"{self._base_url}/{key.lstrip('/')}"
|
||||
|
||||
if not uploaded_url:
|
||||
try:
|
||||
snippet = (response.text or "").strip()
|
||||
if len(snippet) > 300:
|
||||
snippet = snippet[:300] + "..."
|
||||
except Exception:
|
||||
snippet = "<unreadable response>"
|
||||
raise Exception(f"Upload succeeded but response did not include a link (response: {snippet})")
|
||||
|
||||
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
|
||||
|
||||
except Exception as exc:
|
||||
log(f"[file.io] Upload error: {exc}", file=sys.stderr)
|
||||
raise
|
||||
@@ -146,6 +146,7 @@ class Matrix(Provider):
|
||||
|
||||
def upload_to_room(self, file_path: str, room_id: str, **kwargs: Any) -> str:
|
||||
"""Upload a file and send it to a specific room."""
|
||||
from models import ProgressFileReader
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
@@ -167,7 +168,8 @@ class Matrix(Provider):
|
||||
# Upload media
|
||||
upload_url = f"{base}/_matrix/media/v3/upload"
|
||||
with open(path, "rb") as handle:
|
||||
resp = requests.post(upload_url, headers=headers, data=handle, params={"filename": filename})
|
||||
wrapped = ProgressFileReader(handle, total_bytes=int(path.stat().st_size), label="upload")
|
||||
resp = requests.post(upload_url, headers=headers, data=wrapped, params={"filename": filename})
|
||||
if resp.status_code != 200:
|
||||
raise Exception(f"Matrix upload failed: {resp.text}")
|
||||
content_uri = (resp.json() or {}).get("content_uri")
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -79,6 +80,215 @@ class Telegram(Provider):
|
||||
telegram_conf = self.config.get("provider", {}).get("telegram", {}) if isinstance(self.config, dict) else {}
|
||||
self._app_id = telegram_conf.get("app_id")
|
||||
self._api_hash = telegram_conf.get("api_hash")
|
||||
self._bot_token = telegram_conf.get("bot_token")
|
||||
# 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")
|
||||
if self._part_size_kb is None:
|
||||
self._part_size_kb = telegram_conf.get("chunk_kb")
|
||||
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 _legacy_session_base_path(self) -> Path:
|
||||
# Older versions stored sessions under Log/medeia_macina.
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
return root / "Log" / "medeia_macina" / "telegram"
|
||||
|
||||
def _migrate_legacy_session_if_needed(self) -> None:
|
||||
"""If a legacy Telethon session exists, copy it to the new root location."""
|
||||
try:
|
||||
new_base = self._session_base_path()
|
||||
new_session = Path(str(new_base) + ".session")
|
||||
if new_session.is_file():
|
||||
return
|
||||
|
||||
legacy_base = self._legacy_session_base_path()
|
||||
legacy_session = Path(str(legacy_base) + ".session")
|
||||
if not legacy_session.is_file():
|
||||
return
|
||||
|
||||
for suffix in (".session", ".session-journal", ".session-wal", ".session-shm"):
|
||||
src = Path(str(legacy_base) + suffix)
|
||||
dst = Path(str(new_base) + suffix)
|
||||
try:
|
||||
if src.is_file() and not dst.exists():
|
||||
shutil.copy2(str(src), str(dst))
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def _session_file_path(self) -> Path:
|
||||
self._migrate_legacy_session_if_needed()
|
||||
base = self._session_base_path()
|
||||
return Path(str(base) + ".session")
|
||||
|
||||
def _has_session(self) -> bool:
|
||||
self._migrate_legacy_session_if_needed()
|
||||
try:
|
||||
return self._session_file_path().is_file()
|
||||
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.
|
||||
"""
|
||||
if self._has_session():
|
||||
return True
|
||||
|
||||
# Never prompt in non-interactive contexts.
|
||||
try:
|
||||
if not bool(getattr(sys.stdin, "isatty", lambda: False)()):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
from telethon.sync import TelegramClient
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
app_id, api_hash = self._credentials()
|
||||
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")
|
||||
sys.stderr.write("[telegram] Enter 1 or 2: ")
|
||||
sys.stderr.flush()
|
||||
choice = ""
|
||||
try:
|
||||
choice = str(input()).strip().lower()
|
||||
except EOFError:
|
||||
choice = ""
|
||||
|
||||
use_bot = choice in {"2", "b", "bot", "token"}
|
||||
bot_token = ""
|
||||
if use_bot:
|
||||
sys.stderr.write("[telegram] Bot token: ")
|
||||
sys.stderr.flush()
|
||||
try:
|
||||
bot_token = str(input()).strip()
|
||||
except EOFError:
|
||||
bot_token = ""
|
||||
if not bot_token:
|
||||
return False
|
||||
self._bot_token = bot_token
|
||||
else:
|
||||
sys.stderr.write("[telegram] Phone login selected (Telethon will prompt for phone + code).\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
session_base = self._session_base_path()
|
||||
client = TelegramClient(str(session_base), app_id, api_hash)
|
||||
try:
|
||||
if use_bot:
|
||||
_resolve(client.start(bot_token=bot_token))
|
||||
else:
|
||||
_resolve(client.start())
|
||||
finally:
|
||||
try:
|
||||
_resolve(client.disconnect())
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
sys.stderr.write("\n")
|
||||
sys.stderr.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return self._has_session()
|
||||
|
||||
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.
|
||||
"""
|
||||
if self._has_session():
|
||||
return True
|
||||
bot_token = str(bot_token or "").strip()
|
||||
if not bot_token:
|
||||
return False
|
||||
try:
|
||||
from telethon.sync import TelegramClient
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
app_id, api_hash = self._credentials()
|
||||
except Exception:
|
||||
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()
|
||||
client = TelegramClient(str(session_base), app_id, api_hash)
|
||||
try:
|
||||
_resolve(client.start(bot_token=bot_token))
|
||||
finally:
|
||||
try:
|
||||
_resolve(client.disconnect())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return self._has_session()
|
||||
|
||||
def _resolve_part_size_kb(self, file_size: Optional[int]) -> int:
|
||||
# Default: bias to max throughput.
|
||||
val = self._part_size_kb
|
||||
try:
|
||||
if val not in (None, ""):
|
||||
ps = int(str(val).strip())
|
||||
else:
|
||||
ps = 1024
|
||||
except Exception:
|
||||
ps = 1024
|
||||
|
||||
# Clamp to Telethon-safe range.
|
||||
if ps < 4:
|
||||
ps = 4
|
||||
if ps > 1024:
|
||||
ps = 1024
|
||||
# Must be divisible by 4.
|
||||
ps = int(ps / 4) * 4
|
||||
if ps <= 0:
|
||||
ps = 64
|
||||
|
||||
# For very small files, reduce overhead a bit (still divisible by 4).
|
||||
try:
|
||||
if file_size is not None and int(file_size) > 0:
|
||||
if int(file_size) < 2 * 1024 * 1024:
|
||||
ps = min(ps, 256)
|
||||
elif int(file_size) < 10 * 1024 * 1024:
|
||||
ps = min(ps, 512)
|
||||
except Exception:
|
||||
pass
|
||||
return ps
|
||||
|
||||
def validate(self) -> bool:
|
||||
try:
|
||||
@@ -91,16 +301,29 @@ class Telegram(Provider):
|
||||
except Exception:
|
||||
app_id = None
|
||||
api_hash = str(self._api_hash).strip() if self._api_hash not in (None, "") else ""
|
||||
return bool(app_id and api_hash)
|
||||
if not bool(app_id and api_hash):
|
||||
return False
|
||||
|
||||
# Consider Telegram "configured" only if a persisted session exists.
|
||||
if self._has_session():
|
||||
return True
|
||||
|
||||
# If a bot token is already configured, attempt a non-interactive login.
|
||||
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 False
|
||||
Telegram._startup_auth_attempted = True
|
||||
return bool(self._ensure_session_interactive())
|
||||
|
||||
def _session_base_path(self) -> Path:
|
||||
# Store session alongside cookies.txt at repo root.
|
||||
# Telethon uses this as base name and writes "<base>.session".
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
session_dir = root / "Log" / "medeia_macina"
|
||||
try:
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return session_dir / "telegram"
|
||||
return root / "telegram"
|
||||
|
||||
def _credentials(self) -> Tuple[int, str]:
|
||||
raw_app_id = self._app_id
|
||||
@@ -266,7 +489,19 @@ class Telegram(Provider):
|
||||
except Exception:
|
||||
return
|
||||
|
||||
downloaded = _resolve(client.download_media(message, file=str(output_dir), progress_callback=_progress))
|
||||
part_kb = self._resolve_part_size_kb(file_size)
|
||||
try:
|
||||
downloaded = _resolve(
|
||||
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))
|
||||
try:
|
||||
sys.stderr.write("\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
@@ -13,6 +13,7 @@ class ZeroXZero(Provider):
|
||||
|
||||
def upload(self, file_path: str, **kwargs: Any) -> str:
|
||||
from API.HTTP import HTTPClient
|
||||
from models import ProgressFileReader
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
@@ -21,7 +22,12 @@ class ZeroXZero(Provider):
|
||||
headers = {"User-Agent": "Medeia-Macina/1.0"}
|
||||
with HTTPClient(headers=headers) as client:
|
||||
with open(file_path, "rb") as handle:
|
||||
response = client.post("https://0x0.st", files={"file": handle})
|
||||
try:
|
||||
total = os.path.getsize(file_path)
|
||||
except Exception:
|
||||
total = None
|
||||
wrapped = ProgressFileReader(handle, total_bytes=total, label="upload")
|
||||
response = client.post("https://0x0.st", files={"file": wrapped})
|
||||
|
||||
if response.status_code == 200:
|
||||
uploaded_url = response.text.strip()
|
||||
|
||||
Reference in New Issue
Block a user