# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """The !sztuka command: one work of art, and what it is. A near-copy of !pułapka, deliberately: same committed pool of Commons page ids, same single imageinfo request, same live-fetched caption, same absence of a cache. What ``traps_command`` says about each of those holds here. The one thing that is new is the skew. The pool is two lists rather than one, and ``POLISH_SHARE`` per cent of posts come from the Polish one **whatever the two sizes are** -- the pool is chosen before the work is. That is worth saying out loud because the other reading is defensible and this is not it: if the Polish pool holds two thousand works and the world pool twenty thousand, an individual Polish work is drawn about ten times as often as an individual foreign one. Skewing the output is the entire point; a merged pool weighted by size would produce a command that is 90% foreign and technically correct. Nothing is cached, for the reason ``traps_command`` gives: a uniform draw from tens of thousands warms a cache essentially never, so the disk would fill with pictures nobody asks for twice. """ from __future__ import annotations import json import secrets from base64 import b64encode from pathlib import Path from typing import TYPE_CHECKING, Final from signal_bot.analytics import probe from signal_bot.animals import load_aliases from signal_bot.commons import caption_for, imageinfo_params, page_of from signal_bot.endpoints import THUMB_WIDTH if TYPE_CHECKING: from collections.abc import Callable, Sequence from signal_bot.context import CommandContext from signal_bot.decorators import Decorator from signal_bot.http_client import ImageFetcher from signal_bot.registry import CommandRegistry, Handler COMMAND_NAME: Final = "art" DESCRIPTION: Final = "losowe dzieło sztuki" ART_FILE: Final = Path(__file__).with_name("art.json") # Per cent of posts drawn from the Polish pool. POLISH_SHARE: Final = 60 PER_CENT: Final = 100 def load_art(path: Path = ART_FILE) -> tuple[tuple[int, ...], tuple[int, ...]]: """Read both pools. The file holds Commons page ids -- bare integers -- and that is checked here rather than assumed, for the reason ``traps_command.load_traps`` gives. It matters more for art than for traps: a Commons painting filename is very often literally somebody's name, so a pool of filenames would be a list of names committed to a public repository. Args: path: Where to read the pools from. Defaults to the shipped file. Returns: The Polish pool and the world pool. Raises: ValueError: If either pool is empty or holds anything but integers. """ document = json.loads(path.read_text(encoding="utf-8")) pools: list[tuple[int, ...]] = [] for name in ("polish", "world"): pool = document[name] if not pool or not all(isinstance(page, int) for page in pool): msg = f"{path} must hold a non-empty list of Commons page ids under {name!r}" raise ValueError(msg) pools.append(tuple(pool)) return pools[0], pools[1] def random_art( polish: Sequence[int], world: Sequence[int], randbelow: Callable[[int], int] = secrets.randbelow, ) -> int: """Return one page id, skewed towards the Polish pool. secrets rather than random for the reason animals.paged_url gives: the choice is not security-sensitive, but ruff's ALL rule set flags `random` and this repository forbids the noqa that would silence it. ``randbelow`` is a parameter because ``secrets.randbelow`` cannot be seeded, and a test that asserted the 60/40 split by taking real draws would either flake or need thousands of iterations. Injecting it lets the test name the branch it means. """ pool = polish if randbelow(PER_CENT) < POLISH_SHARE else world return pool[randbelow(len(pool))] def art_handler( fetcher: ImageFetcher, api_url: str, pools: tuple[Sequence[int], Sequence[int]] | None = None, ) -> Handler: """Build the handler that posts one random work of art.""" polish, world = load_art() if pools is None else (tuple(pools[0]), tuple(pools[1])) async def handler(ctx: CommandContext) -> None: # One request, because the caption and the picture have to describe # the same file; two would be a chance for them to disagree. page_id = random_art(polish, world) # A Commons page id: public, and an identifier cannot carry a # name. It is what turns "the art pool feels repetitive" into a # repeat rate. The caption is fetched and posted, never recorded. probe.note_item(str(page_id)) document = await fetcher.fetch_json( api_url, imageinfo_params(THUMB_WIDTH, page_id=page_id, describe=True), ) page = page_of(document) image = await fetcher.fetch_image(str(page["imageinfo"][0]["thumburl"])) await ctx.reply_image(b64encode(image).decode("ascii"), caption_for(page)) return handler def register_art_command( registry: CommandRegistry, fetcher: ImageFetcher, api_url: str, *, decorate: Decorator | None = None, aliases: dict[str, list[str]] | None = None, pools: tuple[Sequence[int], Sequence[int]] | None = None, ) -> None: """Register !sztuka under every trigger aliases.json gives it.""" table = load_aliases() if aliases is None else aliases handler = art_handler(fetcher, api_url, pools) if decorate is not None: handler = decorate(handler) registry.register(COMMAND_NAME, table[COMMAND_NAME], handler, DESCRIPTION)