diff --git a/README.md b/README.md index 7f32055..fcff713 100644 --- a/README.md +++ b/README.md @@ -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; diff --git a/fake_signal/routes_fixtures.py b/fake_signal/routes_fixtures.py index 7e12358..c8b4fa8 100644 --- a/fake_signal/routes_fixtures.py +++ b/fake_signal/routes_fixtures.py @@ -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 diff --git a/fake_signal/scenarios/gecko_command.yaml b/fake_signal/scenarios/gecko_command.yaml new file mode 100644 index 0000000..40e0e1d --- /dev/null +++ b/fake_signal/scenarios/gecko_command.yaml @@ -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 } diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 2a8be33..d055822 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -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 & diff --git a/signal_bot/aliases.json b/signal_bot/aliases.json index 85e7643..bff732e 100644 --- a/signal_bot/aliases.json +++ b/signal_bot/aliases.json @@ -7,5 +7,8 @@ "dog": [ "!pies", "!psy", "!dog", "!dogs", "!woof", "!szczek", "!𝗽𝗶𝗲𝘀", "!͓̽p͓̽i͓̽e͓̽s͓̽" + ], + "gecko": [ + "!gekon", "!gekony", "!geko", "!gecko", "!geckon", "!geckos" ] } diff --git a/signal_bot/animals.py b/signal_bot/animals.py index 01e06d5..4f45cae 100644 --- a/signal_bot/animals.py +++ b/signal_bot/animals.py @@ -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) diff --git a/signal_bot/app.py b/signal_bot/app.py index d27b69e..4e71029 100644 --- a/signal_bot/app.py +++ b/signal_bot/app.py @@ -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( diff --git a/signal_bot/config.py b/signal_bot/config.py index 4460d15..a10f7f9 100644 --- a/signal_bot/config.py +++ b/signal_bot/config.py @@ -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, diff --git a/signal_bot/http_client.py b/signal_bot/http_client.py index 4b0a0b6..7ceabdf 100644 --- a/signal_bot/http_client.py +++ b/signal_bot/http_client.py @@ -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. diff --git a/tests/test_animals.py b/tests/test_animals.py index ecf99f0..0a38153 100644 --- a/tests/test_animals.py +++ b/tests/test_animals.py @@ -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 diff --git a/tests/test_fake_server.py b/tests/test_fake_server.py index 7ed4d15..a337839 100644 --- a/tests/test_fake_server.py +++ b/tests/test_fake_server.py @@ -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: diff --git a/tests/test_http_client.py b/tests/test_http_client.py index 074abb6..03917bd 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -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"