mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +01:00
feat: !gekon posts a research-grade gecko photo
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a202317072
commit
79dab5cc01
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
5
fake_signal/scenarios/gecko_command.yaml
Normal file
5
fake_signal/scenarios/gecko_command.yaml
Normal 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 }
|
||||
@ -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 &
|
||||
|
||||
@ -7,5 +7,8 @@
|
||||
"dog": [
|
||||
"!pies", "!psy", "!dog", "!dogs", "!woof", "!szczek",
|
||||
"!𝗽𝗶𝗲𝘀", "!͓̽p͓̽i͓̽e͓̽s͓̽"
|
||||
],
|
||||
"gecko": [
|
||||
"!gekon", "!gekony", "!geko", "!gecko", "!geckon", "!geckos"
|
||||
]
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user