feat: !gekon posts a research-grade gecko photo

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-29 23:26:40 +02:00
parent a202317072
commit 79dab5cc01
12 changed files with 169 additions and 29 deletions

View File

@ -1,6 +1,6 @@
# signal-bot
A Signal group bot: `!cat` / `!dog` image commands and a daily message tally.
A Signal group bot: `!cat` / `!dog` / `!gekon` image commands and a daily tally.
Runs on its **own registered Signal number** (or, if you prefer, as a linked
device on an existing account) via
@ -196,10 +196,10 @@ connection to Signal is dead, a two-minute timer restarts the container, and
|---|---|
| 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` |
Aliases live in `signal_bot/aliases.json`, not in source. Each command is
rate-limited per user, per command — the original bot shared one 10-second
window across everybody.
Aliases live in `signal_bot/aliases.json`, not in source. Each command is rate-limited
per user, per command — the original bot shared one 10-second window across everybody.
`signal_bot/decorators.py` also ships `daily_limit` and `vote_gated` (run only
once N distinct people ask, within a window). Both are generic and tested;

View File

@ -1,5 +1,5 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Local stand-ins for thecatapi and dog.ceo, shaped like the real responses."""
"""Local stand-ins for the image APIs, shaped like the real responses."""
from __future__ import annotations
@ -27,8 +27,22 @@ def build_fixture_router() -> APIRouter:
async def dog_api(request: Request) -> dict[str, str]:
return {"message": _image_url(request)}
@router.get("/gecko-api")
async def gecko_api(request: Request) -> dict[str, object]:
state: State = request.app.state.fake
photo = {"url": f"{state.base_url}/_fixtures/photos/square.png"}
return {"results": [{"photos": [photo]}]}
@router.get("/image.png")
async def image() -> Response:
return Response(content=PIXEL_PNG, media_type="image/png")
# Only the rewritten URL is served. iNaturalist names a square thumbnail
# and the bot is supposed to swap it for the full-size photo, so a
# regression in that rewrite must fail the run with a 404 rather than
# quietly posting a 75px image nobody looks at closely.
@router.get("/photos/large.png")
async def large_photo() -> Response:
return Response(content=PIXEL_PNG, media_type="image/png")
return router

View File

@ -0,0 +1,5 @@
name: "!gekon posts a gecko photo, fetched from the rewritten full-size URL"
inject:
- { text: "!gekon", uuid: uuid-alice, name: Alice }
expect:
- { kind: image, to: reply }

View File

@ -129,6 +129,7 @@ main() {
DATABASE_PATH="${TEMP_DIR}/e2e.sqlite3" \
CAT_API_URL="${FAKE_URL}/_fixtures/cat-api" \
DOG_API_URL="${FAKE_URL}/_fixtures/dog-api" \
GECKO_API_URL="${FAKE_URL}/_fixtures/gecko-api" \
BIND_PORT="$BOT_PORT" \
SILENCE_SECONDS=0 \
"$PYTHON" -m signal_bot &

View File

@ -7,5 +7,8 @@
"dog": [
"!pies", "!psy", "!dog", "!dogs", "!woof", "!szczek",
"!𝗽𝗶𝗲𝘀", "!͓̽p͓̽i͓̽e͓̽s͓̽"
],
"gecko": [
"!gekon", "!gekony", "!geko", "!gecko", "!geckon", "!geckos"
]
}

View File

@ -4,9 +4,12 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Final
from signal_bot.config import DEFAULT_CAT_API_URL, DEFAULT_DOG_API_URL, DEFAULT_GECKO_API_URL
if TYPE_CHECKING:
from collections.abc import Sequence
@ -14,14 +17,32 @@ if TYPE_CHECKING:
from signal_bot.http_client import ImageFetcher
from signal_bot.registry import CommandContext, CommandRegistry, Handler
CAT_API_URL: Final = "https://api.thecatapi.com/v1/images/search"
CAT_URL_PATH: Final[tuple[str | int, ...]] = (0, "url")
DOG_API_URL: Final = "https://dog.ceo/api/breeds/image/random"
DOG_URL_PATH: Final[tuple[str | int, ...]] = ("message",)
ALIAS_FILE: Final = Path(__file__).with_name("aliases.json")
@dataclass(frozen=True, slots=True)
class ImageSource:
"""Where one image command gets its picture, and how to read the answer."""
api_url: str
url_path: tuple[str | int, ...]
rewrite: tuple[str, str] | None = None
# iNaturalist answers with the 75px square thumbnail; the full-size photo is
# the same URL with one segment swapped. Kept beside the endpoint because it
# is a property of that endpoint, not of image fetching in general.
SOURCES: Final[dict[str, ImageSource]] = {
"cat": ImageSource(DEFAULT_CAT_API_URL, (0, "url")),
"dog": ImageSource(DEFAULT_DOG_API_URL, ("message",)),
"gecko": ImageSource(
DEFAULT_GECKO_API_URL,
("results", 0, "photos", 0, "url"),
("/square.", "/large."),
),
}
def load_aliases(path: Path = ALIAS_FILE) -> dict[str, list[str]]:
"""Load the trigger table.
@ -32,11 +53,16 @@ def load_aliases(path: Path = ALIAS_FILE) -> dict[str, list[str]]:
return {name: list(triggers) for name, triggers in document.items() if not name.startswith("_")}
def image_handler(fetcher: ImageFetcher, api_url: str, url_path: Sequence[str | int]) -> Handler:
def image_handler(
fetcher: ImageFetcher,
api_url: str,
url_path: Sequence[str | int],
rewrite: tuple[str, str] | None = None,
) -> Handler:
"""Build a handler that posts one random image from a JSON image API."""
async def handler(ctx: CommandContext) -> None:
encoded = await fetcher.fetch_base64(api_url, url_path)
encoded = await fetcher.fetch_base64(api_url, url_path, rewrite)
await ctx.reply_image(encoded)
return handler
@ -50,7 +76,7 @@ def register_animal_commands(
aliases: dict[str, list[str]] | None = None,
endpoints: dict[str, str] | None = None,
) -> None:
"""Register the cat and dog commands on a registry.
"""Register every image command on a registry.
Args:
registry: Registry to populate.
@ -62,14 +88,10 @@ def register_animal_commands(
public APIs; the offline environment points these at itself.
"""
table = load_aliases() if aliases is None else aliases
urls = {"cat": CAT_API_URL, "dog": DOG_API_URL} | (endpoints or {})
paths: dict[str, tuple[str | int, ...]] = {
"cat": CAT_URL_PATH,
"dog": DOG_URL_PATH,
}
for name, api_url in urls.items():
url_path = paths[name]
handler = image_handler(fetcher, api_url, url_path)
overrides = endpoints or {}
for name, source in SOURCES.items():
api_url = overrides.get(name, source.api_url)
handler = image_handler(fetcher, api_url, source.url_path, source.rewrite)
if decorate is not None:
handler = decorate(handler)
registry.register(name, table[name], handler)

View File

@ -39,6 +39,9 @@ logger = logging.getLogger(__name__)
COOLDOWN_MESSAGE = "BEEP BOOP POCZEKAJ {seconds} SEKUND."
HTTP_TIMEOUT_SECONDS = 30.0
# iNaturalist asks automated clients to identify themselves, and Wikimedia's
# policy outright refuses the default "python-httpx/x.y" this used to send.
USER_AGENT = f"signal-bot/{__version__} (+https://github.com/kuhyx/signal-bot)"
def now_local() -> dt.datetime:
@ -116,7 +119,7 @@ def build_bot(
registry,
ImageFetcher(client),
decorate=cooldown(config.cooldown_seconds, COOLDOWN_MESSAGE),
endpoints={"cat": config.cat_api_url, "dog": config.dog_api_url},
endpoints=config.image_api_urls,
)
dispatcher = Dispatcher(
config=config,
@ -207,7 +210,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
the HTTP app never began serving.
"""
config = load_config()
client = httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS)
client = httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS, headers={"User-Agent": USER_AGENT})
runtime = await create_runtime(config, client)
runtime.start()
logger.info(

View File

@ -38,6 +38,14 @@ DEFAULT_REPORT_MINUTE: Final = 37
DEFAULT_COOLDOWN_SECONDS: Final = 10
DEFAULT_CAT_API_URL: Final = "https://api.thecatapi.com/v1/images/search"
DEFAULT_DOG_API_URL: Final = "https://dog.ceo/api/breeds/image/random"
# taxon_id 371057 is Gekkota, the gecko infraorder. The licence filter is not
# decoration: without it the API happily returns all-rights-reserved photos,
# and this bot reposts whatever it is handed into a group chat.
DEFAULT_GECKO_API_URL: Final = (
"https://api.inaturalist.org/v1/observations"
"?taxon_id=371057&photos=true&quality_grade=research"
"&photo_license=cc-by,cc-by-sa,cc0&per_page=1&order_by=random"
)
DEFAULT_BIND_HOST: Final = "127.0.0.1"
# Not 8000: that port is a magnet for unrelated dev servers and was already
# taken here by a long-running enforcer. 8137 sits below the ephemeral range
@ -73,6 +81,7 @@ class Config:
# without the bot knowing it is not talking to the real APIs.
cat_api_url: str = DEFAULT_CAT_API_URL
dog_api_url: str = DEFAULT_DOG_API_URL
gecko_api_url: str = DEFAULT_GECKO_API_URL
# Where the /healthz readiness probe binds. Read here rather than in
# __main__ so that .env governs it: __main__ runs before anything has
# loaded a .env file, so an os.getenv() there would silently ignore it.
@ -154,6 +163,11 @@ class Config:
"""REST endpoint listing the groups this account knows about."""
return f"{self.api_base_url}/v1/groups/{self.phone_number}"
@property
def image_api_urls(self) -> dict[str, str]:
"""Image-API endpoint per command name, as the commands want it."""
return {"cat": self.cat_api_url, "dog": self.dog_api_url, "gecko": self.gecko_api_url}
def _require(env: dict[str, str], key: str) -> str:
"""Return a non-empty environment value or raise ConfigError."""
@ -221,6 +235,7 @@ def load_config(env: dict[str, str] | None = None, *, use_dotenv: bool = True) -
cooldown_seconds=_positive_int(env, "COOLDOWN_SECONDS", DEFAULT_COOLDOWN_SECONDS),
cat_api_url=env.get("CAT_API_URL", "").strip() or DEFAULT_CAT_API_URL,
dog_api_url=env.get("DOG_API_URL", "").strip() or DEFAULT_DOG_API_URL,
gecko_api_url=env.get("GECKO_API_URL", "").strip() or DEFAULT_GECKO_API_URL,
bind_host=env.get("BIND_HOST", "").strip() or DEFAULT_BIND_HOST,
bind_port=bind_port,
log_level=log_level,

View File

@ -27,12 +27,23 @@ class ImageFetcher:
"""Store the injected HTTP client."""
self._client = client
async def fetch_base64(self, api_url: str, url_path: Sequence[str | int]) -> str:
async def fetch_base64(
self,
api_url: str,
url_path: Sequence[str | int],
rewrite: tuple[str, str] | None = None,
) -> str:
"""Fetch an image and return it as base64.
Args:
api_url: JSON endpoint that names an image.
url_path: Keys/indices to walk in the JSON to reach the image URL.
rewrite: Optional (find, replace) pair applied to the URL found
there. Some APIs name a thumbnail and leave the full-size
photo one substitution away -- iNaturalist's observation JSON
gives a 75px `/square.` URL whose `/large.` sibling is the
real photo -- and a substitution keeps that upstream quirk as
data next to the endpoint rather than as a branch in here.
Returns:
The image bytes, base64-encoded.
@ -43,6 +54,8 @@ class ImageFetcher:
response = await self._client.get(api_url)
response.raise_for_status()
image_url = _walk(response.json(), url_path)
if rewrite is not None:
image_url = image_url.replace(*rewrite)
# Never written to disk: the original bot named a local file after the
# remote URL's last path segment and wrote it into the process CWD.

View File

@ -30,12 +30,20 @@ if TYPE_CHECKING:
IMAGE = b"png-bytes"
def _fetcher() -> ImageFetcher:
def _fetcher(seen: list[str] | None = None) -> ImageFetcher:
"""Build a fetcher over the three fixture APIs, optionally recording URLs."""
def handler(request: httpx.Request) -> httpx.Response:
if "thecatapi" in str(request.url):
url = str(request.url)
if seen is not None:
seen.append(url)
if "thecatapi" in url:
return httpx.Response(200, json=[{"url": "https://cdn/cat.png"}])
if "dog.ceo" in str(request.url):
if "dog.ceo" in url:
return httpx.Response(200, json={"message": "https://cdn/dog.png"})
if "inaturalist" in url:
photo = {"url": "https://cdn/photos/1/square.jpeg"}
return httpx.Response(200, json={"results": [{"photos": [photo]}]})
return httpx.Response(200, content=IMAGE)
return ImageFetcher(httpx.AsyncClient(transport=httpx.MockTransport(handler)))
@ -63,6 +71,7 @@ def test_alias_file_ships_with_the_package() -> None:
aliases = load_aliases()
assert "!cat" in aliases["cat"]
assert "!pies" in aliases["dog"]
assert "!gekon" in aliases["gecko"]
def test_comment_keys_are_skipped(tmp_path: Path) -> None:
@ -77,9 +86,12 @@ def test_styled_unicode_aliases_resolve() -> None:
assert registry.resolve("!\U0001d5f8\U0001d5fc\U0001d601") is not None
assert registry.resolve("!miau").name == "cat"
assert registry.resolve("!szczek").name == "dog"
assert registry.resolve("!gekon").name == "gecko"
@pytest.mark.parametrize(("trigger", "name"), [("!cat", "cat"), ("!dog", "dog")])
@pytest.mark.parametrize(
("trigger", "name"), [("!cat", "cat"), ("!dog", "dog"), ("!gekon", "gecko")]
)
async def test_commands_post_an_image(
transport: FakeTransport, store: Store, config: Config, trigger: str, name: str
) -> None:
@ -106,6 +118,21 @@ async def test_decorator_is_applied_when_given(
def test_custom_alias_table_is_honoured() -> None:
registry = CommandRegistry()
register_animal_commands(registry, _fetcher(), aliases={"cat": ["!c"], "dog": ["!d"]})
table = {"cat": ["!c"], "dog": ["!d"], "gecko": ["!g"]}
register_animal_commands(registry, _fetcher(), aliases=table)
assert registry.resolve("!c").name == "cat"
assert registry.resolve("!cat") is None
async def test_gecko_asks_for_the_full_size_photo(
transport: FakeTransport, store: Store, config: Config
) -> None:
"""iNaturalist names a 75px thumbnail; the group should get the real photo."""
seen: list[str] = []
registry = CommandRegistry()
register_animal_commands(registry, _fetcher(seen))
command = registry.resolve("!gecko")
assert command is not None
await command.handler(_ctx(transport, store, config, "gecko", "!gecko"))
assert "https://cdn/photos/1/large.jpeg" in seen
assert "https://cdn/photos/1/square.jpeg" not in seen

View File

@ -80,9 +80,14 @@ def test_inject_with_no_listeners_delivers_nothing(client: TestClient) -> None:
def test_fixture_apis_mirror_the_real_response_shapes(client: TestClient) -> None:
cat = client.get("/_fixtures/cat-api").json()
dog = client.get("/_fixtures/dog-api").json()
gecko = client.get("/_fixtures/gecko-api").json()
assert cat[0]["url"].endswith("/_fixtures/image.png")
assert dog["message"].endswith("/_fixtures/image.png")
assert gecko["results"][0]["photos"][0]["url"].endswith("/_fixtures/photos/square.png")
assert client.get("/_fixtures/image.png").content == PIXEL_PNG
# The square URL is deliberately absent: the bot must rewrite it.
assert client.get("/_fixtures/photos/large.png").content == PIXEL_PNG
assert client.get("/_fixtures/photos/square.png").status_code == 404
def test_create_app_makes_its_own_state_when_none_given() -> None:

View File

@ -85,3 +85,35 @@ async def test_image_download_error_propagates() -> None:
with pytest.raises(httpx.HTTPStatusError):
await _fetcher(handler).fetch_base64("https://api/images/search", [0, "url"])
async def test_rewrite_swaps_the_thumbnail_for_the_full_size_photo() -> None:
"""The URL actually downloaded is the rewritten one, not the one in the JSON."""
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(str(request.url))
if "observations" in str(request.url):
return httpx.Response(200, json={"url": "https://cdn/1/square.jpeg"})
return httpx.Response(200, content=IMAGE)
encoded = await _fetcher(handler).fetch_base64(
"https://api/observations", ["url"], ("/square.", "/large.")
)
assert base64.b64decode(encoded) == IMAGE
assert seen[-1] == "https://cdn/1/large.jpeg"
async def test_rewrite_that_matches_nothing_leaves_the_url_alone() -> None:
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(str(request.url))
if "observations" in str(request.url):
return httpx.Response(200, json={"url": "https://cdn/1/original.jpeg"})
return httpx.Response(200, content=IMAGE)
await _fetcher(handler).fetch_base64(
"https://api/observations", ["url"], ("/square.", "/large.")
)
assert seen[-1] == "https://cdn/1/original.jpeg"