diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 2e1c292..75ebd6f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1078,7 +1078,8 @@ with the same `{placeholders}`; every Spanish string containing `?` also contain - `/state/sct.sqlite` is git-ignored. One table `kv(key TEXT PRIMARY KEY, value TEXT)` holding exactly: `offset`, `last_ask`, `conv:`, `pending:`, `said:`, `options:`, `remind:` (the reminder and plan offers waiting for a tap), `ctx:` (the last group line - a kept turn took), `calplan`, `skip:`, `migrated_chat`; any other key is refused. Two more + a kept turn took), `calplan`, `skip:`, `migrated_chat`, `announced_version`; any other key + is refused. Two more tables: `chat`, the chat log (5.2), and `reminder`, the reminders set from the chat (5.6). Everything else (`/estado` facts, what was already said, undo) is read from git, the log or the `Change`. The connection is opened with `check_same_thread=False` behind one lock, since the worker @@ -1103,6 +1104,10 @@ with the same `{placeholders}`; every Spanish string containing `?` also contain - At start the bot reads `getMe.can_read_all_group_messages`, then `getChatMember(chat_id=, user_id=)`; either one (privacy mode off, or status `administrator`/`creator`) turns on the privacy-off rule (4.3), and the log line says which. +- After the menu, a version the KV's `announced_version` does not hold yet is announced in private to + each admin of the profile (the group when it names none): «Nueva versión en marcha: 0.6.0 (antes + 0.5.0).». The version is stored once Telegram took at least one of those messages, so a failed send + is tried again at the next start and a restart on the same version says nothing. - `download(file_path, limit)` fetches a file `getFile` returned from `https://api.telegram.org/file/bot/`. Like `call`, its errors are `TelegramError("download", code, description)` with no URL in them; more than `limit` bytes raises diff --git a/supercycletracker/__init__.py b/supercycletracker/__init__.py index b88e970..2cb063a 100644 --- a/supercycletracker/__init__.py +++ b/supercycletracker/__init__.py @@ -1,3 +1,3 @@ """SuperCycleTracker: a menstrual cycle tracker that lives in a Telegram group.""" -__version__ = "0.5.0" +__version__ = "0.6.0" diff --git a/supercycletracker/bot.py b/supercycletracker/bot.py index 95365bf..6062532 100644 --- a/supercycletracker/bot.py +++ b/supercycletracker/bot.py @@ -44,7 +44,7 @@ import yaml -from supercycletracker import assistant, cycle, records +from supercycletracker import __version__, assistant, cycle, records from supercycletracker.i18n import Translator from supercycletracker.records import SYMPTOMS, BlockError, Change, Day, Edit, Log from supercycletracker.repo import DATA_LOCK, KV, STATE_DB, GitError, Repo @@ -640,6 +640,24 @@ def group_admin(self) -> bool: return False return isinstance(member, Mapping) and member.get("status") in ADMIN_STATUSES + def announce_version(self, version: str = __version__) -> None: + """Tell the admins in private, or the group when the profile names none, that a new + version runs. The version is kept in the KV only once Telegram took at least one message, + so a failed send is tried again at the next start and a restart on the same version + says nothing.""" + previous = self.kv.get("announced_version") + if previous == version: + return + if previous: + text = self.t("version_new_from", version=version, previous=previous) + else: + text = self.t("version_new", version=version) + chats = list(self.profile.admins) or [self.chat_id] + sent = [c for c in chats if c is not None and self.post(c, text) is not None] + if sent: + self.kv.set("announced_version", version) + log.info("version %s announced to %d chat(s)", version, len(sent)) + def set_commands(self) -> None: """The menu: one `setMyCommands` in the default scope with the five she uses. The admin commands are listed by /ayuda when an admin asks.""" @@ -1831,6 +1849,7 @@ def run(self, stop: threading.Event) -> None: delay = min(delay * 2, BACKOFF_MAX_S) self.repo.check_remote() self.set_commands() + self.announce_version() self.resume() try: while not stop.is_set(): diff --git a/supercycletracker/locales/en.yaml b/supercycletracker/locales/en.yaml index fff7372..8105115 100644 --- a/supercycletracker/locales/en.yaml +++ b/supercycletracker/locales/en.yaml @@ -233,3 +233,5 @@ plan_yes: "Yes" plan_no: "No" plan_moved: "The forecast has changed since; I will offer you the new one." plan_calendar_turn: "[asked with the «In the calendar» button of the forecast] Put these forecast entries in my calendar:\n{entries}" +version_new: "New version running: {version}." +version_new_from: "New version running: {version} (before {previous})." diff --git a/supercycletracker/locales/es.yaml b/supercycletracker/locales/es.yaml index 1c6ec09..79a1ac4 100644 --- a/supercycletracker/locales/es.yaml +++ b/supercycletracker/locales/es.yaml @@ -230,3 +230,5 @@ plan_yes: "Sí" plan_no: "No" plan_moved: "La previsión ha cambiado desde entonces; te ofrezco la nueva." plan_calendar_turn: "[pedido con el botón «En el calendario» de la previsión] Pon en mi calendario estas entradas de la previsión:\n{entries}" +version_new: "Nueva versión en marcha: {version}." +version_new_from: "Nueva versión en marcha: {version} (antes {previous})." diff --git a/supercycletracker/repo.py b/supercycletracker/repo.py index 82b372f..9fd6a74 100644 --- a/supercycletracker/repo.py +++ b/supercycletracker/repo.py @@ -46,7 +46,7 @@ # --- SQLite kv ----------------------------------------------------------------------------- -KV_KEYS = frozenset({"offset", "last_ask", "migrated_chat", "calplan"}) +KV_KEYS = frozenset({"offset", "last_ask", "migrated_chat", "calplan", "announced_version"}) KV_PREFIXES = ("conv:", "pending:", "skip:", "said:", "options:", "ctx:", "remind:") CHAT_SCHEMA = """ CREATE TABLE IF NOT EXISTS chat ( @@ -66,8 +66,9 @@ class KV: `pending:`, `said:` (the last answered messages, so an edit can be compared), `options:` (the choices under recent answers), `remind:` (reminders and calendar entries offered with buttons), `ctx:` (the last chat log row a turn took as context), - `calplan` (the calendar entries last offered), `skip:` and `migrated_chat`; any other - key is refused, so nothing else about the cycle can creep into a second store. + `calplan` (the calendar entries last offered), `skip:`, `migrated_chat` and + `announced_version` (the last version the admins were told of); any other key is refused, + so nothing else about the cycle can creep into a second store. Two more tables: `chat`, the chat log (every message of the chats the bot serves that it keeps, and its answers, for the assistant's recap), and `reminder`, the one-off reminders set diff --git a/tests/test_bot.py b/tests/test_bot.py index fa40045..3e5fdf1 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -16,7 +16,7 @@ seed_log, ) -from supercycletracker import assistant, cycle, records +from supercycletracker import __version__, assistant, cycle, records from supercycletracker.bot import ( TEXT_MAX, Bot, @@ -1385,6 +1385,60 @@ def test_run_stops_on_a_bad_token(make_bot): bot.run(threading.Event()) +def test_the_first_start_announces_the_version_to_each_admin_in_private(make_bot): + bot = make_bot() + bot.announce_version("1.2.3") + assert [(m["chat_id"], m["text"]) for m in bot.tg.of("sendMessage")] == [ + (ADMIN, "Nueva versión en marcha: 1.2.3.") + ] + assert bot.kv.get("announced_version") == "1.2.3" + + +def test_a_restart_on_the_same_version_says_nothing(make_bot): + bot = make_bot() + bot.announce_version("1.2.3") + bot.announce_version("1.2.3") + assert len(bot.tg.of("sendMessage")) == 1 + + +def test_a_new_version_names_the_one_before(make_bot): + bot = make_bot() + bot.announce_version("1.2.3") + bot.announce_version("1.3.0") + assert bot.tg.texts()[-1] == "Nueva versión en marcha: 1.3.0 (antes 1.2.3)." + assert bot.kv.get("announced_version") == "1.3.0" + + +def test_a_failed_announcement_is_not_stored_and_goes_out_at_the_next_start(make_bot): + bot = make_bot() + bot.tg.fail_next("sendMessage", 403, "Forbidden: bot can't initiate conversation with a user") + bot.announce_version("1.2.3") + assert bot.kv.get("announced_version") is None + bot.announce_version("1.2.3") + assert bot.tg.texts() == ["Nueva versión en marcha: 1.2.3."] * 2 # refused, then taken + assert bot.kv.get("announced_version") == "1.2.3" + + +def test_with_no_admins_the_announcement_goes_to_the_group(make_bot): + bot = make_bot(profile=PROFILE.replace(" admins: {222: Marta}\n", "")) + bot.announce_version("1.2.3") + assert [m["chat_id"] for m in bot.tg.of("sendMessage")] == [GROUP] + + +def test_run_announces_the_running_version_once(make_bot): + bot = make_bot() + bot.repo.flush = lambda force=False: "idle" + stop = threading.Event() + + def interrupted(): + stop.set() + raise Interrupted + + bot.tg.updates = [interrupted] + bot.run(stop) + assert bot.tg.texts() == [f"Nueva versión en marcha: {__version__}."] + + # --- voice notes ---------------------------------------------------------------------------