"""Native Discord ingester — the Discord ADAPTER over `ingest_core.Ingester`. Thin counterpart to subscribestar_ingester (milestone 428). The walk's modes, both ledgers, cursor checkpointing and the post-first capture live in the core; this wires in the Discord client, downloader, ledger models and key. Two things differ from the cookie platforms: - Discord authenticates with a user TOKEN, so `auth_token` is the credential here rather than an argument accepted and ignored. - The body canary is off. It fails a walk whose first 30+ captured posts all came back without text, on the theory that a creator nearly always writes something; a Discord drop is routinely files and nothing else, so on Discord that is an ordinary backfill, not a broken parser. `campaign_id` is the source URL (a server, channel, thread or category link). FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API. """ from __future__ import annotations import asyncio from collections.abc import Callable from pathlib import Path from ..models import DiscordFailedMedia, DiscordSeenMedia from .discord_client import DiscordAPIError, DiscordClient, MediaItem from .discord_downloader import DiscordDownloader from .ingest_core import Ingester _LEDGER_KEY_MAX = 128 def _ledger_key(media: MediaItem) -> str: """`:` — stable across edits (see MediaItem).""" return f"{media.post_id}:{media.media_id}"[:_LEDGER_KEY_MAX] class DiscordIngester(Ingester): """Walk a Discord source's channels, download unseen files, return a `DownloadResult`. `client` / `downloader` are injectable for tests.""" def __init__( self, images_root: Path, cookies_path: str | None, session_factory: Callable[[], object], *, validate: bool = True, rate_limit: float = 0.0, request_sleep: float = 0.0, auth_token: str | None = None, client: DiscordClient | None = None, downloader: DiscordDownloader | None = None, ): del cookies_path # Discord authenticates by token (uniform signature) self.images_root = Path(images_root) super().__init__( client=client if client is not None else DiscordClient( auth_token, request_sleep=request_sleep, ), downloader=downloader if downloader is not None else DiscordDownloader( self.images_root, validate=validate, rate_limit=rate_limit, ), session_factory=session_factory, seen_model=DiscordSeenMedia, failed_model=DiscordFailedMedia, seen_constraint="uq_discord_seen_media_source_id", failed_constraint="uq_discord_failed_media_source_id", ledger_key=_ledger_key, platform="discord", error_base=DiscordAPIError, drift_label="Discord API", body_canary=False, ) async def verify_discord_credential(url: str, auth_token: str | None) -> tuple[bool | None, str]: """The uniform `(ok, message)` probe: is the token valid, and can its account see the channel or server the source names?""" if not auth_token: return False, "No Discord token is saved — add one under Credentials." client = DiscordClient(auth_token) loop = asyncio.get_running_loop() return await loop.run_in_executor(None, client.verify_auth, url)