signal-bot/signal_bot/announce.py
Krzysztof kuhy Rudnicki 9db3687752 release: 0.1.7 «Callistus I» — a version now says which pope, and when
"Wersja 0.1.7 «Callistus I»" told nobody whether Callistus was the
fifteenth pope or the hundredth, and the changelog was the one place the
ladder is actually read. pope_line() derives both facts from popes.json --
the ordinal, and the `reign` string exactly as the table spells it, so the
one reigning pope keeps his single year instead of being padded into a
range he never had.

It appears in all four places a version is named: the group announcement
(header and every block of a backlog), each section of DOCS-changelog.md,
/healthz, and the !papiez caption, which now describes the release's pope
in the same sentence it uses for every other one.

The changelog's copy is checked rather than trusted, like the heading name
already was: a test walks the raw file and compares each line against
popes.json, so a hand-edited ordinal fails here instead of in a group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
2026-08-30 22:12:09 +02:00

131 lines
5.2 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Telling a group that the bot has been upgraded.
A group is told about every release it has not seen, and a group that has
never been told anything has not seen any of them -- so the first
announcement starts at 0.0.0 "Petrus" rather than skipping straight to
whatever is current. Watching the ladder start at Peter is the point of
naming releases after popes at all.
That does mean a group added years from now would be handed the whole
backlog, so the message is capped at MAX_RELEASES and says how many older
versions it left out.
The announcement carries the same three things `!papiez` does: the pope's
portrait, his Latin name and his Polish Wikipedia article. Only for the pope
in the header -- ten attachments is the wall MAX_RELEASES exists to avoid.
The version is recorded *before* the message is sent. `Restart=always` with
`RestartSec=5` means a crash between the two would otherwise re-announce on
every restart, and a missed announcement is far cheaper than a looping one.
That is also why a portrait that will not download costs the photo and not
the announcement: the marker is already written, so a Commons outage at boot
would otherwise eat that group's release notes permanently.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Final
from signal_bot.changelog import since
from signal_bot.versioning import article_url, latin_name, pope_line, version_for
if TYPE_CHECKING:
from collections.abc import Iterable
from signal_bot.changelog import Release
from signal_bot.portraits import PortraitCache
from signal_bot.store import Store
from signal_bot.transport_base import Transport
from signal_bot.versioning import Pope
logger = logging.getLogger(__name__)
ANNOUNCED_KEY: Final = "announced_version"
# How many releases one announcement may list before it becomes a wall.
MAX_RELEASES: Final = 10
def render(pope: Pope, releases: Iterable[Release]) -> str:
"""Return the message announcing a new version, in Polish.
One release is rendered flat, because the header already names it. Several
get a block each, so the group can see the ladder it is climbing. Pure on
purpose: whether this text travels as a caption or as a plain message is
announce_to's call, not this function's.
"""
shown = list(releases)
header = f"Wersja {version_for(pope.ordinal)} \u00ab{latin_name(pope)}\u00bb wydana"
header = f"{header}\n{pope_line(pope)}\n{article_url(pope)}"
omitted = max(0, len(shown) - MAX_RELEASES)
shown = shown[-MAX_RELEASES:]
if len(shown) == 1:
body = "\n".join(f"- {line}" for line in shown[0].lines)
return f"{header}\nChangelog:\n{body}"
blocks = [
"\n".join(
[
f"{release.version} \u00ab{latin_name(release.pope_entry)}\u00bb",
pope_line(release.pope_entry),
*(f"- {x}" for x in release.lines),
]
)
for release in shown
]
tail = f"\n\n(pominieto {omitted} starszych wersji)" if omitted else ""
return f"{header}\nChangelog:\n\n" + "\n\n".join(blocks) + tail
async def announce_to(
transport: Transport,
store: Store,
group_id: str,
releases: tuple[Release, ...],
pope: Pope,
portraits: PortraitCache,
) -> None:
"""Announce the current version to one group, if it has not seen it."""
version = version_for(pope.ordinal)
previous = store.meta(group_id, ANNOUNCED_KEY)
if previous == version:
return
# No record at all means the group has seen nothing, not that it should be
# skipped: that is how Petrus gets announced rather than silently assumed.
pending = releases if previous is None else since(releases, previous)
store.set_meta(group_id, ANNOUNCED_KEY, version)
if not pending:
logger.info("group %s recorded at %s with nothing to announce", group_id, version)
return
text = render(pope, pending)
try:
image = await portraits.get_base64(pope)
await transport.send_image(image, group_id, caption=text)
except Exception:
# Both the download and the send are inside this: the marker is
# already written, so either failing would otherwise cost the group
# its release notes permanently. The attachment turns a 2 KB text
# post into a megabyte of base64, which signal-cli can refuse on its
# own terms while it is perfectly able to take the text -- and a
# duplicate post, if the send failed after delivery, is far cheaper
# than release notes nobody ever sees.
logger.exception("photo announcement of %s failed for %s; sending text", version, group_id)
await transport.send_text(text, group_id)
async def announce_all(
transport: Transport,
store: Store,
group_ids: Iterable[str],
releases: tuple[Release, ...],
pope: Pope,
portraits: PortraitCache,
) -> None:
"""Announce to every watched group; one failure must not silence the rest."""
for group_id in group_ids:
try:
await announce_to(transport, store, group_id, releases, pope, portraits)
except Exception:
logger.exception("release announcement failed for group %s", group_id)