diff --git a/DOCS-commands.md b/DOCS-commands.md
index 9c7228b..3492379 100644
--- a/DOCS-commands.md
+++ b/DOCS-commands.md
@@ -15,6 +15,7 @@ This file is the trigger reference — every spelling that resolves, which
| cat | `!kot` `!koty` `!kots` `!cat` `!cats` `!meow` `!miau` + styled variants |
| dog | `!pies` `!psy` `!dog` `!dogs` `!woof` `!szczek` + styled variants |
| gecko | `!gekon` `!gekony` `!geko` `!gecko` `!geckon` `!geckos` |
+| trap | `!pułapka` `!pulapka` `!pułapki` `!pulapki` `!pułapkę` `!pulapke` `!trap` `!traps` `!trapy` `!trapper` `!sidła` `!sidla` `!sidło` `!sidlo` `!wnyk` `!wnyki` `!potrzask` `!zasadzka` `!klatka` `!samołówka` `!samolowka` `!beartrap` `!bear_trap` `!snare` `!snares` `!mousetrap` |
| pope | `!papież` `!papiez` `!papieze` `!papieża` `!papa` `!pope` `!popes` `!pontifex` + styled variants |
| stats_today | `!staty` `!statystyki` `!stats` `!dziś` `!dzis` |
| stats_yesterday | `!wczoraj` `!yesterday` |
diff --git a/README.md b/README.md
index 3a5ad86..5cc58c6 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# signal-bot
-A Signal group bot: `!cat` / `!dog` / `!gekon` image commands, `!papież`, and
-group message statistics. The group's own data stays out: `DOCS-privacy.md`.
+A Signal group bot: `!cat` / `!dog` / `!gekon` / `!pułapka` image commands,
+`!papież`, and group statistics. The group's own data stays out: `DOCS-privacy.md`.
Runs on its **own registered Signal number** (or, if you prefer, as a linked
device on an existing account) via
diff --git a/fake_signal/scenarios/trap_command.yaml b/fake_signal/scenarios/trap_command.yaml
new file mode 100644
index 0000000..78ec404
--- /dev/null
+++ b/fake_signal/scenarios/trap_command.yaml
@@ -0,0 +1,5 @@
+name: "!pułapka posts a photograph of the device, captioned with what it is"
+inject:
+ - { text: "!pułapka", uuid: uuid-trap, name: Trapper }
+expect:
+ - { kind: image, to: reply }
diff --git a/signal_bot/aliases.json b/signal_bot/aliases.json
index f5e3dda..690171d 100644
--- a/signal_bot/aliases.json
+++ b/signal_bot/aliases.json
@@ -30,6 +30,34 @@
"!geckon",
"!geckos"
],
+ "trap": [
+ "!pułapka",
+ "!pulapka",
+ "!pułapki",
+ "!pulapki",
+ "!pułapkę",
+ "!pulapke",
+ "!trap",
+ "!traps",
+ "!trapy",
+ "!trapper",
+ "!sidła",
+ "!sidla",
+ "!sidło",
+ "!sidlo",
+ "!wnyk",
+ "!wnyki",
+ "!potrzask",
+ "!zasadzka",
+ "!klatka",
+ "!samołówka",
+ "!samolowka",
+ "!beartrap",
+ "!bear_trap",
+ "!snare",
+ "!snares",
+ "!mousetrap"
+ ],
"pope": [
"!papież",
"!papiez",
diff --git a/signal_bot/bot.py b/signal_bot/bot.py
index 3a9096f..ac4f700 100644
--- a/signal_bot/bot.py
+++ b/signal_bot/bot.py
@@ -35,6 +35,7 @@ from signal_bot.stats import periods
from signal_bot.stats.commands import register_stats_commands
from signal_bot.stats.nightly import days_to_report, mark_reported
from signal_bot.suggest_command import register_suggest_command
+from signal_bot.traps_command import register_trap_command
from signal_bot.versioning import POPE
if TYPE_CHECKING:
@@ -198,6 +199,7 @@ def build_bot(
endpoints=config.image_api_urls,
)
register_pope_command(registry, portraits, decorate=rate_limit)
+ register_trap_command(registry, fetcher, config.commons_api_url, decorate=rate_limit)
register_stats_commands(registry, decorate=rate_limit)
# Rate-limited like every other command, and unlike !add_photo, whose
# "NO CAP, NO LIMITS" is a deliberate exception rather than a default.
diff --git a/signal_bot/commons.py b/signal_bot/commons.py
new file mode 100644
index 0000000..951952f
--- /dev/null
+++ b/signal_bot/commons.py
@@ -0,0 +1,126 @@
+# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
+"""What a Wikimedia Commons image fetch needs, shared by its two callers.
+
+`!papież` names a file by title and caches the result; `!pułapka` names one by
+page id and does not cache anything. Everything between those two ends -- the
+query, the width, the place `thumburl` turns up in the answer -- is identical,
+so it lives here rather than twice.
+"""
+
+from __future__ import annotations
+
+import html
+import re
+from typing import Any, Final
+
+from signal_bot.http_client import ImageFetchError
+
+# Where imageinfo puts the thumbnail URL in its reply. The same path whether
+# the file was named by title or by page id.
+THUMB_PATH: Final = ("query", "pages", 0, "imageinfo", 0, "thumburl")
+# Commons hosts these four for the 267 portraits: 245 jpg, 15 png, 6 gif,
+# 1 jpeg. An unknown suffix is refused rather than guessed, because the
+# extension is what tells Signal how to render the attachment.
+SUFFIXES: Final = frozenset({".jpg", ".jpeg", ".png", ".gif"})
+
+
+# Long enough to say what the thing is, short enough not to bury the photo.
+CAPTION_LIMIT: Final = 220
+_TAGS: Final = re.compile(r"<[^>]+>")
+# The Fisheries records put their prose first and then an HTML list of
+# catalogue fields ("Subject: Fishing nets", "Tag: Fisheries Techniques").
+# Cutting at the list keeps the sentence and drops the filing cabinet.
+_LIST: Final = "
dict[str, str]:
+ """Build the imageinfo query for one Commons file.
+
+ Args:
+ width: Thumbnail width to ask for, in pixels.
+ title: Full page title, "File:" prefix included.
+ page_id: Commons page id. Preferred where the caller stores a list,
+ because an integer cannot carry somebody's name into the
+ repository the way a filename can.
+ describe: Also ask for the file's description, so one request
+ answers both what the picture is and where it lives.
+
+ Returns:
+ Query parameters, ready for ImageFetcher.fetch_bytes.
+
+ Raises:
+ ValueError: If both or neither of title and page_id were given.
+ Silently preferring one would make a caller's typo look like a
+ missing file, which is the same shape as a real 404.
+ """
+ if (title is None) == (page_id is None):
+ msg = "name a Commons file by exactly one of title or page_id"
+ raise ValueError(msg)
+ selector = {"titles": title} if title is not None else {"pageids": str(page_id)}
+ described = (
+ {"iiprop": "url|extmetadata", "iiextmetadatafilter": "ImageDescription"}
+ if describe
+ else {"iiprop": "url"}
+ )
+ return {
+ "action": "query",
+ "format": "json",
+ "formatversion": "2",
+ "prop": "imageinfo",
+ "iiurlwidth": str(width),
+ **described,
+ **selector,
+ }
+
+
+def page_of(document: object) -> dict[str, Any]:
+ """Return the single page an imageinfo answer describes.
+
+ Raises:
+ ImageFetchError: If Commons answered without one -- a deleted or
+ renumbered page id comes back as `{"missing": true}` and has no
+ imageinfo at all.
+ """
+ pages = document.get("query", {}).get("pages", []) if isinstance(document, dict) else []
+ if not pages or not pages[0].get("imageinfo"):
+ msg = f"Commons named no image: {document!r:.200}"
+ raise ImageFetchError(msg)
+ page: dict[str, Any] = pages[0]
+ return page
+
+
+def caption_for(page: dict[str, Any]) -> str:
+ """Return the line that rides along with the photograph.
+
+ Commons descriptions are HTML, sometimes several languages deep, and
+ sometimes only the filename repeated back. So the tags come out, the
+ catalogue list is cut, and a description that says nothing falls back to
+ the title -- but the file's own page is always linked, because that is
+ what answers "what am I even looking at" when the rest does not.
+
+ The text is fetched at run time and posted to the group; none of it is
+ ever written into this repository, which is why it may be free text
+ where traps.json may not.
+ """
+ info = page["imageinfo"][0]
+ raw = str(info.get("extmetadata", {}).get("ImageDescription", {}).get("value", ""))
+ text = " ".join(html.unescape(_TAGS.sub(" ", raw.split(_LIST, 1)[0])).split())
+ if text.lower().startswith(_BOILERPLATE):
+ text = ""
+ if not text:
+ text = str(page.get("title", "")).removeprefix("File:").rsplit(".", 1)[0]
+ if len(text) > CAPTION_LIMIT:
+ text = text[:CAPTION_LIMIT].rstrip() + "…"
+ url = str(info.get("descriptionurl", ""))
+ return f"{text}\n{url}" if url else text
diff --git a/signal_bot/endpoints.py b/signal_bot/endpoints.py
index 661995a..6338d3d 100644
--- a/signal_bot/endpoints.py
+++ b/signal_bot/endpoints.py
@@ -22,11 +22,12 @@ DEFAULT_GECKO_API_URL: Final = (
"?taxon_id=371057&photos=true&quality_grade=research"
"&photo_license=cc-by,cc-by-sa,cc0&per_page=1"
)
-# Commons resolves a portrait *filename* to a real upload.wikimedia.org URL.
-# Special:FilePath would be shorter but answers 302, and this repo's httpx
-# client does not follow redirects; imageinfo hands back the final URL and a
-# thumbnail in one hop, which is also what keeps a 4000px original off Signal.
+# Commons resolves a file -- named by title for !papiez, by page id for
+# !pulapka -- to a real upload.wikimedia.org URL. Special:FilePath would be
+# shorter but answers 302, and this repo's httpx client does not follow
+# redirects; imageinfo hands back the final URL and a thumbnail in one hop,
+# which is also what keeps a 4000px original off Signal.
DEFAULT_COMMONS_API_URL: Final = "https://commons.wikimedia.org/w/api.php"
# Wide enough to look like a photograph rather than an avatar, small enough
# that the whole 267-portrait cache stays well under 100 MB.
-PORTRAIT_WIDTH: Final = 640
+THUMB_WIDTH: Final = 640
diff --git a/signal_bot/help_command.py b/signal_bot/help_command.py
index ba26563..538323c 100644
--- a/signal_bot/help_command.py
+++ b/signal_bot/help_command.py
@@ -38,7 +38,7 @@ HEADING: Final = "Komendy"
# Anything missing from here still gets printed, in a block of its own -- see
# the module docstring.
GROUPS: Final[tuple[tuple[str, tuple[str, ...]], ...]] = (
- ("Obrazki", ("cat", "dog", "gecko")),
+ ("Obrazki", ("cat", "dog", "gecko", "trap")),
("Papież", ("pope",)),
("Propozycje", ("suggest",)),
(
diff --git a/signal_bot/http_client.py b/signal_bot/http_client.py
index 96dc697..b7ce4be 100644
--- a/signal_bot/http_client.py
+++ b/signal_bot/http_client.py
@@ -65,17 +65,32 @@ class ImageFetcher:
Raises:
ImageFetchError: The JSON did not contain a usable URL.
"""
- response = await self._client.get(api_url, params=params)
- response.raise_for_status()
- image_url = _walk(response.json(), url_path)
+ image_url = _walk(await self.fetch_json(api_url, params), url_path)
if rewrite is not None:
image_url = image_url.replace(*rewrite)
+ return await self.fetch_image(image_url)
- # These bytes are never written to a path built from the remote URL:
- # the original bot named a local file after the URL's last path
- # segment and wrote it into the process CWD, which is both path
- # traversal and unbounded. portraits.py does cache to disk, but under
- # a name it derives from the pope's ordinal, never from upstream.
+ async def fetch_json(self, api_url: str, params: dict[str, str] | None = None) -> object:
+ """Fetch one JSON document from an API.
+
+ Separate from fetch_bytes because a Commons answer carries more than
+ the picture: !pułapka needs the description in the same reply, and
+ asking twice would be both wasteful and a chance for the two halves
+ to disagree.
+ """
+ response = await self._client.get(api_url, params=params)
+ response.raise_for_status()
+ return response.json()
+
+ async def fetch_image(self, image_url: str) -> bytes:
+ """Fetch the bytes at an already-resolved image URL.
+
+ These bytes are never written to a path built from the remote URL:
+ the original bot named a local file after the URL's last path
+ segment and wrote it into the process CWD, which is both path
+ traversal and unbounded. portraits.py does cache to disk, but under
+ a name it derives from the pope's ordinal, never from upstream.
+ """
image = await self._client.get(image_url)
image.raise_for_status()
return image.content
diff --git a/signal_bot/pack.py b/signal_bot/pack.py
index 75cee02..d7683f5 100644
--- a/signal_bot/pack.py
+++ b/signal_bot/pack.py
@@ -42,7 +42,7 @@ MEDIA_DIR: Final = "media"
# What Signal is told an attachment is. Closed, and checked at load time: the
# suffix is the only thing that tells Signal how to render the file, so an
-# unknown one is refused rather than guessed. Mirrors portraits._SUFFIXES.
+# unknown one is refused rather than guessed. Mirrors commons.SUFFIXES.
MEDIA_TYPES: Final[dict[str, str]] = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
diff --git a/signal_bot/portraits.py b/signal_bot/portraits.py
index 74152f4..497c9c6 100644
--- a/signal_bot/portraits.py
+++ b/signal_bot/portraits.py
@@ -12,22 +12,16 @@ from __future__ import annotations
import base64
import os
from pathlib import Path
-from typing import TYPE_CHECKING, Final
+from typing import TYPE_CHECKING
-from signal_bot.endpoints import PORTRAIT_WIDTH
+from signal_bot.commons import SUFFIXES, THUMB_PATH, imageinfo_params
+from signal_bot.endpoints import THUMB_WIDTH
from signal_bot.http_client import ImageFetchError
if TYPE_CHECKING:
from signal_bot.http_client import ImageFetcher
from signal_bot.versioning import Pope
-# Where imageinfo puts the thumbnail URL in its reply.
-_THUMB_PATH: Final = ("query", "pages", 0, "imageinfo", 0, "thumburl")
-# Commons hosts these four for the 267 portraits: 245 jpg, 15 png, 6 gif,
-# 1 jpeg. An unknown suffix is refused rather than guessed, because the
-# extension is what tells Signal how to render the attachment.
-_SUFFIXES: Final = frozenset({".jpg", ".jpeg", ".png", ".gif"})
-
def default_cache_dir() -> Path:
"""Return the portrait directory, preferring the one systemd hands us.
@@ -68,7 +62,7 @@ class PortraitCache:
data in a way an integer is not.
"""
suffix = Path(pope.portrait).suffix.lower()
- if suffix not in _SUFFIXES:
+ if suffix not in SUFFIXES:
msg = f"portrait for #{pope.ordinal} has an unusable suffix: {pope.portrait!r}"
raise ImageFetchError(msg)
return self._dir / f"{pope.ordinal:03d}{suffix}"
@@ -86,16 +80,8 @@ class PortraitCache:
image = await self._fetcher.fetch_bytes(
self._api_url,
- _THUMB_PATH,
- params={
- "action": "query",
- "format": "json",
- "formatversion": "2",
- "prop": "imageinfo",
- "iiprop": "url",
- "iiurlwidth": str(PORTRAIT_WIDTH),
- "titles": f"File:{pope.portrait}",
- },
+ THUMB_PATH,
+ params=imageinfo_params(THUMB_WIDTH, title=f"File:{pope.portrait}"),
)
_write(path, image)
return image
diff --git a/signal_bot/traps.json b/signal_bot/traps.json
new file mode 100644
index 0000000..5031189
--- /dev/null
+++ b/signal_bot/traps.json
@@ -0,0 +1,2223 @@
+{
+ "source": "trap_categories.json",
+ "traps": [
+ 83848,
+ 116723,
+ 226912,
+ 252209,
+ 329601,
+ 352710,
+ 396271,
+ 423041,
+ 474690,
+ 507446,
+ 538108,
+ 608011,
+ 711270,
+ 729362,
+ 745433,
+ 807979,
+ 814712,
+ 817931,
+ 817933,
+ 874459,
+ 896042,
+ 919331,
+ 955080,
+ 1196327,
+ 1267658,
+ 1497320,
+ 1506776,
+ 1551795,
+ 1570846,
+ 1575286,
+ 1618733,
+ 1718515,
+ 1731893,
+ 1731898,
+ 1740653,
+ 1827193,
+ 1868856,
+ 1871414,
+ 1925361,
+ 2008565,
+ 2008742,
+ 2014184,
+ 2015815,
+ 2187575,
+ 2336022,
+ 2416662,
+ 2439656,
+ 2439659,
+ 2470469,
+ 2513736,
+ 2534375,
+ 2581838,
+ 2648930,
+ 2690523,
+ 2773988,
+ 2785269,
+ 2818248,
+ 2842254,
+ 2871278,
+ 2871288,
+ 2897838,
+ 2898112,
+ 2995088,
+ 3015819,
+ 3103711,
+ 3148163,
+ 3194546,
+ 3212171,
+ 3282973,
+ 3358979,
+ 3421436,
+ 3445663,
+ 3464944,
+ 3503900,
+ 3553741,
+ 3660897,
+ 3745072,
+ 3745751,
+ 3801791,
+ 3804500,
+ 3821276,
+ 3854165,
+ 3899580,
+ 3940840,
+ 3983559,
+ 4116873,
+ 4153204,
+ 4196258,
+ 4220407,
+ 4235488,
+ 4245136,
+ 4337255,
+ 4337427,
+ 4403743,
+ 4403766,
+ 4505449,
+ 4533438,
+ 4539569,
+ 4558220,
+ 4565550,
+ 4565565,
+ 4565582,
+ 4565606,
+ 4621782,
+ 4647210,
+ 4647219,
+ 4711547,
+ 4767144,
+ 4807888,
+ 4862948,
+ 4905588,
+ 4976885,
+ 5342704,
+ 5368471,
+ 5392271,
+ 5588993,
+ 5616840,
+ 5787303,
+ 5802721,
+ 5803374,
+ 5874037,
+ 5990608,
+ 5990616,
+ 6142139,
+ 6151681,
+ 6172937,
+ 6172944,
+ 6173148,
+ 6192923,
+ 6192927,
+ 6192929,
+ 6192947,
+ 6192950,
+ 6192956,
+ 6192960,
+ 6192970,
+ 6195163,
+ 6195166,
+ 6481979,
+ 6482286,
+ 6601023,
+ 6637805,
+ 6748007,
+ 6755974,
+ 6777953,
+ 6808879,
+ 6808881,
+ 6808882,
+ 6808888,
+ 6808906,
+ 6852244,
+ 6944504,
+ 6977858,
+ 6980338,
+ 6980356,
+ 6980373,
+ 6980390,
+ 6980411,
+ 6980440,
+ 6980463,
+ 6984287,
+ 7007680,
+ 7035987,
+ 7076966,
+ 7112118,
+ 7112139,
+ 7116361,
+ 7122669,
+ 7193959,
+ 7232334,
+ 7385748,
+ 7437455,
+ 7470425,
+ 7609654,
+ 7631929,
+ 7631930,
+ 7631931,
+ 7631932,
+ 7631933,
+ 7642216,
+ 7642217,
+ 7642218,
+ 7751353,
+ 7768333,
+ 7828605,
+ 7828650,
+ 7837527,
+ 7837538,
+ 7837544,
+ 7885067,
+ 7939442,
+ 7939459,
+ 7939491,
+ 7944763,
+ 7944766,
+ 7944772,
+ 7944773,
+ 7944777,
+ 7967342,
+ 7974891,
+ 8027386,
+ 8027441,
+ 8039259,
+ 8039262,
+ 8039264,
+ 8089267,
+ 8089338,
+ 8215246,
+ 8215252,
+ 8321581,
+ 8361232,
+ 8525028,
+ 8537730,
+ 8537745,
+ 8538226,
+ 8538251,
+ 8538261,
+ 8538271,
+ 8538284,
+ 8538295,
+ 8538519,
+ 8538586,
+ 8552607,
+ 8577483,
+ 8577577,
+ 8582102,
+ 8582598,
+ 8590507,
+ 8591637,
+ 8596619,
+ 8596626,
+ 8599712,
+ 8600003,
+ 8600572,
+ 8600579,
+ 8601147,
+ 8601272,
+ 8601716,
+ 8602145,
+ 8603981,
+ 8605985,
+ 8607422,
+ 8608410,
+ 8609595,
+ 8610106,
+ 8610725,
+ 8611892,
+ 8612890,
+ 8613944,
+ 8653451,
+ 8669740,
+ 8678831,
+ 8753116,
+ 9109973,
+ 9109977,
+ 9114806,
+ 9123147,
+ 9151286,
+ 9203726,
+ 9215724,
+ 9229133,
+ 9236013,
+ 9237562,
+ 9280393,
+ 9282056,
+ 9285806,
+ 9318083,
+ 9325371,
+ 9345756,
+ 9354244,
+ 9375751,
+ 9376077,
+ 9379688,
+ 9455667,
+ 9484809,
+ 9553339,
+ 9562970,
+ 9580150,
+ 9644450,
+ 9669827,
+ 9708330,
+ 9757337,
+ 9761770,
+ 9761778,
+ 9761786,
+ 9841769,
+ 9841771,
+ 9845778,
+ 9846873,
+ 9873849,
+ 9979602,
+ 10087702,
+ 10144448,
+ 10190368,
+ 10190369,
+ 10190370,
+ 10229755,
+ 10236953,
+ 10241928,
+ 10263144,
+ 10298326,
+ 10384553,
+ 10405564,
+ 10417526,
+ 10417573,
+ 10432768,
+ 10440318,
+ 10500359,
+ 10668155,
+ 10707672,
+ 10744910,
+ 10895768,
+ 10947819,
+ 11006469,
+ 11006470,
+ 11020565,
+ 11020688,
+ 11020743,
+ 11020744,
+ 11026009,
+ 11026028,
+ 11030786,
+ 11032508,
+ 11032509,
+ 11032510,
+ 11050800,
+ 11050811,
+ 11189597,
+ 11189934,
+ 11189963,
+ 11189982,
+ 11190012,
+ 11207022,
+ 11230552,
+ 11323872,
+ 11380552,
+ 11385335,
+ 11430037,
+ 11444257,
+ 11444262,
+ 11463237,
+ 11465937,
+ 11466408,
+ 11517778,
+ 11522121,
+ 11548900,
+ 11590451,
+ 11771460,
+ 11828905,
+ 11843338,
+ 11844541,
+ 11871865,
+ 11872009,
+ 11872023,
+ 11872033,
+ 11872035,
+ 11872039,
+ 12037483,
+ 12042019,
+ 12121894,
+ 12125094,
+ 12167994,
+ 12281530,
+ 12285346,
+ 12299674,
+ 12469639,
+ 12495354,
+ 12507351,
+ 12522132,
+ 12522140,
+ 12522142,
+ 12522143,
+ 12522147,
+ 12522151,
+ 12523894,
+ 12545171,
+ 12585255,
+ 12602396,
+ 12805727,
+ 12811192,
+ 12811263,
+ 12860267,
+ 12882350,
+ 12891192,
+ 12903871,
+ 12949996,
+ 12987771,
+ 12996281,
+ 13035828,
+ 13055803,
+ 13103128,
+ 13105431,
+ 13118148,
+ 13202872,
+ 13210630,
+ 13365821,
+ 13367751,
+ 13378563,
+ 13379127,
+ 13379905,
+ 13381508,
+ 13386172,
+ 13394784,
+ 13394785,
+ 13421830,
+ 13434464,
+ 13437458,
+ 13443948,
+ 13462351,
+ 13471081,
+ 13474495,
+ 13474496,
+ 13489589,
+ 13491474,
+ 13510230,
+ 13526430,
+ 13526551,
+ 13526684,
+ 13555406,
+ 13643322,
+ 13724968,
+ 13755368,
+ 13764280,
+ 13766359,
+ 13789083,
+ 13789084,
+ 13789085,
+ 13799380,
+ 13857250,
+ 13913076,
+ 14041150,
+ 14053940,
+ 14213079,
+ 14218627,
+ 14239589,
+ 14246127,
+ 14446999,
+ 14561259,
+ 14571236,
+ 14573797,
+ 14834545,
+ 14999351,
+ 15007822,
+ 15025113,
+ 15244841,
+ 15244849,
+ 15253644,
+ 15271413,
+ 15658099,
+ 15915235,
+ 15915280,
+ 15915305,
+ 15915339,
+ 15915402,
+ 16028892,
+ 16192190,
+ 16192192,
+ 16358433,
+ 16387672,
+ 16388732,
+ 16402606,
+ 16402607,
+ 16402608,
+ 16402609,
+ 16463607,
+ 16735933,
+ 16893166,
+ 16911773,
+ 16932573,
+ 16939785,
+ 16960563,
+ 16960564,
+ 16960571,
+ 16987263,
+ 17087093,
+ 17087465,
+ 17087573,
+ 17101848,
+ 17112808,
+ 17131844,
+ 17150133,
+ 17219610,
+ 17346876,
+ 17376742,
+ 17393586,
+ 17393587,
+ 17424862,
+ 17430904,
+ 17463392,
+ 17505144,
+ 17584705,
+ 17686254,
+ 17700421,
+ 17700609,
+ 17700905,
+ 17703820,
+ 17718453,
+ 17724894,
+ 17724898,
+ 17801639,
+ 17830410,
+ 17916262,
+ 17916414,
+ 17969562,
+ 17978061,
+ 17978240,
+ 18015572,
+ 18015574,
+ 18096071,
+ 18190162,
+ 18195441,
+ 18195442,
+ 18195443,
+ 18195444,
+ 18195445,
+ 18195446,
+ 18384841,
+ 18526753,
+ 18589687,
+ 18620820,
+ 18731693,
+ 18731696,
+ 18731703,
+ 18731707,
+ 18779482,
+ 18782812,
+ 18798420,
+ 18894613,
+ 19318396,
+ 19318397,
+ 19402466,
+ 19564889,
+ 19564921,
+ 19688856,
+ 19856894,
+ 20054319,
+ 20305884,
+ 20351078,
+ 20356984,
+ 20359313,
+ 20359315,
+ 20359887,
+ 20392212,
+ 20392222,
+ 20392227,
+ 20768233,
+ 20906122,
+ 20906169,
+ 20994796,
+ 21040949,
+ 21040950,
+ 21040951,
+ 21040954,
+ 21262123,
+ 21392595,
+ 21447177,
+ 21571186,
+ 21600095,
+ 21600098,
+ 21600193,
+ 21687139,
+ 21727804,
+ 21902101,
+ 21931846,
+ 21931863,
+ 21932037,
+ 22131014,
+ 22142126,
+ 22474787,
+ 22599489,
+ 22606962,
+ 22606963,
+ 22654076,
+ 22655523,
+ 22724755,
+ 22734914,
+ 22737190,
+ 22819706,
+ 22819713,
+ 22819717,
+ 22819718,
+ 22819720,
+ 22848101,
+ 22848304,
+ 22909780,
+ 22909781,
+ 22946575,
+ 22955086,
+ 22969196,
+ 22969197,
+ 23320750,
+ 23320752,
+ 23341440,
+ 23486612,
+ 23695737,
+ 24018361,
+ 24177374,
+ 24252059,
+ 24358212,
+ 24359610,
+ 24377484,
+ 24436015,
+ 24436313,
+ 24490151,
+ 24612147,
+ 24642917,
+ 24642927,
+ 24646351,
+ 24646387,
+ 24667528,
+ 24788008,
+ 24870887,
+ 24870895,
+ 24885052,
+ 24885223,
+ 24885398,
+ 24913053,
+ 24913054,
+ 24917859,
+ 24924661,
+ 24924974,
+ 24925964,
+ 24926215,
+ 24936235,
+ 24939469,
+ 24971013,
+ 25381845,
+ 25398041,
+ 25513345,
+ 25514413,
+ 25524367,
+ 25740425,
+ 25740426,
+ 25805848,
+ 25823538,
+ 25823816,
+ 25824231,
+ 25835597,
+ 25923093,
+ 25992535,
+ 26012208,
+ 26012243,
+ 26063737,
+ 26195294,
+ 26223925,
+ 26244104,
+ 26259304,
+ 26283019,
+ 26302255,
+ 26306277,
+ 26308147,
+ 26325396,
+ 26325544,
+ 26330480,
+ 26375761,
+ 26385293,
+ 26667999,
+ 27013207,
+ 27033672,
+ 27133531,
+ 27201244,
+ 27201255,
+ 27236650,
+ 27374590,
+ 27420683,
+ 27501563,
+ 27504959,
+ 27597026,
+ 27673670,
+ 27703459,
+ 27733597,
+ 27749963,
+ 27749964,
+ 27750025,
+ 27812821,
+ 27833650,
+ 28064007,
+ 28064199,
+ 28069026,
+ 28069027,
+ 28069619,
+ 28337964,
+ 28438129,
+ 28767887,
+ 28908240,
+ 28923904,
+ 28923905,
+ 28923908,
+ 28973513,
+ 28979098,
+ 29147424,
+ 29160640,
+ 29235886,
+ 29362332,
+ 29450891,
+ 29450902,
+ 29461098,
+ 29578269,
+ 29616928,
+ 29684133,
+ 29692784,
+ 29692846,
+ 29738347,
+ 29751149,
+ 29790616,
+ 29812314,
+ 29812315,
+ 29812319,
+ 29812320,
+ 29812328,
+ 29812353,
+ 29813722,
+ 29813725,
+ 29813741,
+ 29813745,
+ 29826405,
+ 29826406,
+ 29826434,
+ 29830387,
+ 29830450,
+ 29934664,
+ 30038940,
+ 30079801,
+ 30198483,
+ 30441723,
+ 30473813,
+ 30886681,
+ 30900551,
+ 31071541,
+ 31174192,
+ 31176025,
+ 31314984,
+ 31314991,
+ 31315155,
+ 31315156,
+ 31315162,
+ 31315165,
+ 31315558,
+ 31316523,
+ 31316575,
+ 31316576,
+ 31316578,
+ 31398704,
+ 31428791,
+ 31428792,
+ 31472404,
+ 31484438,
+ 31500717,
+ 31525099,
+ 31525321,
+ 31525322,
+ 31525583,
+ 31525584,
+ 31525585,
+ 31529440,
+ 31531742,
+ 31551869,
+ 31755842,
+ 31865626,
+ 31993212,
+ 32080591,
+ 32081254,
+ 32361898,
+ 32361988,
+ 32368236,
+ 32548191,
+ 32823284,
+ 32966153,
+ 33347801,
+ 33400128,
+ 33430804,
+ 33435063,
+ 33450779,
+ 33458547,
+ 33655501,
+ 33655503,
+ 33720685,
+ 33755661,
+ 33932456,
+ 33949757,
+ 33986691,
+ 33986747,
+ 34136829,
+ 34171883,
+ 34219545,
+ 34343594,
+ 34352693,
+ 34366024,
+ 34417293,
+ 34502808,
+ 34759658,
+ 34770702,
+ 34770734,
+ 34922556,
+ 34923577,
+ 35276580,
+ 35276581,
+ 35457507,
+ 35472957,
+ 35475440,
+ 35496249,
+ 35839520,
+ 35871385,
+ 36084057,
+ 36194311,
+ 36203715,
+ 36208174,
+ 36209749,
+ 36384830,
+ 36503402,
+ 36503414,
+ 36503427,
+ 36512623,
+ 36528830,
+ 36528860,
+ 36529889,
+ 36529896,
+ 36529902,
+ 36531182,
+ 36537026,
+ 36537035,
+ 36543479,
+ 36543480,
+ 36543481,
+ 36543652,
+ 36543653,
+ 36543654,
+ 36543655,
+ 36543656,
+ 36543657,
+ 36543658,
+ 36543659,
+ 36543660,
+ 36543661,
+ 36544783,
+ 36545004,
+ 36545016,
+ 36545028,
+ 36546193,
+ 36550010,
+ 36557167,
+ 36575681,
+ 36576483,
+ 36576486,
+ 36576495,
+ 36576497,
+ 36667302,
+ 36786213,
+ 36837211,
+ 37048096,
+ 37048098,
+ 37048099,
+ 37048101,
+ 37048105,
+ 37048106,
+ 37048109,
+ 37048113,
+ 37115615,
+ 37134754,
+ 37218243,
+ 37271884,
+ 37272651,
+ 37289728,
+ 37291256,
+ 37326518,
+ 37341816,
+ 37346719,
+ 37347295,
+ 37367290,
+ 37457475,
+ 37628079,
+ 37660837,
+ 37736020,
+ 37736309,
+ 37760536,
+ 37808472,
+ 37819842,
+ 37867898,
+ 37900351,
+ 38058475,
+ 38264572,
+ 38475627,
+ 38475628,
+ 38501807,
+ 38532527,
+ 38637356,
+ 38646116,
+ 38646136,
+ 38653859,
+ 38654008,
+ 38654081,
+ 38654574,
+ 38654599,
+ 38654601,
+ 38654602,
+ 38654603,
+ 39143252,
+ 39267238,
+ 39360030,
+ 39360052,
+ 39383541,
+ 39383542,
+ 39383545,
+ 39994471,
+ 40411687,
+ 40411689,
+ 40512165,
+ 40593079,
+ 40593093,
+ 40720254,
+ 40720436,
+ 40725831,
+ 41321997,
+ 41552440,
+ 41600579,
+ 41601121,
+ 41617224,
+ 41618001,
+ 41726020,
+ 41820063,
+ 41828582,
+ 41850556,
+ 42023273,
+ 42165883,
+ 42248389,
+ 42284528,
+ 42330050,
+ 42330160,
+ 42330563,
+ 42330578,
+ 42330590,
+ 42330640,
+ 42331590,
+ 42331862,
+ 42362267,
+ 42378225,
+ 42378951,
+ 42380871,
+ 42381162,
+ 42381220,
+ 42382055,
+ 42382784,
+ 42383609,
+ 42383670,
+ 42383673,
+ 42383738,
+ 42384724,
+ 42386379,
+ 42416801,
+ 42422386,
+ 42422522,
+ 42435606,
+ 42435622,
+ 42435633,
+ 42435648,
+ 42435661,
+ 42435676,
+ 42435690,
+ 42435696,
+ 42435708,
+ 42435712,
+ 42435718,
+ 42435728,
+ 42435737,
+ 42435751,
+ 42435758,
+ 42435767,
+ 42435781,
+ 42435791,
+ 42435803,
+ 42435811,
+ 42435816,
+ 42435823,
+ 42435833,
+ 42435845,
+ 42435870,
+ 42435880,
+ 42435885,
+ 42435901,
+ 42435918,
+ 42435929,
+ 42435955,
+ 42435962,
+ 42435966,
+ 42435972,
+ 42436004,
+ 42436011,
+ 42436019,
+ 42436026,
+ 42437444,
+ 42437515,
+ 42445832,
+ 42446212,
+ 42446675,
+ 42446976,
+ 42447237,
+ 42450817,
+ 42451435,
+ 42451469,
+ 42476114,
+ 42478583,
+ 42494658,
+ 42516523,
+ 42555853,
+ 42555855,
+ 42555965,
+ 42566200,
+ 42566251,
+ 42566525,
+ 42566816,
+ 42566826,
+ 42567884,
+ 42569804,
+ 42569806,
+ 42569808,
+ 42569810,
+ 42569816,
+ 42569822,
+ 42569825,
+ 42569827,
+ 42570222,
+ 42570587,
+ 42570679,
+ 42573001,
+ 42573005,
+ 42573210,
+ 42573400,
+ 42604669,
+ 42615480,
+ 42638916,
+ 42664731,
+ 42684363,
+ 42708564,
+ 42708723,
+ 42736935,
+ 42736944,
+ 42736950,
+ 42736957,
+ 42736964,
+ 42736996,
+ 42737033,
+ 42737130,
+ 42737895,
+ 42738530,
+ 42782602,
+ 42792941,
+ 42792950,
+ 42792953,
+ 42792976,
+ 42797222,
+ 42839431,
+ 42839646,
+ 42840893,
+ 42840896,
+ 42840905,
+ 42840913,
+ 42840915,
+ 42841068,
+ 42841307,
+ 42844242,
+ 42849166,
+ 42864372,
+ 42865135,
+ 42865242,
+ 42865476,
+ 42865509,
+ 42865939,
+ 42866709,
+ 42871592,
+ 42887652,
+ 42887658,
+ 42887662,
+ 42887669,
+ 42887674,
+ 42889798,
+ 42906576,
+ 42998131,
+ 43025629,
+ 43132583,
+ 43214145,
+ 43249845,
+ 43325621,
+ 43339738,
+ 43339949,
+ 43340216,
+ 43343660,
+ 43353984,
+ 43363510,
+ 43364809,
+ 43366494,
+ 43366572,
+ 43366646,
+ 43366713,
+ 43368614,
+ 43368869,
+ 43369139,
+ 43371830,
+ 43371841,
+ 43390851,
+ 43390871,
+ 43391674,
+ 43392567,
+ 43395544,
+ 43420652,
+ 43422527,
+ 43425767,
+ 43453121,
+ 43501200,
+ 43575933,
+ 43819812,
+ 43820054,
+ 43820203,
+ 43848345,
+ 43854095,
+ 43993561,
+ 43998939,
+ 44001178,
+ 44001242,
+ 44001793,
+ 44001971,
+ 44002078,
+ 44062790,
+ 44090859,
+ 44094087,
+ 44164310,
+ 44164312,
+ 44231317,
+ 44243731,
+ 44244040,
+ 44244416,
+ 44244425,
+ 44244553,
+ 44244695,
+ 44244728,
+ 44245177,
+ 44245337,
+ 44266072,
+ 44266092,
+ 44335653,
+ 44347046,
+ 44347202,
+ 44347312,
+ 44347316,
+ 44347338,
+ 44347378,
+ 44347449,
+ 44348188,
+ 44399304,
+ 44640387,
+ 44640504,
+ 45145559,
+ 45186957,
+ 45249959,
+ 45263524,
+ 45263525,
+ 45263526,
+ 45263528,
+ 45263529,
+ 45263530,
+ 45263532,
+ 45263533,
+ 45391376,
+ 45682728,
+ 45682729,
+ 45682730,
+ 45812179,
+ 45952018,
+ 45952989,
+ 45952999,
+ 45953007,
+ 45953083,
+ 45953087,
+ 45953099,
+ 45953180,
+ 45953793,
+ 45954219,
+ 45954232,
+ 46038388,
+ 46133656,
+ 46294349,
+ 46501370,
+ 46501451,
+ 47027990,
+ 47117018,
+ 47153183,
+ 47346867,
+ 47370680,
+ 47957067,
+ 47957068,
+ 47957069,
+ 47985656,
+ 47985657,
+ 48018496,
+ 48018506,
+ 48237179,
+ 48248075,
+ 48370419,
+ 48583058,
+ 48594276,
+ 48594339,
+ 48719754,
+ 48834823,
+ 48834827,
+ 48865480,
+ 48942859,
+ 48962406,
+ 48964432,
+ 48964448,
+ 48964454,
+ 48989605,
+ 48992882,
+ 49096792,
+ 49097136,
+ 49098575,
+ 49157522,
+ 49283232,
+ 49577191,
+ 49614371,
+ 49615025,
+ 49639025,
+ 49698962,
+ 49698963,
+ 49698965,
+ 49698967,
+ 49721586,
+ 49735344,
+ 49780200,
+ 49786913,
+ 49786914,
+ 49787377,
+ 49787378,
+ 49789246,
+ 49856587,
+ 50402957,
+ 50469498,
+ 50469499,
+ 50546756,
+ 50546768,
+ 50546773,
+ 50546774,
+ 50546775,
+ 50546780,
+ 50635293,
+ 50655321,
+ 50680145,
+ 50680147,
+ 50680187,
+ 50680212,
+ 50770355,
+ 50772482,
+ 50772653,
+ 50772729,
+ 50776490,
+ 50834318,
+ 50883343,
+ 50900244,
+ 51093345,
+ 51144194,
+ 51144195,
+ 51149082,
+ 51404314,
+ 51418990,
+ 51673493,
+ 51774905,
+ 51833752,
+ 51916566,
+ 51981301,
+ 52101897,
+ 52140548,
+ 52191515,
+ 52194718,
+ 52554485,
+ 52554654,
+ 52554680,
+ 52615626,
+ 52637839,
+ 53016032,
+ 53148197,
+ 53471069,
+ 53560045,
+ 53832630,
+ 53992460,
+ 54022932,
+ 54025092,
+ 54047967,
+ 54105527,
+ 54170320,
+ 54170361,
+ 54292119,
+ 54409640,
+ 54513327,
+ 54513334,
+ 54513338,
+ 54573744,
+ 54581172,
+ 54581215,
+ 54967143,
+ 55074241,
+ 55098902,
+ 55099066,
+ 55099082,
+ 55102759,
+ 55118869,
+ 55118902,
+ 55439405,
+ 55516376,
+ 55516377,
+ 55516378,
+ 55516380,
+ 55602605,
+ 56141157,
+ 56141174,
+ 56476622,
+ 56476637,
+ 56608152,
+ 56685834,
+ 56878878,
+ 56929501,
+ 57199917,
+ 57205491,
+ 57205492,
+ 57361354,
+ 57547634,
+ 57606092,
+ 57762322,
+ 58117974,
+ 58231215,
+ 58546658,
+ 59050068,
+ 59338541,
+ 59421844,
+ 59467726,
+ 59582602,
+ 60618894,
+ 60791369,
+ 60834312,
+ 60834349,
+ 60834876,
+ 60836138,
+ 60836566,
+ 60979270,
+ 61495652,
+ 61532135,
+ 61573203,
+ 61573288,
+ 61573391,
+ 61574296,
+ 61615939,
+ 61736975,
+ 61740434,
+ 61750523,
+ 61877539,
+ 61937453,
+ 62010889,
+ 62010890,
+ 62211424,
+ 62415446,
+ 62653258,
+ 62653264,
+ 62694862,
+ 62694863,
+ 62694866,
+ 62694868,
+ 62694869,
+ 62694870,
+ 62694874,
+ 62694876,
+ 62694881,
+ 62709167,
+ 62811056,
+ 62811057,
+ 62826238,
+ 62830135,
+ 62830142,
+ 62830180,
+ 62861828,
+ 62955033,
+ 62955396,
+ 62955701,
+ 62955772,
+ 62955950,
+ 62956005,
+ 62956166,
+ 62956550,
+ 62956754,
+ 62956773,
+ 62956843,
+ 62956872,
+ 62957037,
+ 62957528,
+ 62957711,
+ 62957785,
+ 62958873,
+ 62958903,
+ 62959411,
+ 62959741,
+ 63538833,
+ 63672968,
+ 63716127,
+ 63716129,
+ 63716130,
+ 63764319,
+ 63810754,
+ 63923546,
+ 64013573,
+ 64082317,
+ 64403832,
+ 64490255,
+ 64606438,
+ 64641477,
+ 64779196,
+ 64779197,
+ 64813308,
+ 64825997,
+ 64826005,
+ 64984136,
+ 65249210,
+ 65685894,
+ 65685902,
+ 65789183,
+ 66120675,
+ 66123954,
+ 66214386,
+ 66314399,
+ 66367046,
+ 66367047,
+ 66367053,
+ 66367055,
+ 66411272,
+ 66451459,
+ 66478508,
+ 66644486,
+ 66644496,
+ 66644810,
+ 66644823,
+ 66910065,
+ 66910073,
+ 66910084,
+ 66910085,
+ 66910086,
+ 66910087,
+ 66910090,
+ 66910092,
+ 66910125,
+ 66910132,
+ 66910134,
+ 66916447,
+ 66918876,
+ 66919864,
+ 66920019,
+ 66920025,
+ 66989812,
+ 66989867,
+ 66993455,
+ 66993464,
+ 66994096,
+ 66994122,
+ 67030209,
+ 67056251,
+ 67056254,
+ 67175530,
+ 67241454,
+ 67241455,
+ 67241456,
+ 67241459,
+ 67241460,
+ 67241461,
+ 67400675,
+ 67508221,
+ 67508243,
+ 67508270,
+ 67808008,
+ 67808065,
+ 67907358,
+ 68036016,
+ 68036017,
+ 68341257,
+ 68341278,
+ 68670267,
+ 68684953,
+ 68774602,
+ 68894104,
+ 68905904,
+ 68987780,
+ 68987783,
+ 68987784,
+ 68989887,
+ 68990601,
+ 69195582,
+ 69195584,
+ 69204663,
+ 69457832,
+ 69623568,
+ 70053790,
+ 70310770,
+ 70330475,
+ 70330476,
+ 70483773,
+ 70537884,
+ 70537893,
+ 70537899,
+ 70542349,
+ 70638013,
+ 70943454,
+ 70943455,
+ 70997979,
+ 71166697,
+ 71211404,
+ 71550490,
+ 71771324,
+ 72064954,
+ 72542566,
+ 72574632,
+ 72660375,
+ 72703925,
+ 72703931,
+ 72703937,
+ 72711734,
+ 72727639,
+ 72857601,
+ 72996315,
+ 73147810,
+ 73171958,
+ 73543558,
+ 73543560,
+ 73543561,
+ 73543562,
+ 73560621,
+ 73563677,
+ 73563679,
+ 73565026,
+ 73565384,
+ 73627891,
+ 73627892,
+ 73627902,
+ 73695012,
+ 73695196,
+ 74011168,
+ 74019568,
+ 74248545,
+ 74248574,
+ 74248693,
+ 74248727,
+ 74341856,
+ 74455109,
+ 74552190,
+ 74621757,
+ 74747529,
+ 74824512,
+ 75081198,
+ 75428479,
+ 75479679,
+ 75666529,
+ 76465543,
+ 76585941,
+ 76703793,
+ 76711526,
+ 76920658,
+ 76951750,
+ 76951751,
+ 76951752,
+ 77185175,
+ 77192576,
+ 77244236,
+ 77265807,
+ 77288307,
+ 77489738,
+ 77507503,
+ 77513074,
+ 77726747,
+ 77726748,
+ 77726749,
+ 77782074,
+ 77885772,
+ 78106104,
+ 78106204,
+ 78155255,
+ 78810811,
+ 78929852,
+ 78930756,
+ 79112440,
+ 79163392,
+ 79791134,
+ 79791136,
+ 79791138,
+ 79791139,
+ 79791140,
+ 79791142,
+ 80070860,
+ 80128901,
+ 80149859,
+ 80206287,
+ 80207233,
+ 80216685,
+ 80294980,
+ 80347307,
+ 80380792,
+ 80542253,
+ 80547281,
+ 80669480,
+ 80890834,
+ 80957063,
+ 81071882,
+ 81094324,
+ 81171410,
+ 81356079,
+ 81687671,
+ 81741527,
+ 81807113,
+ 81807114,
+ 81807116,
+ 81807735,
+ 81807736,
+ 81807737,
+ 81807740,
+ 81808024,
+ 82036539,
+ 82139893,
+ 82491947,
+ 82633682,
+ 82654224,
+ 82932078,
+ 82932080,
+ 82943596,
+ 83081643,
+ 83081647,
+ 83081650,
+ 83121749,
+ 83121750,
+ 83121752,
+ 83121753,
+ 83229417,
+ 83327057,
+ 83365994,
+ 83365996,
+ 83366933,
+ 83366934,
+ 83366939,
+ 83367515,
+ 83367516,
+ 83367518,
+ 83367522,
+ 83367524,
+ 83367525,
+ 83367526,
+ 83368187,
+ 83368189,
+ 83368190,
+ 83368193,
+ 83528931,
+ 83722112,
+ 84073106,
+ 84073110,
+ 84209211,
+ 84595440,
+ 84818771,
+ 84832506,
+ 84837974,
+ 84920032,
+ 84920033,
+ 85042186,
+ 85084057,
+ 85416846,
+ 85438387,
+ 85515957,
+ 85768588,
+ 86192832,
+ 86256525,
+ 86256528,
+ 86607858,
+ 86609693,
+ 86704528,
+ 86705444,
+ 86705799,
+ 86706036,
+ 86734031,
+ 86734037,
+ 86734709,
+ 86743299,
+ 86743994,
+ 86744053,
+ 86887034,
+ 86888918,
+ 86889194,
+ 86889388,
+ 86907141,
+ 86907852,
+ 86908012,
+ 86909543,
+ 87106046,
+ 87672841,
+ 87915023,
+ 87996699,
+ 87996704,
+ 88257667,
+ 88876357,
+ 88876358,
+ 88884667,
+ 89020036,
+ 89287807,
+ 89322634,
+ 89600993,
+ 90020510,
+ 90452059,
+ 90452067,
+ 90866782,
+ 90955700,
+ 91011237,
+ 91021970,
+ 91455467,
+ 91916566,
+ 91916614,
+ 92039339,
+ 92106352,
+ 92229949,
+ 92229950,
+ 92229955,
+ 92230305,
+ 92516036,
+ 92959915,
+ 92980446,
+ 93094009,
+ 93363242,
+ 93511488,
+ 93543664,
+ 93754413,
+ 93755794,
+ 93755845,
+ 93755849,
+ 93755851,
+ 93755876,
+ 93755946,
+ 93756070,
+ 93756085,
+ 93756155,
+ 93756386,
+ 93756408,
+ 93756458,
+ 93756467,
+ 93756469,
+ 93756475,
+ 93756491,
+ 93756532,
+ 93756547,
+ 93756610,
+ 93900159,
+ 93902043,
+ 94212904,
+ 94622560,
+ 94895673,
+ 95056761,
+ 95208926,
+ 95466872,
+ 95493583,
+ 95509294,
+ 95931284,
+ 95932422,
+ 95933458,
+ 96195193,
+ 96295584,
+ 96748213,
+ 97013754,
+ 97064444,
+ 97136543,
+ 97137896,
+ 97380442,
+ 97482123,
+ 97817536,
+ 98011522,
+ 98139994,
+ 98873743,
+ 98873745,
+ 98873748,
+ 98879960,
+ 99642045,
+ 99642074,
+ 99726149,
+ 99894412,
+ 100327877,
+ 100332834,
+ 100531978,
+ 102165992,
+ 103508218,
+ 103539449,
+ 103586076,
+ 104232694,
+ 104272695,
+ 104311324,
+ 104311413,
+ 105643147,
+ 105643148,
+ 105643149,
+ 105643153,
+ 105643154,
+ 105643155,
+ 105643156,
+ 105643157,
+ 105643158,
+ 105686311,
+ 105960179,
+ 106230760,
+ 106261604,
+ 106391917,
+ 106404286,
+ 106467469,
+ 106583135,
+ 106926374,
+ 107027615,
+ 107090315,
+ 107090316,
+ 107184557,
+ 107343755,
+ 107740278,
+ 107894820,
+ 107894821,
+ 108081122,
+ 108113888,
+ 108214445,
+ 108306024,
+ 108778466,
+ 108913571,
+ 109159732,
+ 109434269,
+ 109555373,
+ 110057904,
+ 110359666,
+ 110360192,
+ 110360204,
+ 110360472,
+ 110360478,
+ 110360483,
+ 110673375,
+ 110914692,
+ 111257836,
+ 111344495,
+ 111419226,
+ 112453773,
+ 112949590,
+ 113122418,
+ 113373544,
+ 113600805,
+ 114378576,
+ 114480829,
+ 114505350,
+ 114616471,
+ 114840432,
+ 115569487,
+ 115705762,
+ 115762176,
+ 115762286,
+ 115762492,
+ 115792767,
+ 115933381,
+ 116612196,
+ 117217851,
+ 117217888,
+ 117277094,
+ 117717799,
+ 117950064,
+ 118040092,
+ 118336329,
+ 118603175,
+ 119042963,
+ 119459733,
+ 120021781,
+ 120062215,
+ 120115380,
+ 120132863,
+ 120133112,
+ 120340107,
+ 120713774,
+ 121130232,
+ 121441651,
+ 121449881,
+ 121601911,
+ 121608235,
+ 121608236,
+ 122045014,
+ 122045019,
+ 122394153,
+ 122892708,
+ 122898810,
+ 123618104,
+ 124481488,
+ 124481655,
+ 125164513,
+ 126590711,
+ 126590712,
+ 126590713,
+ 126590715,
+ 126590719,
+ 126593630,
+ 126802978,
+ 127631689,
+ 128168217,
+ 129121996,
+ 129121997,
+ 129121998,
+ 129121999,
+ 129122000,
+ 129122042,
+ 129580202,
+ 129580313,
+ 129580323,
+ 129580326,
+ 129580327,
+ 129580522,
+ 129580525,
+ 129580527,
+ 129580528,
+ 129580529,
+ 129580530,
+ 129580532,
+ 129580533,
+ 129580535,
+ 129580536,
+ 129580537,
+ 129580538,
+ 129589469,
+ 129589475,
+ 129589737,
+ 129589742,
+ 129606076,
+ 129606082,
+ 129619172,
+ 129678496,
+ 129678533,
+ 129678534,
+ 129678535,
+ 129678537,
+ 129678538,
+ 129678702,
+ 129678704,
+ 129678705,
+ 129678708,
+ 129678710,
+ 129678711,
+ 129678712,
+ 129678713,
+ 129678714,
+ 129678717,
+ 129678718,
+ 129678727,
+ 129678787,
+ 129678811,
+ 130487701,
+ 130569516,
+ 131323125,
+ 131324437,
+ 131324491,
+ 131661059,
+ 131966204,
+ 132364119,
+ 132364120,
+ 132364121,
+ 132364128,
+ 132364129,
+ 132364133,
+ 132364138,
+ 132364139,
+ 132364140,
+ 132364147,
+ 132364149,
+ 132364150,
+ 132364155,
+ 132364156,
+ 132364160,
+ 132364163,
+ 132364164,
+ 132364165,
+ 132364166,
+ 132364167,
+ 132364175,
+ 132364178,
+ 132364180,
+ 132364186,
+ 132569545,
+ 132569550,
+ 132569553,
+ 132569558,
+ 132569561,
+ 132569565,
+ 132569566,
+ 132569568,
+ 132569576,
+ 132569577,
+ 132754858,
+ 132789003,
+ 132942800,
+ 132955842,
+ 133030008,
+ 133071907,
+ 133209375,
+ 133698412,
+ 133755469,
+ 135979707,
+ 136064739,
+ 136065178,
+ 136065316,
+ 136066378,
+ 136137676,
+ 136137883,
+ 136138049,
+ 136138367,
+ 136152775,
+ 136398561,
+ 138173669,
+ 138173671,
+ 138173672,
+ 138173674,
+ 138173675,
+ 138173676,
+ 138173678,
+ 138173682,
+ 138173683,
+ 138173689,
+ 138173690,
+ 138173691,
+ 138173694,
+ 138173696,
+ 138323925,
+ 138323926,
+ 138323927,
+ 138323933,
+ 138323934,
+ 138323935,
+ 138323938,
+ 138323939,
+ 138323940,
+ 138414185,
+ 138514437,
+ 139367221,
+ 139439904,
+ 139440100,
+ 139649096,
+ 140205060,
+ 140855587,
+ 141078337,
+ 141424866,
+ 141745475,
+ 141745477,
+ 141792600,
+ 142016669,
+ 142286448,
+ 142650376,
+ 143654357,
+ 143854501,
+ 143871648,
+ 143871652,
+ 143871656,
+ 143871668,
+ 144151271,
+ 144211162,
+ 145196909,
+ 145372044,
+ 145867539,
+ 145867541,
+ 146799019,
+ 146799020,
+ 146799021,
+ 146903579,
+ 147027222,
+ 147262278,
+ 147262279,
+ 147262281,
+ 147406797,
+ 147465250,
+ 147588903,
+ 147588904,
+ 148053853,
+ 148583033,
+ 148717572,
+ 148738730,
+ 149021114,
+ 149257776,
+ 149298329,
+ 149298409,
+ 149411194,
+ 149852168,
+ 149948248,
+ 150174440,
+ 150625487,
+ 150743007,
+ 151224315,
+ 151918804,
+ 152785735,
+ 153023481,
+ 153105129,
+ 154123837,
+ 154505994,
+ 154801903,
+ 155255926,
+ 155684847,
+ 156942951,
+ 156942959,
+ 156955644,
+ 156955944,
+ 157848022,
+ 157848023,
+ 157848786,
+ 157848787,
+ 157848789,
+ 157848792,
+ 157848793,
+ 158140879,
+ 159099740,
+ 160221206,
+ 160394838,
+ 160916408,
+ 161046998,
+ 161962965,
+ 162225144,
+ 162470304,
+ 162571850,
+ 162584208,
+ 162586759,
+ 162689080,
+ 162848170,
+ 162970689,
+ 162970714,
+ 163007381,
+ 163007388,
+ 164088551,
+ 164454068,
+ 164647386,
+ 165466198,
+ 166831526,
+ 166831534,
+ 167672274,
+ 169651383,
+ 169651627,
+ 169681240,
+ 169681268,
+ 169718594,
+ 170186331,
+ 170187011,
+ 170269640,
+ 170861649,
+ 171032747,
+ 171329631,
+ 171394372,
+ 171394375,
+ 171923181,
+ 171923182,
+ 173198944,
+ 173389400,
+ 174418452,
+ 174631701,
+ 176935629,
+ 177406683,
+ 177406684,
+ 177492410,
+ 177521740,
+ 177663991,
+ 178280024,
+ 178280025,
+ 178280026,
+ 178575728,
+ 178575729,
+ 178640931,
+ 178641013,
+ 178732137,
+ 178864946,
+ 178899614,
+ 179522491,
+ 179537821,
+ 179987676,
+ 180037170,
+ 180702599,
+ 181093822,
+ 181406823,
+ 182275478,
+ 182275479,
+ 182873103,
+ 182936956,
+ 183013815,
+ 183244417,
+ 183754396,
+ 183890568,
+ 183980732,
+ 184014579,
+ 184445372,
+ 184511099,
+ 185236103,
+ 185236144,
+ 188017261,
+ 188358750,
+ 188358894,
+ 189429830,
+ 191901488,
+ 192288828,
+ 192288829,
+ 192288830,
+ 192288833,
+ 192288835,
+ 192288842,
+ 192288844,
+ 192288847,
+ 192921815,
+ 193208576,
+ 193366451,
+ 193598502,
+ 193598504,
+ 195160331,
+ 195160336,
+ 195160349,
+ 195726454,
+ 195726455,
+ 195726456,
+ 196169291,
+ 196169292,
+ 197775780,
+ 197827548,
+ 198060253,
+ 198060254,
+ 198096374,
+ 198242565,
+ 198242567
+ ]
+}
diff --git a/signal_bot/traps_command.py b/signal_bot/traps_command.py
new file mode 100644
index 0000000..96dafd1
--- /dev/null
+++ b/signal_bot/traps_command.py
@@ -0,0 +1,116 @@
+# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
+"""The !pułapka command: a photograph of the device, and what it is.
+
+The joke is literalism -- whatever the word meant to whoever typed it, what
+comes back is a bear trap, a mousetrap, a snare, a cage, a fish weir. The
+caption is not an apology for the joke, it is the answer to the next
+question: Commons has plenty of files whose subject is genuinely unguessable
+("Molchreuse", "K Pañayacu 0811"), and a newt trap nobody can identify is
+just a photograph of a basket.
+
+Nothing is cached. !papież caches because 267 portraits are a closed set a
+group works through; a uniform draw from a couple of thousand traps warms a
+cache essentially never, so the disk would fill with images nobody asks for
+twice. This works the way !kot and !pies do instead: fetch, post, forget.
+"""
+
+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.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 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 = "trap"
+DESCRIPTION: Final = "losowe zdjęcie pułapki"
+TRAPS_FILE: Final = Path(__file__).with_name("traps.json")
+
+
+def load_traps(path: Path = TRAPS_FILE) -> tuple[int, ...]:
+ """Read the trap pool.
+
+ The file holds Commons page ids -- bare integers -- and that is checked
+ here rather than assumed. Filenames would be free text carrying
+ photographers' and sitters' names, and a name committed here collides
+ sooner or later with a label somebody minted from the chat, at which
+ point scripts/check_personal_data.py refuses every commit for good over
+ data nobody can edit by hand. An integer cannot do that, and this is the
+ guard that keeps it an integer.
+
+ Args:
+ path: Where to read the pool from. Defaults to the shipped file.
+
+ Returns:
+ Every Commons page id in the pool.
+
+ Raises:
+ ValueError: If the pool is empty or holds anything but integers.
+ """
+ document = json.loads(path.read_text(encoding="utf-8"))
+ traps = document["traps"]
+ if not traps or not all(isinstance(page, int) for page in traps):
+ msg = f"{path} must hold a non-empty list of Commons page ids"
+ raise ValueError(msg)
+ return tuple(traps)
+
+
+def random_trap(traps: Sequence[int]) -> int:
+ """Return one page id, uniformly at random.
+
+ 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.
+ """
+ return traps[secrets.randbelow(len(traps))]
+
+
+def trap_handler(
+ fetcher: ImageFetcher,
+ api_url: str,
+ traps: Sequence[int] | None = None,
+) -> Handler:
+ """Build the handler that posts one random trap."""
+ pool = load_traps() if traps is None else tuple(traps)
+
+ 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.
+ document = await fetcher.fetch_json(
+ api_url,
+ imageinfo_params(THUMB_WIDTH, page_id=random_trap(pool), 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_trap_command(
+ registry: CommandRegistry,
+ fetcher: ImageFetcher,
+ api_url: str,
+ *,
+ decorate: Decorator | None = None,
+ aliases: dict[str, list[str]] | None = None,
+ traps: Sequence[int] | None = None,
+) -> None:
+ """Register !pułapka under every trigger aliases.json gives it."""
+ table = load_aliases() if aliases is None else aliases
+ handler = trap_handler(fetcher, api_url, traps)
+ if decorate is not None:
+ handler = decorate(handler)
+ registry.register(COMMAND_NAME, table[COMMAND_NAME], handler, DESCRIPTION)
diff --git a/tests/test_commons.py b/tests/test_commons.py
new file mode 100644
index 0000000..a1f25ba
--- /dev/null
+++ b/tests/test_commons.py
@@ -0,0 +1,123 @@
+# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
+"""The imageinfo query both Commons commands send."""
+
+from __future__ import annotations
+
+import pytest
+
+from signal_bot.commons import (
+ CAPTION_LIMIT,
+ SUFFIXES,
+ THUMB_PATH,
+ caption_for,
+ imageinfo_params,
+ page_of,
+)
+from signal_bot.http_client import ImageFetchError
+
+
+def test_a_file_can_be_named_by_title() -> None:
+ params = imageinfo_params(640, title="File:1868 Mausefalle.jpg")
+ assert params["titles"] == "File:1868 Mausefalle.jpg"
+ assert "pageids" not in params
+ assert params["iiurlwidth"] == "640"
+ assert params["prop"] == "imageinfo"
+
+
+def test_a_file_can_be_named_by_page_id() -> None:
+ # The page id arrives as an int and has to reach Commons as a string;
+ # sending the int would raise inside httpx rather than here.
+ params = imageinfo_params(480, page_id=111419226)
+ assert params["pageids"] == "111419226"
+ assert "titles" not in params
+
+
+@pytest.mark.parametrize(
+ ("title", "page_id"),
+ [(None, None), ("File:x.jpg", 1)],
+)
+def test_naming_a_file_neither_way_or_both_ways_is_refused(
+ title: str | None, page_id: int | None
+) -> None:
+ # Preferring one silently would make a caller's mistake look like a 404,
+ # which is the one failure shape that already exists downstream.
+ with pytest.raises(ValueError, match="exactly one"):
+ imageinfo_params(640, title=title, page_id=page_id)
+
+
+def test_the_thumbnail_path_is_where_commons_actually_puts_it() -> None:
+ # Pinned because both callers walk it and neither can see it fail until
+ # a real fetch returns something unwalkable.
+ assert THUMB_PATH == ("query", "pages", 0, "imageinfo", 0, "thumburl")
+
+
+def test_the_suffix_allowlist_is_what_signal_can_render() -> None:
+ assert ".jpg" in SUFFIXES
+ assert ".svg" not in SUFFIXES
+
+
+def _page(
+ description: str | None = None, *, title: str = "File:Molchreuse.JPG", url: str = ""
+) -> dict[str, object]:
+ """One imageinfo page, shaped the way Commons shapes it."""
+ info: dict[str, object] = {"thumburl": "https://cdn/x.jpg"}
+ if url:
+ info["descriptionurl"] = url
+ if description is not None:
+ info["extmetadata"] = {"ImageDescription": {"value": description}}
+ return {"title": title, "imageinfo": [info]}
+
+
+def test_asking_for_a_description_widens_the_query() -> None:
+ plain = imageinfo_params(640, page_id=1)
+ described = imageinfo_params(640, page_id=1, describe=True)
+ assert plain["iiprop"] == "url"
+ assert "iiextmetadatafilter" not in plain
+ assert described["iiprop"] == "url|extmetadata"
+ assert described["iiextmetadatafilter"] == "ImageDescription"
+
+
+def test_the_caption_strips_markup_and_links_the_page() -> None:
+ page = _page("Eine Molchreuse & ein Korb", url="https://commons/File:M.JPG")
+ assert caption_for(page) == "Eine Molchreuse & ein Korb\nhttps://commons/File:M.JPG"
+
+
+def test_the_catalogue_list_is_cut_and_the_sentence_kept() -> None:
+ # The Fisheries records append their filing fields as an HTML list.
+ page = _page("Round Fyke (Bollreuse). Prussia\n")
+ assert caption_for(page) == "Round Fyke (Bollreuse). Prussia"
+
+
+@pytest.mark.parametrize("description", [None, "", ""])
+def test_a_description_that_says_nothing_falls_back_to_the_title(description: str | None) -> None:
+ assert caption_for(_page(description)) == "Molchreuse"
+
+
+def test_a_long_description_is_cut_rather_than_burying_the_photo() -> None:
+ page = _page("word " * 200)
+ caption = caption_for(page)
+ assert caption.endswith("…")
+ # rstrip() before the ellipsis means the exact length depends on where
+ # the cut landed; the ceiling is what matters.
+ assert len(caption) <= CAPTION_LIMIT + 1
+
+
+def test_a_page_commons_could_not_find_is_an_image_fetch_error() -> None:
+ # A deleted or renumbered page id comes back as {"missing": true}.
+ for document in ({"query": {"pages": [{"missing": True}]}}, {"query": {"pages": []}}, {}, []):
+ with pytest.raises(ImageFetchError, match="named no image"):
+ page_of(document)
+
+
+def test_the_page_comes_back_when_commons_did_find_it() -> None:
+ assert page_of({"query": {"pages": [_page("x")]}})["title"] == "File:Molchreuse.JPG"
+
+
+def test_a_template_that_is_not_a_description_falls_back_to_the_title() -> None:
+ # Measured on a Maltese fish trap: the filename said more than Commons'
+ # own description field did.
+ page = _page(
+ "This photo has been taken in the country: Malta",
+ title="File:Hand woven fish trap.jpg",
+ )
+ assert caption_for(page) == "Hand woven fish trap"
diff --git a/tests/test_help_command.py b/tests/test_help_command.py
index 83629c3..d8d4c9d 100644
--- a/tests/test_help_command.py
+++ b/tests/test_help_command.py
@@ -33,6 +33,7 @@ from signal_bot.portraits import PortraitCache
from signal_bot.registry import CommandRegistry
from signal_bot.stats.commands import register_stats_commands
from signal_bot.suggest_command import register_suggest_command
+from signal_bot.traps_command import register_trap_command
from tests import pack_fixture
from tests.conftest import SECOND_GROUP, WATCHED_GROUP, envelope
@@ -64,6 +65,7 @@ def full_registry() -> CommandRegistry:
fetcher = _offline_fetcher()
register_animal_commands(registry, fetcher)
register_pope_command(registry, PortraitCache(fetcher, DEFAULT_COMMONS_API_URL))
+ register_trap_command(registry, fetcher, DEFAULT_COMMONS_API_URL, traps=(1,))
register_stats_commands(registry)
register_suggest_command(registry)
register_help_command(registry)
diff --git a/tests/test_traps_command.py b/tests/test_traps_command.py
new file mode 100644
index 0000000..c87e6b1
--- /dev/null
+++ b/tests/test_traps_command.py
@@ -0,0 +1,191 @@
+# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
+"""The !pułapka command: a photograph of the device, with nothing attached."""
+
+from __future__ import annotations
+
+import base64
+import json
+from typing import TYPE_CHECKING
+
+import httpx
+import pytest
+
+from signal_bot.context import CommandContext
+from signal_bot.decorators import DISPLAY_MARGIN_SECONDS, cooldown
+from signal_bot.http_client import ImageFetcher
+from signal_bot.models import parse_envelope
+from signal_bot.registry import CommandRefusedError, CommandRegistry
+from signal_bot.traps_command import (
+ COMMAND_NAME,
+ TRAPS_FILE,
+ load_traps,
+ random_trap,
+ register_trap_command,
+)
+from tests.conftest import WATCHED_GROUP, envelope
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from signal_bot.config import Config
+ from signal_bot.store import Store
+ from signal_bot.transport_fake import FakeTransport
+
+IMAGE = b"trap-bytes"
+API = "https://commons.example/w/api.php"
+ALIASES = {COMMAND_NAME: ["!pułapka", "!pulapka", "!trap"]}
+POOL = (111419226, 124481655, 151918804)
+
+
+def _fetcher(seen: list[httpx.URL] | None = None) -> ImageFetcher:
+ def handler(request: httpx.Request) -> httpx.Response:
+ if "commons" in str(request.url):
+ if seen is not None:
+ seen.append(request.url)
+ page = {
+ "title": "File:Molchreuse.JPG",
+ "imageinfo": [
+ {
+ "thumburl": "https://cdn/trap.jpg",
+ "descriptionurl": "https://commons.wikimedia.org/wiki/File:Molchreuse.JPG",
+ "extmetadata": {
+ "ImageDescription": {"value": "Eine Molchreuse"},
+ },
+ }
+ ],
+ }
+ return httpx.Response(200, json={"query": {"pages": [page]}})
+ return httpx.Response(200, content=IMAGE)
+
+ return ImageFetcher(httpx.AsyncClient(transport=httpx.MockTransport(handler)))
+
+
+def _ctx(transport: FakeTransport, store: Store, config: Config, text: str) -> CommandContext:
+ message = parse_envelope(envelope(text=text))
+ assert message is not None
+ return CommandContext(
+ message=message,
+ group_id=WATCHED_GROUP,
+ transport=transport,
+ store=store,
+ config=config,
+ command_name=COMMAND_NAME,
+ now=100.0,
+ day="2026-08-31",
+ )
+
+
+@pytest.mark.parametrize("trigger", ["!pułapka", "!pulapka", "!trap"])
+def test_every_alias_resolves(trigger: str) -> None:
+ registry = CommandRegistry()
+ register_trap_command(registry, _fetcher(), API, aliases=ALIASES, traps=POOL)
+
+ command = registry.resolve(trigger)
+ assert command is not None
+ assert command.name == COMMAND_NAME
+
+
+def test_the_real_alias_file_carries_the_command() -> None:
+ registry = CommandRegistry()
+ register_trap_command(registry, _fetcher(), API, traps=POOL)
+ # The Polish spelling is the primary one !pomoc prints; the English
+ # !trap is the word that prompted the command in the first place.
+ assert registry.resolve("!pułapka") is not None
+ assert registry.resolve("!trap") is not None
+
+
+async def test_it_posts_an_image_captioned_with_what_the_thing_is(
+ transport: FakeTransport, store: Store, config: Config
+) -> None:
+ registry = CommandRegistry()
+ register_trap_command(registry, _fetcher(), API, aliases=ALIASES, traps=POOL)
+ command = registry.resolve("!trap")
+ assert command is not None
+
+ await command.handler(_ctx(transport, store, config, "!trap"))
+
+ sent = transport.sent[0]
+ assert sent.recipient == WATCHED_GROUP
+ assert sent.image_base64 is not None
+ assert base64.b64decode(sent.image_base64) == IMAGE
+ assert len(transport.sent) == 1
+ # Commons is full of files whose subject is unguessable, so the caption
+ # says what it is and links the page that says more. Tags come out.
+ assert sent.caption == (
+ "Eine Molchreuse\nhttps://commons.wikimedia.org/wiki/File:Molchreuse.JPG"
+ )
+
+
+async def test_it_names_the_file_by_page_id_and_never_by_title(
+ transport: FakeTransport, store: Store, config: Config
+) -> None:
+ # A title would be free text carrying people's names into the repository;
+ # see load_traps. This is the assertion that keeps the query on page ids.
+ seen: list[httpx.URL] = []
+ registry = CommandRegistry()
+ register_trap_command(registry, _fetcher(seen), API, aliases=ALIASES, traps=POOL)
+ command = registry.resolve("!trap")
+ assert command is not None
+
+ await command.handler(_ctx(transport, store, config, "!trap"))
+
+ assert len(seen) == 1
+ assert seen[0].params["pageids"] in {str(page) for page in POOL}
+ assert "titles" not in seen[0].params
+
+
+async def test_the_cooldown_decorator_applies(
+ transport: FakeTransport, store: Store, config: Config
+) -> None:
+ registry = CommandRegistry()
+ register_trap_command(
+ registry,
+ _fetcher(),
+ API,
+ decorate=cooldown(10, "WAIT {seconds}"),
+ aliases=ALIASES,
+ traps=POOL,
+ )
+ store.record_use(WATCHED_GROUP, "uuid-alice", COMMAND_NAME, 100.0, "2026-08-31")
+ command = registry.resolve("!trap")
+ assert command is not None
+
+ with pytest.raises(CommandRefusedError):
+ await command.handler(_ctx(transport, store, config, "!trap"))
+
+ assert transport.texts == [f"WAIT {10 + DISPLAY_MARGIN_SECONDS}"]
+
+
+def test_the_draw_actually_varies() -> None:
+ """0.0.3 posted one identical gecko forever; that is what this catches."""
+ drawn = {random_trap(POOL) for _ in range(20)}
+ assert len(drawn) > 1
+
+
+def test_the_shipped_pool_is_page_ids_and_nothing_else() -> None:
+ """The personal-data invariant, as a unit test.
+
+ A filename here would carry photographers' and sitters' names into a
+ public repository, and one of them colliding with a label somebody minted
+ from the chat wedges check_personal_data.py permanently. Integers cannot.
+ """
+ raw = json.loads(TRAPS_FILE.read_text(encoding="utf-8"))["traps"]
+ assert raw
+ assert all(isinstance(page, int) for page in raw)
+ assert len(set(raw)) == len(raw)
+ assert raw == sorted(raw)
+
+
+def test_the_shipped_pool_is_large_enough_to_be_worth_drawing_from() -> None:
+ # kuhy asked for a set "as big as possible", and a regeneration that
+ # silently truncated would still pass every other test here.
+ assert len(load_traps()) >= 500
+
+
+@pytest.mark.parametrize("traps", [[], ["111419226.jpg"], [None]])
+def test_a_pool_that_is_not_page_ids_is_refused(tmp_path: Path, traps: list[object]) -> None:
+ path = tmp_path / "traps.json"
+ path.write_text(json.dumps({"traps": traps}), encoding="utf-8")
+
+ with pytest.raises(ValueError, match="page ids"):
+ load_traps(path)
diff --git a/tool/gen_traps.py b/tool/gen_traps.py
new file mode 100755
index 0000000..edc2add
--- /dev/null
+++ b/tool/gen_traps.py
@@ -0,0 +1,216 @@
+#!/usr/bin/env python3
+# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
+"""Regenerate ``signal_bot/traps.json`` from Wikimedia Commons.
+
+Run this by hand; the output is committed, so the bot never searches Commons
+at the moment somebody types ``!pułapka``.
+
+**The file holds page ids, not filenames, and that is load-bearing.** Commons
+filenames are free text carrying people's names -- the harvest turns up things
+like ``"Fishing-Boat with Fishermen" - Vasileios Chatzis.jpg``. Committing two
+thousand of those would eventually collide with a label somebody minted from
+the chat with ``!add_photo``, and ``scripts/check_personal_data.py`` would then
+refuse every commit for good, over data nobody can edit out by hand. An
+integer cannot carry a name. ``prop=imageinfo`` accepts ``pageids=`` and puts
+``thumburl`` at the same place ``titles=`` does, so nothing downstream cares.
+
+Depth is one, and that is also measured: recursing three levels from
+*Category:Fish traps* reaches Alfred Sisley's net paintings and Hawaiian
+fisherman watercolours. Immediate subcategories only.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+import time
+from pathlib import Path
+from typing import Any, Final
+
+import httpx
+
+CATEGORIES_FILE: Final = Path(__file__).with_name("trap_categories.json")
+
+API: Final = "https://commons.wikimedia.org/w/api.php"
+# Wikimedia's policy requires a descriptive agent with a contact URL; the
+# generic client default is rate-limited hard and may be blocked outright.
+USER_AGENT: Final = "signal-bot traps generator (https://github.com/kuhyx/signal-bot)"
+
+# The extension is what tells Signal how to render the attachment, so an
+# unknown one is dropped rather than guessed. Same set as portraits.SUFFIXES.
+SUFFIXES: Final = frozenset({".jpg", ".jpeg", ".png", ".gif"})
+
+# Commons hosts non-free content too. !gekon filters licences at its query and
+# endpoints.py calls that filter load-bearing; this is the same idea, on the
+# only field Commons exposes for it. Matched case-folded, by prefix.
+LICENCES: Final = ("public domain", "cc0", "cc by", "cc-by", "no restrictions")
+
+# imageinfo takes fifty page ids per request, joined with "|". A comma is
+# rejected outright ("badinteger"), which is worth writing down because the
+# error names the parameter rather than the separator.
+BATCH: Final = 50
+# A harvest smaller than this is a truncated one, and committing it would
+# quietly shrink the command's whole world.
+MINIMUM: Final = 500
+
+TOO_MANY_REQUESTS: Final = 429
+RETRIES: Final = 6
+PAUSE: Final = 0.4
+
+
+def categories() -> tuple[tuple[str, ...], frozenset[str], tuple[str, ...]]:
+ """Return the seeds, the pruned set and the pruned patterns."""
+ document = json.loads(CATEGORIES_FILE.read_text(encoding="utf-8"))
+ return (
+ tuple(document["seeds"]),
+ frozenset(document["excluded"]),
+ tuple(document["prune_patterns"]),
+ )
+
+
+def _get(params: dict[str, str]) -> dict[str, Any]:
+ """One API call, retried with a widening pause when Commons says slow down.
+
+ Commons answers 429 after a few hundred requests even at a steady 0.4s,
+ and a harvest that silently loses a category is worse than a slow one.
+ """
+ query = {"action": "query", "format": "json", "formatversion": "2", **params}
+ for attempt in range(RETRIES):
+ time.sleep(PAUSE)
+ response = httpx.get(API, params=query, headers={"User-Agent": USER_AGENT}, timeout=60.0)
+ if response.status_code == TOO_MANY_REQUESTS:
+ time.sleep(2.0**attempt)
+ continue
+ response.raise_for_status()
+ document: dict[str, Any] = response.json()
+ return document
+ msg = f"Commons kept answering {TOO_MANY_REQUESTS} for {params}"
+ raise RuntimeError(msg)
+
+
+def _members(category: str, kind: str) -> list[dict[str, Any]]:
+ """Every member of one category, following continuations."""
+ found: list[dict[str, Any]] = []
+ carry_on: str | None = None
+ while True:
+ params = {
+ "list": "categorymembers",
+ "cmtitle": category,
+ "cmtype": kind,
+ "cmlimit": "500",
+ }
+ if carry_on is not None:
+ params["cmcontinue"] = carry_on
+ document = _get(params)
+ found += document.get("query", {}).get("categorymembers", [])
+ carry_on = document.get("continue", {}).get("cmcontinue")
+ if carry_on is None:
+ return found
+
+
+def verify_seeds(seeds: tuple[str, ...]) -> None:
+ """Fail before harvesting if any seed category does not exist.
+
+ A category that is not there is not an error to Commons -- it is an empty
+ answer -- so a plausible-looking typo removes a whole subject silently.
+ Five of the first draft's names were wrong this way.
+
+ Raises:
+ RuntimeError: If any seed is missing, naming all of them at once.
+ """
+ missing: list[str] = []
+ for start in range(0, len(seeds), BATCH):
+ document = _get({"titles": "|".join(seeds[start : start + BATCH])})
+ missing += [
+ str(page["title"])
+ for page in document.get("query", {}).get("pages", [])
+ if page.get("missing")
+ ]
+ if missing:
+ msg = f"these seed categories do not exist on Commons: {sorted(missing)}"
+ raise RuntimeError(msg)
+
+
+def harvest() -> dict[int, str]:
+ """Page id to title, for every usable file one step from the seeds."""
+ seeds, excluded, patterns = categories()
+ verify_seeds(seeds)
+ files: dict[int, str] = {}
+ seen: set[str] = set(excluded)
+ for number, seed in enumerate(seeds, start=1):
+ sys.stderr.write(f"[{number}/{len(seeds)}] {seed} ({len(files)} files so far)\n")
+ sys.stderr.flush()
+ branch = [seed, *(sub["title"] for sub in _members(seed, "subcat"))]
+ for category in branch:
+ if category in seen or any(pattern in category for pattern in patterns):
+ continue
+ seen.add(category)
+ for entry in _members(category, "file"):
+ title = str(entry["title"])
+ if Path(title).suffix.lower() in SUFFIXES:
+ files[int(entry["pageid"])] = title
+ return files
+
+
+def licensed(files: dict[int, str]) -> dict[int, str]:
+ """Drop everything whose licence does not permit reposting it."""
+ kept: dict[int, str] = {}
+ ids = sorted(files)
+ sys.stderr.write(f"checking licences on {len(ids)} files\n")
+ sys.stderr.flush()
+ for start in range(0, len(ids), BATCH):
+ chunk = ids[start : start + BATCH]
+ document = _get(
+ {
+ "prop": "imageinfo",
+ "iiprop": "extmetadata",
+ "iiextmetadatafilter": "LicenseShortName",
+ "pageids": "|".join(str(page) for page in chunk),
+ }
+ )
+ for page in document.get("query", {}).get("pages", []):
+ info = page.get("imageinfo") or [{}]
+ short = info[0].get("extmetadata", {}).get("LicenseShortName", {}).get("value", "")
+ name = str(short).casefold()
+ if name.startswith(LICENCES):
+ kept[int(page["pageid"])] = files[int(page["pageid"])]
+ return kept
+
+
+def _rejection(traps: dict[int, str]) -> str:
+ """Return why this harvest must not be written, or an empty string."""
+ if len(traps) < MINIMUM:
+ return f"got {len(traps)} traps, expected at least {MINIMUM}"
+ return ""
+
+
+def main() -> int:
+ """Write traps.json, refusing to write a harvest that failed its own checks."""
+ parser = argparse.ArgumentParser(description="Regenerate signal_bot/traps.json.")
+ parser.add_argument(
+ "--titles",
+ action="store_true",
+ help="print every kept title; the committed file is integers, so this is the review",
+ )
+ arguments = parser.parse_args()
+
+ traps = licensed(harvest())
+ rejection = _rejection(traps)
+ if rejection:
+ sys.stderr.write(f"refusing to write: {rejection}\n")
+ return 1
+
+ if arguments.titles:
+ for page in sorted(traps):
+ sys.stdout.write(f"{page}\t{traps[page]}\n")
+
+ out = Path(__file__).resolve().parent.parent / "signal_bot" / "traps.json"
+ document = {"source": str(CATEGORIES_FILE.name), "traps": sorted(traps)}
+ out.write_text(json.dumps(document, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
+ sys.stdout.write(f"wrote {len(traps)} traps to {out}\n")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tool/trap_categories.json b/tool/trap_categories.json
new file mode 100644
index 0000000..97a4458
--- /dev/null
+++ b/tool/trap_categories.json
@@ -0,0 +1,58 @@
+{
+ "_comment": "Which Commons categories !pulapka draws from. Data, not source, for the reason aliases.json is: changing the policy should never mean touching Python. Every seed is checked against Commons before the harvest starts -- Snares, Man traps, Hunting traps, Wasp traps and Animal trapping all look like plausible names and none of them exists, so a typo would otherwise remove a whole subject in silence. by-function and by-animal are container categories whose immediate children hold the leg-hold, deadfall and per-species traps.",
+ "_excluded_comment": "Pruned as whole subtrees, never enqueued and never read. The command posts an attachment with no caption into a group chat, so this list is the only place the content of that attachment can be decided at all. Camera traps are photographs taken *by* a trap and drown the pool; booby traps reach wartime propaganda including a poster whose title carries a racial slur; the Wolfsangel is a heraldic charge that is also a neo-Nazi symbol; the mice categories are animals dying; the rest are geology, botany, paintings, architecture and bait rather than devices.",
+ "seeds": [
+ "Category:Traps",
+ "Category:Traps by function",
+ "Category:Traps by animal",
+ "Category:Mousetraps",
+ "Category:Rat traps",
+ "Category:Mole traps",
+ "Category:Bear traps",
+ "Category:Leghold traps",
+ "Category:Snare traps",
+ "Category:Cage traps",
+ "Category:Live capture traps",
+ "Category:Pitfall traps",
+ "Category:Trapping pits",
+ "Category:Deadfall traps",
+ "Category:Mantraps",
+ "Category:Fish traps",
+ "Category:Lobster traps",
+ "Category:Crab traps",
+ "Category:Eel traps",
+ "Category:Fyke nets",
+ "Category:Fly traps",
+ "Category:Flypaper",
+ "Category:Insect traps"
+ ],
+ "excluded": [
+ "Category:Animals in traps",
+ "Category:Baits",
+ "Category:Booby traps",
+ "Category:Booby traps in the Vietnam War",
+ "Category:Camera traps",
+ "Category:Carnivorous plants with prey",
+ "Category:Fishing bait",
+ "Category:Fixed gillnets",
+ "Category:Glue traps",
+ "Category:Mammals in traps",
+ "Category:Mice in cage traps",
+ "Category:Mice in glue mousetraps",
+ "Category:Mice in spring-loaded bar traps",
+ "Category:Petroleum traps",
+ "Category:Rodent bait stations",
+ "Category:Tar baby",
+ "Category:Trapdoors",
+ "Category:Traps in art",
+ "Category:Traps in heraldry",
+ "Category:Traps of carnivorous plants",
+ "Category:Videos of mice in mousetraps",
+ "Category:Wolfsangel"
+ ],
+ "prune_patterns": [
+ " in art",
+ " in heraldry"
+ ],
+ "_prune_comment": "Any subcategory whose title contains one of these is pruned wholesale. Measured: Category:Mousetraps in art put a Robert Campin altarpiece panel and an 18th-century oil of a street vendor into the pool, and both arrive with no caption to explain why a painting is the answer to !pulapka. A pattern rather than a list because Commons mints a new ' in art' category whenever somebody needs one."
+}