From d1e9f00527aaf0513abdc6007d1b87636c8b8107 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:09:18 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat(bot):=20never=20leave=20a=20household?= =?UTF-8?q?=20message=20with=20nothing=20under=20it,=20mark=20it=20read=20?= =?UTF-8?q?with=20=F0=9F=91=80=20(0.9.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With answer_scope: all, a turn that ended in NO_REPLY, an empty answer, an empty gestures block, choices with no text or a record that changed nothing showed nothing in the group, so the person could not tell whether the bot had read them. Such a turn now puts 👀 on every person's message it covered, handed-over lines included, trying 👍 once where Telegram refuses 👀 and never posting text instead. A reaction-only answer goes on every covered message, not only the last. A mention, a reply to the bot or a voice note never gets only 👀: when its turn showed nothing it gets send_failed. A failed turn marks nothing and its lines go with the next one. The rules now say NO_REPLY marks the messages read. asked mode and private chats are unchanged. --- AGENTS.md | 2 +- README.md | 2 +- docs/DESIGN.md | 39 ++++-- supercycletracker/__init__.py | 2 +- supercycletracker/assistant.py | 16 +-- supercycletracker/bot.py | 115 ++++++++++++++--- tests/test_answer_scope.py | 221 +++++++++++++++++++++++++++++++-- 7 files changed, 349 insertions(+), 48 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b0ee387..12df138 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Run the test suite once per change. It drives real git repositories in temp dirs - This is health data. Never log personal data or message contents, and never write a record anywhere except the private data repo the household owns. - Code never knows a person, a chat or a date of a real cycle. Those live in the data repo. Keep every example and test fictional. - Every user-facing string is translatable. English is the source; Spanish ships too, and the bot talks Spanish to its users. -- In the group the bot reads replies to the bot, answers to its questions, commands, and messages that mention it when Telegram delivers them (as a group admin, all of her messages). Voice notes, photos, videos, stickers and files from her or an admin are always processed. It also answers private chats with her or an admin. With `history_scope: all` the group's other messages go to the assistant as context marked `[not to you]`, never answered. With `answer_scope: all` her and the admins' group texts without a mention start a turn like addressed ones, marked `[no mention]` (her plain texts too when the bot reads the whole group; context stays `[not to you]`, and principle 1 is rendered scoped to allow it); the answer may be only a reaction, or nothing when the assistant writes `NO_REPLY`, which the bot never sends. Bots never start a turn, and a failed turn of only such lines posts no error. +- In the group the bot reads replies to the bot, answers to its questions, commands, and messages that mention it when Telegram delivers them (as a group admin, all of her messages). Voice notes, photos, videos, stickers and files from her or an admin are always processed. It also answers private chats with her or an admin. With `history_scope: all` the group's other messages go to the assistant as context marked `[not to you]`, never answered. With `answer_scope: all` her and the admins' group texts without a mention start a turn like addressed ones, marked `[no mention]` (her plain texts too when the bot reads the whole group; context stays `[not to you]`, and principle 1 is rendered scoped to allow it); the answer may be only a reaction (on every message of the turn), or no words when the assistant writes `NO_REPLY`, which the bot never sends. A person's group message is never left with nothing: a turn that shows nothing puts 👀 («read») on every message it covered (👍 once where 👀 is refused), and a mention or reply that got nothing gets `send_failed`. Bots never start a turn, and a failed turn of only such lines posts no error and no 👀. - Whatever the bot offers to do (a reminder, calendar entries) waits for a tap; without one nothing happens. - The tracker syncs to a private remote named in `.beads/config.yaml`, never to this public repo. Nothing written here names the server behind it. - Conventional commits. PR titles say why, not what. diff --git a/README.md b/README.md index 31a7e8f..71cd938 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ SuperCycleTracker is a menstrual cycle tracker that lives in a Telegram group. I - The assistant reads the shared assistant principles first, in English or Spanish (`supercycletracker/principles/`): twelve rules on whom it answers, buttons, offers, privacy and honest results. The bot's own rules in `rules.md` follow them and only add what is specific to tracking a cycle. The files are copied unchanged from the shared text, and a test pins their hashes. - A burst of messages gets one answer, and what she writes while it thinks joins that answer. An edited message is taken into account. Choices come as buttons she can tap or ignore, and an action the assistant wants to take waits for Approve or Reject. - Optionally reads the rest of the group as context, marked as not addressed to it, and keeps a chat log so a fresh conversation starts from the last two weeks of the chat. A remark between people is never taken as an order. -- Optionally (`answer_scope: all`) takes her and the admins' group messages without a mention as meant for it too: it acts on what they say, answers with a 👍 when that is enough, in words when they ask something or change what someone has to do, and stays silent on talk that has nothing for it. +- Optionally (`answer_scope: all`) takes her and the admins' group messages without a mention as meant for it too: it acts on what they say, answers with a 👍 when that is enough, in words when they ask something or change what someone has to do, and marks talk that has nothing for it with a 👀 («read»), so no message is left with nothing under it. - Offers what it can do instead of doing it unasked: a reminder here or in her calendar, or the predicted phases and next period as calendar entries, with buttons. Without an answer nothing happens, and `/recordatorios` lists and cancels the reminders set. - Every string is translatable. English and Spanish ship. - Keeps the records in a private git repository you own, one commit per check-in, with `/deshacer` as undo. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 375c052..7a28c80 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -428,10 +428,12 @@ is a group admin. The rules tell the assistant what such a line wants: take in what it says and act on it as if asked (a day it describes gets its record block), then answer with only a reaction (👍, "seen and noted") unless it asks something or changes what someone has to do, in which case answer in words and say -it took note. Talk between people with nothing for the assistant gets no answer at all: the -assistant writes only `NO_REPLY`. A mention or a reply to the bot always gets words. The reaction is -the assistant's own, after reading, through the `telegram` block (gestures.py); the bot never reacts -on arrival. With several lines in one turn it goes on the last one. Principle 1 of the shared +it took note. Talk between people with nothing for the assistant gets no words: the assistant writes +only `NO_REPLY`, and the bot marks the messages 👀 («read»). A mention or a reply to the bot always +gets words. The reaction is the assistant's own, after reading, through the `telegram` block +(gestures.py); the bot never reacts on arrival. In the group, an answer with no text puts its +reaction on every person's message the turn covered, the lines it started with and those handed over +mid-turn alike; an answer with text reacts on the last one, as in `asked`. Principle 1 of the shared principles says the rest of the chat is never answered and that no rule overrides it, so in this mode the app renders principle 1 scoped (`assistant.PRINCIPLE_1_ALL`): the household's lines are addressed to the assistant mention or not, and `[not to you]` lines stay context. The shared files @@ -442,12 +444,27 @@ says nothing in the group: no `ai_down` and no `ai_busy` in the middle of the ho lines stay in `pending:` and go with the next turn, as after any failed turn. A turn with an addressed line in it posts the notice as before. -**No answer.** The app removes the word `NO_REPLY` (with any formatting around it, and `NO\_REPLY`) -from every answer and every `later` text before sending, so it never reaches the chat. When nothing -but punctuation, quotes, brackets or an empty code fence is left (`NO_REPLY.`, `«NO_REPLY»`, a fenced -`NO_REPLY`), the answer is empty. An answer left with no text and -no gesture sends nothing, posts no `ai_down`, is not retried, and still counts as answered: the -conversation is kept and the lines leave `pending:`, so they are not sent again. The word +**No words, never nothing.** The app removes the word `NO_REPLY` (with any formatting around it, and +`NO\_REPLY`) from every answer and every `later` text before sending, so it never reaches the chat. +When nothing but punctuation, quotes, brackets or an empty code fence is left (`NO_REPLY.`, +`«NO_REPLY»`, a fenced `NO_REPLY`), the answer is empty. An empty answer posts no `ai_down`, is not +retried, and still counts as answered: the conversation is kept and the lines leave +`pending:`, so they are not sent again. + +With `answer_scope: all`, a person's message in the group is never left with nothing visible. When a +kept turn showed nothing in the chat (no text, no reaction, sticker, photo or file, no «Anotado:», +no settings or reminder message), the bot puts 👀 on every person's message the turn covered (texts, +voice notes, photos and files by their own id, an edit on the message edited; a tap is on the bot's +own message and gets none). That covers `NO_REPLY`, an empty answer (the private plugin's own +silence word arrives as `""`), an empty `telegram` block, choices with no text to carry them, and a +record block that changed nothing. Where Telegram refuses 👀 on a message (the chat's allowed +reactions, a deleted message), the bot tries 👍 once on that message, then logs the refusal and +goes on; it never posts a text instead. A message that called the bot (a mention, a reply to it, a +voice note, a photo or file) gets words, never only 👀: when such a turn showed nothing, the bot +posts `send_failed` as a reply to the newest one, and only the turn's `[no mention]` messages get 👀. +A failed turn (an exception, a timeout, `kept: false`, a busy plugin) puts no 👀, since nothing was +read: its lines stay in `pending:` and go with the next turn. Private chats and `asked` keep +the 0.8 behaviour: an empty answer shows nothing there. The word exists because a provider may fail a turn whose session wrote no text (AIDriver ends one as `no_output`, and counts a message handed in mid-turn as answered only when text follows it); a plugin that returns `""` gets the same clean result. @@ -1259,7 +1276,7 @@ joins a group or its abilities change. | `test_turns.py` | section 5.2: one answer per burst, lines handed to a running turn, edits, choices as buttons, asks; a line queued before a restart and a turn killed mid-flight are answered by the next process | | `test_gestures.py` | the renderer (folds, quotes, code with its language, hostile markup never opening a tag), the gestures block on hostile model output (dropped), the HTML send and its plain-text resend, a reaction alone sends no text and a failed one sends the emoji, files and a refused file never losing the text, a file name with no letter named in her language, stickers only from the profile's set, photos only from `photo_hosts`, the voice reaching the rules | | `test_offers.py` | the chat log: group talk goes to the next turn as `[not to you]` context once and is never answered, nothing is kept with `asked`, a failed turn keeps it, the recap leaves out the new lines and reaches back `history_days` only (0: none), edits rewrite the log and one that adds the mention keeps one row, `/nuevo` forgets it; an offer says no answer does nothing and expires into «Sin respuesta: nada»; the rules say who reaches the assistant; the calendar plan's dates (and none once late), its place in `context.md`, its offer once per key in waking hours with configurable titles, one tap approving exactly its entries, a moved plan offered again, «Nada» and strangers; reminders offered (past ones dropped, the calendar button only when set), posted once at their time, late after a restart, listed and cancelled, expiring, the calendar button approving that one addition and no other time, only for the person answered, and the block parsed strictly | -| `test_answer_scope.py` | with `answer_scope: all` a group text without a mention from her or an admin starts a turn marked `[no mention]` (a reply to someone else's message named), a burst of them is one turn and a reaction-only answer sends no text and reacts on the last, one arriving mid-turn joins the running turn, a bot (itself, one listed as admin) and a stranger never start one and reach the next turn as `[not to you]` context, a stranger named like her stays `[not to you]` beside a household `[no mention]` line, her plain text with `read_all` is a `[no mention]` line, all mode renders principle 1 scoped, a failed or busy turn of only `[no mention]` lines posts nothing and keeps them pending while one with an addressed line still posts `ai_down`, `NO_REPLY` with punctuation, quotes, brackets, an escape or a code fence around it is silence while marks that do not wrap it stay, a bare mention is no `[no mention]` line, `NO_REPLY` sends nothing, posts no error, keeps the conversation and is not sent again, and is cut out of any answer it appears in, an edit of an answered line asks to check the answer and one answered with a reaction goes again as one row, `asked` keeps `[not to you]` and answers nothing, no assistant answers nothing, `bad_answer_scope`, and the all-mode rules | +| `test_answer_scope.py` | with `answer_scope: all` a group text without a mention from her or an admin starts a turn marked `[no mention]` (a reply to someone else's message named), a burst of them is one turn and a reaction-only answer sends no text and reacts on every covered message, a line handed over mid-turn too, one arriving mid-turn joins the running turn, a bot (itself, one listed as admin) and a stranger never start one and reach the next turn as `[not to you]` context, a stranger named like her stays `[not to you]` beside a household `[no mention]` line, her plain text with `read_all` is a `[no mention]` line, all mode renders principle 1 scoped, a failed or busy turn of only `[no mention]` lines posts nothing and keeps them pending while one with an addressed line still posts `ai_down`, `NO_REPLY` with punctuation, quotes, brackets, an escape or a code fence around it is silence while marks that do not wrap it stay, a bare mention is no `[no mention]` line, `NO_REPLY` sends nothing, posts no error, keeps the conversation and is not sent again, and is cut out of any answer it appears in, an edit of an answered line asks to check the answer and one answered with a reaction goes again as one row, `asked` keeps `[not to you]` and answers nothing, no assistant answers nothing, `bad_answer_scope`, and the all-mode rules; never silent: `NO_REPLY`, an empty answer, an empty gestures block, choices without text and a record that changed nothing put 👀 on every covered message (handed-over ones too), a text answer or an «Anotado:» adds none, a refused 👀 tries 👍 once and never posts text, a turn with a reply to the bot that shows nothing posts `send_failed` to it and 👀 only on the `[no mention]` lines, a failed or busy turn reacts nothing and its lines go with the next, `asked` and private chats never get 👀, and the rules say `NO_REPLY` marks the messages read | | `test_i18n.py`, `test_privacy.py` | sections 7 and 11 | Each CI gate (ruff, pytest) is shown failing once on a mutated line before it is trusted. diff --git a/supercycletracker/__init__.py b/supercycletracker/__init__.py index 6b49521..c260b0f 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.8.0" +__version__ = "0.9.0" diff --git a/supercycletracker/assistant.py b/supercycletracker/assistant.py index 2076577..99de02b 100644 --- a/supercycletracker/assistant.py +++ b/supercycletracker/assistant.py @@ -59,10 +59,11 @@ REMINDERS_MAX, REMINDER_CHARS = 5, 300 # reminders an answer may offer _REMINDERS = re.compile(r"\n?```reminders[^\S\n]*\n(.*?)```[^\S\n]*", re.DOTALL) _WHEN = re.compile(r"\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}") -# What the assistant writes, alone, when an answer should send nothing at all (`answer_scope: all`, -# talk between people with nothing for it). A provider that fails a turn with no text (AIDriver ends -# one as `no_output`) still gets text; the app drops the word wherever it stands, so it never -# reaches the chat, and an answer left empty sends nothing and still counts as answered. +# What the assistant writes, alone, when an answer needs no words (`answer_scope: all`, talk between +# people with nothing for it). A provider that fails a turn with no text (AIDriver ends one as +# `no_output`) still gets text; the app drops the word wherever it stands, so it never reaches the +# chat. An answer left empty still counts as answered, and in the group the bot then puts 👀 +# («read») on every message of the turn, so nobody's message is left with nothing. NO_REPLY = "NO_REPLY" _NO_REPLY = re.compile( r"(? str: def drop_no_reply(text: str) -> str: """`text` without the NO_REPLY word (and the formatting around it), stripped: empty when nothing but punctuation, quotes, brackets or an empty code fence is left around the word, so - the app sends nothing.""" + the answer has no text.""" if not _NO_REPLY.search(text): return text rest = _EMPTY_FENCE.sub("", _NO_REPLY.sub("", text)) diff --git a/supercycletracker/bot.py b/supercycletracker/bot.py index 31792d2..43d776d 100644 --- a/supercycletracker/bot.py +++ b/supercycletracker/bot.py @@ -128,6 +128,8 @@ LOG_KEEP_DAYS = 7 # the chat log keeps at least this much, for the context lines of a turn NOT_TO_YOU = "[not to you] " # the mark of a group line that did not call the bot NO_MENTION = "[no mention] " # the same mark with `answer_scope: all`, where such a line is for it +READ = "👀" # `answer_scope: all`: put on every message a turn read when it showed nothing +NOTED = "👍" # tried once on a message whose chat refuses another reaction OFFER_GONE = "remind_timeout" # the label an offer's buttons give way to when nobody answered REMIND_TTL_S = 1800 # an offered reminder can be tapped this long; then nothing is done REMIND_DAYS = 60 # the furthest ahead a reminder may be @@ -482,6 +484,25 @@ def _mid(line: Mapping) -> int | None: return line["id"] if isinstance(line.get("id"), int) else None +def _people(lines: Sequence[Mapping]) -> list[int]: + """The people's messages a turn's lines are: a text, voice note, photo or file (its own id) + and an edit (the message edited). A tap is none: its message is the bot's.""" + mids = [_mid(x) for x in lines if not x.get("tap")] + return list(dict.fromkeys(m for m in mids if isinstance(m, int))) + + +def _asking(lines: Sequence[Mapping]) -> list[int]: + """The messages among `lines` that called the bot (a mention, a reply to it, a voice note, a + photo or file, her text when the bot reads the group): they always get words.""" + return [ + x["id"] + for x in lines + if isinstance(x.get("id"), int) + and not x.get("tap") + and not str(x.get("content", "")).startswith(NO_MENTION) + ] + + def _unmentioned_only(lines: Sequence[Mapping]) -> bool: """Every line is household talk without a mention (`answer_scope: all`).""" return bool(lines) and all(str(x.get("content", "")).startswith(NO_MENTION) for x in lines) @@ -2089,12 +2110,17 @@ def _converse(self, chat: int, key: str, lines: list[dict], state: Turn) -> None if text: self.kv.add_chat(chat, "assistant", None, text, int(self.now().timestamp())) others = block is not None or settings or offered or listed # each says something - self.act(chat, acts, _mid(covered[-1]), bool(text or others), reply_to) + # `answer_scope: all` in the group: a person's message is never left with nothing shown + people = _people(covered) if self.answer_all and chat == self.chat_id else [] + everyone = people if people and not text else None # a reaction alone goes on each + shown = self.act(chat, acts, _mid(covered[-1]), bool(text or others), reply_to, everyone) + shown = shown or bool(text) if block is not None: source = "admin" if all(x.get("admin") for x in covered) else "telegram" - self.apply_block(chat, block, source, today) + shown = self.apply_block(chat, block, source, today) or shown if settings is not None: self.apply_settings(chat, settings) + shown = True # it says what changed, or why not if text: self.remember(chat, covered, text) # last, so a crash before here sends the lines again; a block applied twice changes @@ -2104,7 +2130,9 @@ def _converse(self, chat: int, key: str, lines: list[dict], state: Turn) -> None if seen: # the context lines went with a turn that was kept: not again self.kv.set(f"ctx:{chat}", str(max(r["id"] for r in seen))) if listed: - self.offer_reminders(chat, listed, covered, reply_to) + shown = self.offer_reminders(chat, listed, covered, reply_to) or shown + if people and not shown: + self.mark_read(chat, covered, people) missed = len(state.pulled) - len(handed) if handed or missed: log.info("handed over: covered=%d next_turn=%d", len(handed), missed) @@ -2113,6 +2141,41 @@ def _converse(self, chat: int, key: str, lines: list[dict], state: Turn) -> None self.later.submit(lambda: self.run_later(chat, job)) log.info("later queued") + def mark_read(self, chat: int, covered: Sequence[Mapping], people: Sequence[int]) -> None: + """`answer_scope: all`: a turn that showed nothing (`NO_REPLY`, an empty answer, a record + that changed nothing, buttons with no text to carry them) puts 👀 on every person's + message it covered, «read», never a text. A message that called the bot gets words: the + answer failed it, so «send_failed» as a reply to the newest such message, and no 👀.""" + asking = _asking(covered) + if asking: + note = {"message_id": asking[-1], "allow_sending_without_reply": True} + self.post(chat, html.escape(self.t("send_failed")), reply_parameters=note) + log.info("ai turn showed nothing for an addressed message: send_failed") + read = [m for m in people if m not in asking] + if read: + self.react(chat, read, READ) + log.info("ai turn showed nothing: read mark on %d", len(read)) + + def react(self, chat: int, mids: Sequence[int], emoji: str) -> int: + """Put `emoji` on each message of `mids`. Where Telegram refuses it (the chat's allowed + reactions, a deleted message) 👍 is tried once instead, then the refusal is logged and + the next message goes on. How many messages got a reaction.""" + done = 0 + for mid in mids: + for one in dict.fromkeys((emoji, NOTED)): + try: + self.tg.call( + "setMessageReaction", + chat_id=chat, + message_id=mid, + reaction=[{"type": "emoji", "emoji": one}], + ) + done += 1 + break + except TelegramError as e: + log.info("reaction failed: %s", safe(e)) + return done + def tell_down(self, chat: int, lines: Sequence[Mapping]) -> None: """A failed turn: «ai_down» with the keyboard. Not when every line is household talk without a mention (`answer_scope: all`): an error in the middle of their conversation @@ -2725,10 +2788,11 @@ def rekey(self, chat: int, message_id: object, rows: list) -> None: def offer_reminders( self, chat: int, listed: Sequence[Mapping], covered: Sequence[Mapping], reply_to - ) -> None: + ) -> bool: """One message with the reminders an answer listed, buttons for each. Nothing happens until a tap; untapped, the offer expires (REMIND_TTL_S) and nothing is done. A reminder - is for the person the answer was for (her, when it was for several).""" + is for the person the answer was for (her, when it was for several). True when the + offer was posted.""" now = self.now().timestamp() items = [] for r in listed: @@ -2739,7 +2803,7 @@ def offer_reminders( if now + 60 <= at.timestamp() <= now + REMIND_DAYS * 86400: items.append({"at": at.timestamp(), "text": r["text"], "done": None}) if not items: - return + return False users = _users(covered) uid = users[0] if len(users) == 1 else self.profile.user_id many = len(items) > 1 @@ -2761,10 +2825,11 @@ def offer_reminders( text = self.t("remind_offer" if name else "remind_offer_nobody", name=name, lines=lines) sent = self.post(chat, text, _keyboard(self.remind_rows(offer)), reply_parameters=where) if sent is None: - return + return False offer["msg"] = sent.get("message_id") self.keep_offer(chat, offer) log.info("reminders offered n=%d", len(items)) + return True def tapped_remind(self, cq: Mapping, chat: int, data: str, user: object, role: str) -> None: """Where an offered reminder goes: here (stored, posted at its time), in the calendar (a @@ -3299,15 +3364,23 @@ def say( return True def act( - self, chat: int, acts: Mapping, to: int | None, texted: bool, reply_to: int | None = None - ) -> None: + self, + chat: int, + acts: Mapping, + to: int | None, + texted: bool, + reply_to: int | None = None, + everyone: Sequence[int] | None = None, + ) -> bool: """What the answer's gestures block asks for (gestures.py), each on its own after the text, so a file or a reaction Telegram refuses never loses the answer. A reaction goes on message `to`. When the answer said nothing and none of it reached the chat (no message to react to, a refusal, no sticker set), the emoji is said instead, or that it could not be - sent, so the chat always hears back.""" + sent, so the chat always hears back. `everyone` (`answer_scope: all`, an answer with no + text): the reaction goes on each of those messages, 👍 where one refuses it. True when + something reached the chat.""" if not any(acts.values()): - return + return False shown = texted for doc in acts["documents"]: try: @@ -3335,7 +3408,10 @@ def act( shown = True except TelegramError as e: log.warning("%s failed: %s", method, safe(e)) - if acts["react"] and to is not None: + if acts["react"] and everyone: + if self.react(chat, everyone, acts["react"]): + return True + elif acts["react"] and to is not None: try: self.tg.call( "setMessageReaction", @@ -3343,7 +3419,7 @@ def act( message_id=to, reaction=[{"type": "emoji", "emoji": acts["react"]}], ) - return + return True except TelegramError as e: log.info("reaction failed: %s", safe(e)) if not shown: @@ -3353,6 +3429,7 @@ def act( said = acts["react"] or acts["sticker"] text = html.escape(said) if said else html.escape(self.t("send_failed")) self.post(chat, text, reply_parameters=note) + return True def sticker(self, emoji: str | None) -> str | None: """The file_id of the profile's sticker set for `emoji`, read once per set, or None.""" @@ -3367,9 +3444,10 @@ def sticker(self, emoji: str | None) -> str | None: return None return gestures.sticker_for(self._stickers.get("set"), emoji) - def apply_block(self, chat: int, block: object, source: str, today: date) -> None: + def apply_block(self, chat: int, block: object, source: str, today: date) -> bool: """Validate and write a record block, all or nothing, then «Anotado:» from the - `Change` (none when nothing changed), the closing line and the pain red flag.""" + `Change` (none when nothing changed), the closing line and the pain red flag. True when + it posted something.""" try: if block is assistant.BAD_BLOCK: raise BlockError("bad_json") @@ -3378,15 +3456,16 @@ def apply_block(self, chat: int, block: object, source: str, today: date) -> Non except BlockError as e: log.warning("block refused: %s", e.code) self.post(chat, self.t("invalid_block"), self.today_keyboard()) - return + return True except (ValueError, OSError) as e: # an unreadable log, or the disk refused the write log.warning("block failed: %s", safe(e)) self.post(chat, self.t("invalid_block"), self.today_keyboard()) - return + return True text = self.recorded_text(change) if text: self.post(chat, text) - self.announce(change, chat) + self.announce(change, chat) # it posts only on a change, which «Anotado:» shows + return bool(text) # --- settings ------------------------------------------------------------------------ diff --git a/tests/test_answer_scope.py b/tests/test_answer_scope.py index 780f626..0b6c5f8 100644 --- a/tests/test_answer_scope.py +++ b/tests/test_answer_scope.py @@ -45,6 +45,38 @@ def sent(bot): return bot.tg.of("sendMessage") +def reactions(bot): + return [(r["message_id"], r["reaction"][0]["emoji"]) for r in bot.tg.of("setMessageReaction")] + + +def addressed(text, user=HER): + """A reply to the bot's question: a message that called it.""" + msg = message(text, user=user) + msg["reply_to_message"] = {"message_id": 1, "from": {"id": BOT_ID}, "text": QUESTION} + return msg + + +def burst(bot, first, *rest, reply=None, during=None): + """`first`, then each of `rest` a second apart within the debounce: one turn. `during` + arrives while that turn runs and is handed over (and covered) when `reply` is a str.""" + bot.debounce_s = 2.5 + fake_time(bot, {1.0 + n: (lambda m=m: bot.handle({"message": m})) for n, m in enumerate(rest)}) + mod = plugin(bot) + if during is not None: + + def handover(turn): + bot.handle({"message": during}) + got = turn["incoming"]() + return {"text": reply, "conversation": "c", "kept": True, "covers": [got[0]["id"]]} + + mod.REPLIES[:] = [handover] + elif reply is not None: + mod.REPLIES[:] = [reply] + bot.handle({"message": first}) + bot.join_idle() + return mod.CALLS + + def test_an_unmentioned_line_starts_a_turn_marked_no_mention(make_bot): # MUT: drop the `unmentioned` fallback in on_message → no call bot = make_bot(profile=ALL) @@ -71,14 +103,15 @@ def test_her_unmentioned_line_and_a_reply_to_someone_else_are_marked(make_bot): ] -def test_a_burst_of_unmentioned_lines_is_one_turn_and_a_reaction_goes_on_the_last(make_bot): - # MUT: `_mid(covered[0])` instead of `_mid(covered[-1])` in the act call → reaction on the first +def test_a_burst_of_unmentioned_lines_is_one_turn_and_a_reaction_goes_on_each(make_bot): + # MUT: pass `everyone=None` to act in _converse → the reaction goes on the last only bot = make_bot(profile=ALL) bot.debounce_s = 2.5 second = message("y he sacado 100 g de merluza", user=HER) fake_time(bot, {1.0: lambda: bot.handle({"message": second})}) plugin(bot).REPLIES[:] = [REACT] - bot.handle({"message": message("quedan 10 raciones de lasaña")}) + first = message("quedan 10 raciones de lasaña") + bot.handle({"message": first}) bot.join_idle() calls = plugin(bot).CALLS assert len(calls) == 1 @@ -87,9 +120,7 @@ def test_a_burst_of_unmentioned_lines_is_one_turn_and_a_reaction_goes_on_the_las "[no mention] Lucía: y he sacado 100 g de merluza", ] assert sent(bot) == [] # a reaction only: no text - [r] = bot.tg.of("setMessageReaction") - assert r["message_id"] == second["message_id"] - assert r["reaction"] == [{"type": "emoji", "emoji": "👍"}] + assert reactions(bot) == [(first["message_id"], "👍"), (second["message_id"], "👍")] assert bot.kv.get_json(f"pending:{GROUP}") is None @@ -134,15 +165,15 @@ def test_bots_and_strangers_never_start_a_turn_and_stay_context_marked_not_to_yo ] -def test_no_reply_sends_nothing_raises_nothing_and_counts_as_answered(make_bot): +def test_no_reply_sends_no_text_marks_read_and_counts_as_answered(make_bot): # MUT: drop `drop_no_reply` in _converse → "NO_REPLY" is sent to the chat bot = make_bot(profile=ALL) mod = plugin(bot) mod.REPLIES[:] = [{"text": "NO_REPLY", "conversation": "c1", "kept": True}] - group_text(bot, "¿vienes a cenar, Lucía?") + msg = group_text(bot, "¿vienes a cenar, Lucía?") assert len(mod.CALLS) == 1 assert sent(bot) == [] # no text, no «ai_down», no «try again» - assert bot.tg.of("setMessageReaction") == [] + assert reactions(bot) == [(msg["message_id"], "👀")] # read assert bot.kv.get_json(f"pending:{GROUP}") is None # handled: not sent again assert bot.kv.get(f"conv:{GROUP}") == "c1" # the conversation keeps the turn group_text(bot, "quedan 10 raciones") @@ -355,3 +386,175 @@ def test_a_mention_alone_is_not_an_unmentioned_line(make_bot): bot.handle({"message": msg}) bot.join_idle() assert plugin(bot).CALLS == [] and sent(bot) == [] + + +# --- never silent: with answer_scope all, a person's message always shows something ------------- + + +def test_a_reaction_only_answer_goes_on_a_line_that_joined_mid_turn_too(make_bot): + # MUT: `everyone = None` in _converse → only the last covered message gets the reaction + bot = make_bot(profile=ALL) + a, b, late = message("quedan 10 raciones"), message("y 2 yogures", user=HER), message("y pan") + calls = burst(bot, a, b, reply=REACT, during=late) + assert len(calls) == 1 and sent(bot) == [] + ids = [a["message_id"], b["message_id"], late["message_id"]] + assert reactions(bot) == [(m, "👍") for m in ids] + assert bot.kv.get_json(f"pending:{GROUP}") is None + + +def test_no_reply_marks_every_covered_message_read(make_bot): + # MUT: drop `self.mark_read(...)` in _converse → nothing shows under any of them + bot = make_bot(profile=ALL) + a, b, late = message("¿vienes a cenar?"), message("sí, a las 9", user=HER), message("vale") + calls = burst(bot, a, b, reply="NO_REPLY", during=late) + assert len(calls) == 1 and sent(bot) == [] + ids = [a["message_id"], b["message_id"], late["message_id"]] + assert reactions(bot) == [(m, "👀") for m in ids] + assert bot.kv.get_json(f"pending:{GROUP}") is None + + +@pytest.mark.parametrize( + "answer", + [ + {"text": "", "conversation": "c", "kept": True}, # the private plugin's «(sin respuesta)» + "", # a plugin that returns an empty string + "```telegram\n```", # an empty gestures block + "```options\nSí\nNo\n```", # buttons with no text to carry them + ], +) +def test_every_answer_that_would_show_nothing_marks_the_lines_read(make_bot, answer): + # MUT: `if people and shown:` in _converse → the empty answer leaves them with nothing + bot = make_bot(profile=ALL) + plugin(bot).REPLIES[:] = [answer] + msg = group_text(bot, "¿compras pan?") + assert sent(bot) == [] + assert reactions(bot) == [(msg["message_id"], "👀")] + + +def test_a_record_that_changes_nothing_and_says_nothing_marks_the_line_read(make_bot): + # MUT: `return True` at the end of apply_block → the unchanged record counts as shown + from test_bot import record + + bot = make_bot(profile=ALL) + before = bot.repo.log_path.read_bytes() + plugin(bot).REPLIES[:] = [record("", {"date": "2027-03-03", "flow": "light"})] + msg = group_text(bot, "el miércoles poco", user=HER) + assert bot.repo.log_path.read_bytes() == before # the day already said so + assert sent(bot) == [] + assert reactions(bot) == [(msg["message_id"], "👀")] + + +def test_a_record_that_changes_something_shows_anotado_and_no_read_mark(make_bot): + from test_bot import record + + bot = make_bot(profile=ALL) + plugin(bot).REPLIES[:] = [record("", {"date": "2027-03-04", "flow": "heavy"})] + group_text(bot, "hoy mucho", user=HER) + assert bot.tg.texts()[0].startswith("Anotado:") + assert reactions(bot) == [] + + +def test_a_text_answer_adds_no_reactions(make_bot): + # MUT: call `self.mark_read(...)` whatever was shown → 👀 under a worded answer + bot = make_bot(profile=ALL) + a, b = message("quedan 10 raciones"), message("y 2 yogures", user=HER) + burst(bot, a, b, reply="Anotado todo.") + assert bot.tg.texts() == ["Anotado todo."] + assert reactions(bot) == [] + + +def test_a_refused_read_mark_falls_back_to_a_thumb_once_and_never_to_text(make_bot): + # MUT: drop NOTED from `react`'s tries → the refused message gets no reaction at all + bot = make_bot(profile=ALL) + a, b = message("¿vienes a cenar?"), message("sí", user=HER) + bot.tg.fail_next("setMessageReaction", 400, "Bad Request: REACTION_INVALID") + bot.tg.fail_next("setMessageReaction", 400, "Bad Request: REACTION_INVALID") + bot.tg.fail_next("setMessageReaction", 400, "Bad Request: REACTION_INVALID") + burst(bot, a, b, reply="NO_REPLY") + # a: 👀 refused, 👍 refused, then left alone; b: 👀 refused, 👍 taken + assert reactions(bot) == [ + (a["message_id"], "👀"), + (a["message_id"], "👍"), + (b["message_id"], "👀"), + (b["message_id"], "👍"), + ] + assert sent(bot) == [] # never a text instead of 👀 + + +def test_a_mention_never_gets_only_a_read_mark(make_bot): + # MUT: drop the `asking` branch in mark_read → the reply to the bot gets only 👀 + bot = make_bot(profile=ALL) + chat, asked = message("¿vienes a cenar?"), addressed("¿me ha bajado?") + burst(bot, chat, asked, reply="NO_REPLY") + [said] = sent(bot) + assert said["text"] == bot.t("send_failed") + assert said["reply_parameters"]["message_id"] == asked["message_id"] + assert reactions(bot) == [(chat["message_id"], "👀")] # the line without a mention is read + assert bot.kv.get_json(f"pending:{GROUP}") is None + + +def test_a_mention_answered_with_words_gets_no_read_mark(make_bot): + bot = make_bot(profile=ALL) + burst(bot, message("¿vienes a cenar?"), addressed("¿me ha bajado?"), reply="Aún no.") + assert bot.tg.texts() == ["Aún no."] and reactions(bot) == [] + + +def test_a_failed_turn_marks_nothing_and_its_lines_go_with_the_next(make_bot): + # MUT: delete `pending:` before tell_down on an exception → the next turn lacks them + bot = make_bot(profile=ALL) + mod = plugin(bot) + mod.REPLIES[:] = [ + RuntimeError("down"), + {"text": "x", "conversation": None, "kept": False}, + "NO_REPLY", + ] + a = group_text(bot, "¿vienes a cenar?") + b = group_text(bot, "sí", user=HER) + assert reactions(bot) == [] and sent(bot) == [] # nothing was read, nothing said + c = group_text(bot, "vale") + assert contents(mod.CALLS[2]) == [ + "[no mention] Marta: ¿vienes a cenar?", + "[no mention] Lucía: sí", + "[no mention] Marta: vale", + ] + assert reactions(bot) == [(m["message_id"], "👀") for m in (a, b, c)] + assert bot.kv.get_json(f"pending:{GROUP}") is None + + +def test_a_busy_turn_marks_nothing(make_bot): + from test_turns import busy + + bot = make_bot(profile=ALL) + plugin(bot).REPLIES[:] = [busy(600)] + group_text(bot, "¿vienes a cenar?") + assert reactions(bot) == [] and sent(bot) == [] + + +def test_asked_mode_never_marks_read_and_reacts_on_the_last_only(make_bot): + # MUT: drop `self.answer_all and` from `people` in _converse → asked mode gets 👀 too + bot = make_bot() + plugin(bot).REPLIES[:] = ["NO_REPLY"] + say(bot, "hoy poco", reply_to=QUESTION) + assert sent(bot) == [] and reactions(bot) == [] # as in 0.8.0: nothing at all + a, b = addressed("hoy poco"), addressed("y dolor 3") + burst(bot, a, b, reply=REACT) + assert reactions(bot) == [(b["message_id"], "👍")] + + +def test_a_private_chat_in_all_mode_gets_no_read_mark(make_bot): + # MUT: drop `chat == self.chat_id` from `people` in _converse → «send_failed» in private + bot = make_bot(profile=ALL) + plugin(bot).REPLIES[:] = ["NO_REPLY"] + say(bot, "hola", user=ADMIN, chat=ADMIN, private=True) + assert len(plugin(bot).CALLS) == 1 + assert reactions(bot) == [] and sent(bot) == [] # as in 0.8.0 + + +def test_the_all_rules_say_no_reply_marks_the_messages_read(): + text = " ".join(assistant.rules("Lucía", "es", ["Marta"], answer_all=True).split()) + assert "write only NO_REPLY: the bot then marks those messages with 👀 (read)" in text + assert "never need words just to say you saw something" in text + assert "a 👍 says you noted it" in text + assert "sends nothing" not in text and "gets nothing" not in text + asked = assistant.rules("Lucía", "es", ["Marta"]) + assert "👀" not in asked.split("You are the cycle companion", 1)[1].split("telegram")[0] From 9050cfc27c37591d389a0030be1afd7c1f4b04cd Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:37:42 +0100 Subject: [PATCH 2/3] =?UTF-8?q?fix(bot):=20a=20silent=20sticker=20or=20pho?= =?UTF-8?q?to=20gets=20=F0=9F=91=80,=20not=20a=20false=20error,=20and=20a?= =?UTF-8?q?=20failed=20gesture=20never=20counts=20as=20shown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file without a mention no longer counts as a message that called the bot, so silence on it is marked read. A mention, reply or voice note left without words gets an honest read_no_answer instead of send_failed, and the rules tell the model those always get words. act now reports only what reached the chat, so an unchanged record beside a failed sticker gets 👀. --- AGENTS.md | 2 +- docs/DESIGN.md | 12 ++-- supercycletracker/assistant.py | 2 +- supercycletracker/bot.py | 49 +++++++++----- supercycletracker/locales/en.yaml | 1 + supercycletracker/locales/es.yaml | 1 + tests/test_answer_scope.py | 103 +++++++++++++++++++++++++++++- tests/test_privacy.py | 4 +- 8 files changed, 147 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 12df138..0a0cb6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Run the test suite once per change. It drives real git repositories in temp dirs - This is health data. Never log personal data or message contents, and never write a record anywhere except the private data repo the household owns. - Code never knows a person, a chat or a date of a real cycle. Those live in the data repo. Keep every example and test fictional. - Every user-facing string is translatable. English is the source; Spanish ships too, and the bot talks Spanish to its users. -- In the group the bot reads replies to the bot, answers to its questions, commands, and messages that mention it when Telegram delivers them (as a group admin, all of her messages). Voice notes, photos, videos, stickers and files from her or an admin are always processed. It also answers private chats with her or an admin. With `history_scope: all` the group's other messages go to the assistant as context marked `[not to you]`, never answered. With `answer_scope: all` her and the admins' group texts without a mention start a turn like addressed ones, marked `[no mention]` (her plain texts too when the bot reads the whole group; context stays `[not to you]`, and principle 1 is rendered scoped to allow it); the answer may be only a reaction (on every message of the turn), or no words when the assistant writes `NO_REPLY`, which the bot never sends. A person's group message is never left with nothing: a turn that shows nothing puts 👀 («read») on every message it covered (👍 once where 👀 is refused), and a mention or reply that got nothing gets `send_failed`. Bots never start a turn, and a failed turn of only such lines posts no error and no 👀. +- In the group the bot reads replies to the bot, answers to its questions, commands, and messages that mention it when Telegram delivers them (as a group admin, all of her messages). Voice notes, photos, videos, stickers and files from her or an admin are always processed. It also answers private chats with her or an admin. With `history_scope: all` the group's other messages go to the assistant as context marked `[not to you]`, never answered. With `answer_scope: all` her and the admins' group texts without a mention start a turn like addressed ones, marked `[no mention]` (her plain texts too when the bot reads the whole group; context stays `[not to you]`, and principle 1 is rendered scoped to allow it); the answer may be only a reaction (on every message of the turn), or no words when the assistant writes `NO_REPLY`, which the bot never sends. A person's group message is never left with nothing: a turn that shows nothing puts 👀 («read») on every message it covered (👍 once where 👀 is refused), and a mention, reply or voice note that got nothing gets `read_no_answer` (a sticker or photo without a mention gets 👀). Bots never start a turn, and a failed turn of only such lines posts no error and no 👀. - Whatever the bot offers to do (a reminder, calendar entries) waits for a tap; without one nothing happens. - The tracker syncs to a private remote named in `.beads/config.yaml`, never to this public repo. Nothing written here names the server behind it. - Conventional commits. PR titles say why, not what. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7a28c80..dcd4186 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -457,11 +457,15 @@ no settings or reminder message), the bot puts 👀 on every person's message th voice notes, photos and files by their own id, an edit on the message edited; a tap is on the bot's own message and gets none). That covers `NO_REPLY`, an empty answer (the private plugin's own silence word arrives as `""`), an empty `telegram` block, choices with no text to carry them, and a -record block that changed nothing. Where Telegram refuses 👀 on a message (the chat's allowed +record block that changed nothing (a gesture that failed beside it does not count as shown). Where Telegram refuses 👀 on a message (the chat's allowed reactions, a deleted message), the bot tries 👍 once on that message, then logs the refusal and goes on; it never posts a text instead. A message that called the bot (a mention, a reply to it, a -voice note, a photo or file) gets words, never only 👀: when such a turn showed nothing, the bot -posts `send_failed` as a reply to the newest one, and only the turn's `[no mention]` messages get 👀. +command, a voice note, or a photo or file whose caption mentions the bot or that replies to it) +gets words, never only 👀: when such a turn showed nothing, the bot posts `read_no_answer` («Leído. +No tengo nada que añadir.») as a reply to the newest one, and the turn's other messages get 👀. Not +`send_failed`: nothing failed, the assistant chose no words, and the rules tell it a mention, a +reply or a voice note always gets words. A pending line records whether it called the bot (`asks`); +a sticker, GIF or photo without a mention does not, so silence on it gets 👀, not an error. A failed turn (an exception, a timeout, `kept: false`, a busy plugin) puts no 👀, since nothing was read: its lines stay in `pending:` and go with the next turn. Private chats and `asked` keep the 0.8 behaviour: an empty answer shows nothing there. The word @@ -1276,7 +1280,7 @@ joins a group or its abilities change. | `test_turns.py` | section 5.2: one answer per burst, lines handed to a running turn, edits, choices as buttons, asks; a line queued before a restart and a turn killed mid-flight are answered by the next process | | `test_gestures.py` | the renderer (folds, quotes, code with its language, hostile markup never opening a tag), the gestures block on hostile model output (dropped), the HTML send and its plain-text resend, a reaction alone sends no text and a failed one sends the emoji, files and a refused file never losing the text, a file name with no letter named in her language, stickers only from the profile's set, photos only from `photo_hosts`, the voice reaching the rules | | `test_offers.py` | the chat log: group talk goes to the next turn as `[not to you]` context once and is never answered, nothing is kept with `asked`, a failed turn keeps it, the recap leaves out the new lines and reaches back `history_days` only (0: none), edits rewrite the log and one that adds the mention keeps one row, `/nuevo` forgets it; an offer says no answer does nothing and expires into «Sin respuesta: nada»; the rules say who reaches the assistant; the calendar plan's dates (and none once late), its place in `context.md`, its offer once per key in waking hours with configurable titles, one tap approving exactly its entries, a moved plan offered again, «Nada» and strangers; reminders offered (past ones dropped, the calendar button only when set), posted once at their time, late after a restart, listed and cancelled, expiring, the calendar button approving that one addition and no other time, only for the person answered, and the block parsed strictly | -| `test_answer_scope.py` | with `answer_scope: all` a group text without a mention from her or an admin starts a turn marked `[no mention]` (a reply to someone else's message named), a burst of them is one turn and a reaction-only answer sends no text and reacts on every covered message, a line handed over mid-turn too, one arriving mid-turn joins the running turn, a bot (itself, one listed as admin) and a stranger never start one and reach the next turn as `[not to you]` context, a stranger named like her stays `[not to you]` beside a household `[no mention]` line, her plain text with `read_all` is a `[no mention]` line, all mode renders principle 1 scoped, a failed or busy turn of only `[no mention]` lines posts nothing and keeps them pending while one with an addressed line still posts `ai_down`, `NO_REPLY` with punctuation, quotes, brackets, an escape or a code fence around it is silence while marks that do not wrap it stay, a bare mention is no `[no mention]` line, `NO_REPLY` sends nothing, posts no error, keeps the conversation and is not sent again, and is cut out of any answer it appears in, an edit of an answered line asks to check the answer and one answered with a reaction goes again as one row, `asked` keeps `[not to you]` and answers nothing, no assistant answers nothing, `bad_answer_scope`, and the all-mode rules; never silent: `NO_REPLY`, an empty answer, an empty gestures block, choices without text and a record that changed nothing put 👀 on every covered message (handed-over ones too), a text answer or an «Anotado:» adds none, a refused 👀 tries 👍 once and never posts text, a turn with a reply to the bot that shows nothing posts `send_failed` to it and 👀 only on the `[no mention]` lines, a failed or busy turn reacts nothing and its lines go with the next, `asked` and private chats never get 👀, and the rules say `NO_REPLY` marks the messages read | +| `test_answer_scope.py` | with `answer_scope: all` a group text without a mention from her or an admin starts a turn marked `[no mention]` (a reply to someone else's message named), a burst of them is one turn and a reaction-only answer sends no text and reacts on every covered message, a line handed over mid-turn too, one arriving mid-turn joins the running turn, a bot (itself, one listed as admin) and a stranger never start one and reach the next turn as `[not to you]` context, a stranger named like her stays `[not to you]` beside a household `[no mention]` line, her plain text with `read_all` is a `[no mention]` line, all mode renders principle 1 scoped, a failed or busy turn of only `[no mention]` lines posts nothing and keeps them pending while one with an addressed line still posts `ai_down`, `NO_REPLY` with punctuation, quotes, brackets, an escape or a code fence around it is silence while marks that do not wrap it stay, a bare mention is no `[no mention]` line, `NO_REPLY` sends nothing, posts no error, keeps the conversation and is not sent again, and is cut out of any answer it appears in, an edit of an answered line asks to check the answer and one answered with a reaction goes again as one row, `asked` keeps `[not to you]` and answers nothing, no assistant answers nothing, `bad_answer_scope`, and the all-mode rules; never silent: `NO_REPLY`, an empty answer, an empty gestures block, choices without text and a record that changed nothing put 👀 on every covered message (handed-over ones too), a text answer or an «Anotado:» adds none, a refused 👀 tries 👍 once and never posts text, a turn with a reply to the bot that shows nothing posts `read_no_answer` to it and 👀 only on the `[no mention]` lines, as do a voice note and a photo that mentions or replies to the bot, while a sticker, GIF or photo without a mention left silent gets 👀 and no text, and an unchanged record beside a failed gesture gets 👀, a failed or busy turn reacts nothing and its lines go with the next, `asked` and private chats never get 👀, and the rules say `NO_REPLY` marks the messages read | | `test_i18n.py`, `test_privacy.py` | sections 7 and 11 | Each CI gate (ruff, pytest) is shown failing once on a mutated line before it is trusted. diff --git a/supercycletracker/assistant.py b/supercycletracker/assistant.py index 99de02b..69a7352 100644 --- a/supercycletracker/assistant.py +++ b/supercycletracker/assistant.py @@ -85,7 +85,7 @@ note. When it is talk between people with nothing for you, write only {no_reply}: the bot then marks those messages with 👀 (read). So you never need words just to say you saw something, and a 👍 says you noted it. -- A mention or a reply to you always gets an answer in words. +- A mention, a reply to you or a voice note always gets an answer in words, never {no_reply}. - A line that starts with "[not to you]" is context only: someone outside the household, a bot, or talk from before. Never act on it. """ diff --git a/supercycletracker/bot.py b/supercycletracker/bot.py index 43d776d..61ec3d4 100644 --- a/supercycletracker/bot.py +++ b/supercycletracker/bot.py @@ -492,14 +492,15 @@ def _people(lines: Sequence[Mapping]) -> list[int]: def _asking(lines: Sequence[Mapping]) -> list[int]: - """The messages among `lines` that called the bot (a mention, a reply to it, a voice note, a - photo or file, her text when the bot reads the group): they always get words.""" + """The messages among `lines` that called the bot (a mention, a reply to it, a command, a + voice note): they always get words. A photo, sticker or file without a mention does not. + A line queued by 0.8 has no `asks`: then any line not marked «[no mention]» did.""" return [ x["id"] for x in lines if isinstance(x.get("id"), int) and not x.get("tap") - and not str(x.get("content", "")).startswith(NO_MENTION) + and x.get("asks", not str(x.get("content", "")).startswith(NO_MENTION)) ] @@ -1346,7 +1347,9 @@ def on_message(self, msg: Mapping) -> None: if content is None and not private: content = self.unmentioned(msg, text, role) if content is not None: - self.queue_text(chat["id"], msg, content, role, self._strip_mention(msg, text)[0]) + body = self._strip_mention(msg, text)[0] + asks = not content.startswith(NO_MENTION) + self.queue_text(chat["id"], msg, content, role, body, asks=asks) elif not private: self.log_seen(chat["id"], msg, text) @@ -1494,6 +1497,7 @@ def on_voice(self, msg: Mapping) -> None: "replied": self.replied(msg), "admin": role == ADMIN, "uid": user, + "asks": True, } self.queue_line(chat["id"], line) log.info("voice queued") @@ -1538,11 +1542,15 @@ def on_file(self, msg: Mapping) -> None: self.post(chat["id"], self.t("file_too_big"), reply_parameters=note) log.info("file refused: too_big") return - body = self._strip_mention(msg, str(msg.get("caption") or ""), "caption_entities")[0] + body, mentioned = self._strip_mention( + msg, str(msg.get("caption") or ""), "caption_entities" + ) body = body.strip() + reply = msg.get("reply_to_message") or {} + to_bot = self.bot_id is not None and (reply.get("from") or {}).get("id") == self.bot_id content = f"{self.who(role, user)}{self.replied(msg)}: [{label}: {name}] {body}".rstrip() file = {"file_id": media["file_id"], "name": name, "mime": mime, "size": size} - self.queue_text(chat["id"], msg, content, role, body, file=file) + self.queue_text(chat["id"], msg, content, role, body, file=file, asks=mentioned or to_bot) def queue_text( self, @@ -1553,11 +1561,13 @@ def queue_text( body: str | None = None, row: int | None = None, file: dict | None = None, + asks: bool = True, ) -> None: """Append a line to `pending:`, log it in the chat log (or reuse its `row`) and queue the chat for the worker. `body` is the message's own words, kept so an edit can be compared with them; `file` is the photo or file the line names, fetched when its turn - starts. Without an assistant: «no_ai» with today's keyboard.""" + starts; `asks`: the message called the bot (a mention, a reply to it, a command), so it + always gets words. Without an assistant: «no_ai» with today's keyboard.""" if not self.has_assistant: self.post(chat, self.t("no_ai"), self.today_keyboard()) log.info("text without assistant") @@ -1581,6 +1591,7 @@ def queue_text( "admin": role == ADMIN, "uid": user, "row": row, + "asks": asks, } if body is not None: line["body"] = body.strip() @@ -2144,13 +2155,14 @@ def _converse(self, chat: int, key: str, lines: list[dict], state: Turn) -> None def mark_read(self, chat: int, covered: Sequence[Mapping], people: Sequence[int]) -> None: """`answer_scope: all`: a turn that showed nothing (`NO_REPLY`, an empty answer, a record that changed nothing, buttons with no text to carry them) puts 👀 on every person's - message it covered, «read», never a text. A message that called the bot gets words: the - answer failed it, so «send_failed» as a reply to the newest such message, and no 👀.""" + message it covered, «read», never a text. A message that called the bot gets words: + «read_no_answer» as a reply to the newest such message, and no 👀. Not «send_failed»: + nothing failed, the assistant chose no words.""" asking = _asking(covered) if asking: note = {"message_id": asking[-1], "allow_sending_without_reply": True} - self.post(chat, html.escape(self.t("send_failed")), reply_parameters=note) - log.info("ai turn showed nothing for an addressed message: send_failed") + self.post(chat, html.escape(self.t("read_no_answer")), reply_parameters=note) + log.info("ai turn showed nothing for an addressed message: read_no_answer") read = [m for m in people if m not in asking] if read: self.react(chat, read, READ) @@ -3188,7 +3200,8 @@ def correct(state=state, line=line, chat_id=chat_id, body=body): if logged is not None: # logged before (seen, or answered long ago): one row self.kv.chat_edit(logged["id"], content, body, asked=True) row = logged["id"] - self.queue_text(chat_id, msg, content, role, body, row) + asks = not content.startswith(NO_MENTION) + self.queue_text(chat_id, msg, content, role, body, row, asks=asks) log.info("edit: new message") elif logged is not None and not logged["asked"]: self.relog(logged, body, name) @@ -3378,10 +3391,11 @@ def act( react to, a refusal, no sticker set), the emoji is said instead, or that it could not be sent, so the chat always hears back. `everyone` (`answer_scope: all`, an answer with no text): the reaction goes on each of those messages, 👍 where one refuses it. True when - something reached the chat.""" + something of the gestures, or the fallback, reached the chat: `texted` (the answer has + text or a block) only spares the fallback, it does not count as shown here.""" if not any(acts.values()): return False - shown = texted + shown = False for doc in acts["documents"]: try: self.tg.send_file( @@ -3422,14 +3436,17 @@ def act( return True except TelegramError as e: log.info("reaction failed: %s", safe(e)) - if not shown: + if shown: + return True + if not texted: note = ( {"message_id": reply_to, "allow_sending_without_reply": True} if reply_to else None ) said = acts["react"] or acts["sticker"] text = html.escape(said) if said else html.escape(self.t("send_failed")) self.post(chat, text, reply_parameters=note) - return True + return True + return False def sticker(self, emoji: str | None) -> str | None: """The file_id of the profile's sticker set for `emoji`, read once per set, or None.""" diff --git a/supercycletracker/locales/en.yaml b/supercycletracker/locales/en.yaml index 3e09f44..458eac9 100644 --- a/supercycletracker/locales/en.yaml +++ b/supercycletracker/locales/en.yaml @@ -36,6 +36,7 @@ invalid_block: "I could not note that properly, so I saved nothing. Can you tell ai_down: "Right now I cannot read written messages. You can note it with the buttons, and if you write it again later I will read it." file_name: "file" # a file the assistant sends whose name has no letter left send_failed: "I could not send you what I had. Can you ask me again?" +read_no_answer: "Read. I have nothing to add." # a mention or reply the assistant left with no words ai_busy: "I can't answer right now: I have no capacity left for {wait}. I'll answer everything then, with no need to repeat anything. Meanwhile you can note it with the buttons." busy_minutes: "about {n} minutes" busy_hours: "about {n} hours" diff --git a/supercycletracker/locales/es.yaml b/supercycletracker/locales/es.yaml index 675edc7..a3620d3 100644 --- a/supercycletracker/locales/es.yaml +++ b/supercycletracker/locales/es.yaml @@ -34,6 +34,7 @@ invalid_block: "No he sabido anotarlo bien, así que no he guardado nada. ¿Me l ai_down: "Ahora mismo no puedo leer mensajes escritos. Puedes anotarlo con los botones y, si me lo vuelves a escribir más tarde, lo leo." file_name: "archivo" # a file the assistant sends whose name has no letter left send_failed: "No he podido enviarte lo que tenía. ¿Me lo pides otra vez?" +read_no_answer: "Leído. No tengo nada que añadir." # a mention or reply the assistant left with no words ai_busy: "Ahora no puedo contestar: no me queda capacidad hasta dentro de {wait}. Entonces te contesto a todo, sin que haga falta repetir nada. Mientras, puedes anotarlo con los botones." busy_minutes: "unos {n} minutos" busy_hours: "unas {n} horas" diff --git a/tests/test_answer_scope.py b/tests/test_answer_scope.py index 0b6c5f8..d9cab1f 100644 --- a/tests/test_answer_scope.py +++ b/tests/test_answer_scope.py @@ -258,7 +258,7 @@ def test_the_all_rules_say_how_to_answer_a_line_without_a_mention(): assert "every message Lucía or Marta writes reaches you, mention or not" in text assert "answer with only a reaction (react: 👍" in text assert "write only NO_REPLY" in text - assert "A mention or a reply to you always gets an answer in words." in text + assert "A mention, a reply to you or a voice note always gets an answer in words, never" in text assert '"[not to you]" is context only' in text assert "and are context only" not in text # the asked-mode lines are gone assert assistant.drop_no_reply("NO_REPLY") == "" @@ -487,7 +487,8 @@ def test_a_mention_never_gets_only_a_read_mark(make_bot): chat, asked = message("¿vienes a cenar?"), addressed("¿me ha bajado?") burst(bot, chat, asked, reply="NO_REPLY") [said] = sent(bot) - assert said["text"] == bot.t("send_failed") + # MUT: post «send_failed» in mark_read → a false error for a silence the assistant chose + assert said["text"] == bot.t("read_no_answer") != bot.t("send_failed") assert said["reply_parameters"]["message_id"] == asked["message_id"] assert reactions(bot) == [(chat["message_id"], "👀")] # the line without a mention is read assert bot.kv.get_json(f"pending:{GROUP}") is None @@ -542,7 +543,7 @@ def test_asked_mode_never_marks_read_and_reacts_on_the_last_only(make_bot): def test_a_private_chat_in_all_mode_gets_no_read_mark(make_bot): - # MUT: drop `chat == self.chat_id` from `people` in _converse → «send_failed» in private + # MUT: drop `chat == self.chat_id` from `people` in _converse → «read_no_answer» in private bot = make_bot(profile=ALL) plugin(bot).REPLIES[:] = ["NO_REPLY"] say(bot, "hola", user=ADMIN, chat=ADMIN, private=True) @@ -558,3 +559,99 @@ def test_the_all_rules_say_no_reply_marks_the_messages_read(): assert "sends nothing" not in text and "gets nothing" not in text asked = assistant.rules("Lucía", "es", ["Marta"]) assert "👀" not in asked.split("You are the cycle companion", 1)[1].split("telegram")[0] + + +def media(kind, user=HER, caption=None, entities=None, reply_to_bot=False): + """A sticker, GIF or photo in the group; `entities` go on its caption.""" + extra = ( + {"photo": [{"file_id": "m1", "file_size": 3}]} + if kind == "photo" + else {kind: {"file_id": "m1", "is_animated": False, "is_video": False}} + ) + msg = { + "message_id": next(_mids), + "from": {"id": user}, + "chat": {"id": GROUP, "type": "supergroup"}, + **extra, + } + if caption is not None: + msg["caption"] = caption + if entities is not None: + msg["caption_entities"] = entities + if reply_to_bot: + msg["reply_to_message"] = {"message_id": 1, "from": {"id": BOT_ID}, "text": QUESTION} + return msg + + +@pytest.mark.parametrize("kind", ["sticker", "animation", "photo"]) +def test_a_sticker_gif_or_photo_left_without_words_is_marked_read_not_an_error(make_bot, kind): + # MUT: `asks=True` for every file in on_file → «read_no_answer» under a sticker + bot = make_bot(profile=ALL) + bot.tg.files["m1"] = b"fictional media" + plugin(bot).REPLIES[:] = [{"text": "", "conversation": "c", "kept": True}] + msg = media(kind) + bot.handle({"message": msg}) + bot.join_idle() + assert len(plugin(bot).CALLS) == 1 + assert sent(bot) == [] + assert reactions(bot) == [(msg["message_id"], "👀")] + + +@pytest.mark.parametrize("how", ["mention", "reply"]) +def test_a_photo_that_calls_the_bot_still_gets_words(make_bot, how): + # MUT: `asks=False` for every file in on_file → the photo that asked gets only 👀 + bot = make_bot(profile=ALL) + bot.tg.files["m1"] = b"fictional media" + plugin(bot).REPLIES[:] = ["NO_REPLY"] + if how == "mention": + mention = [{"type": "mention", "offset": 0, "length": 18}] + msg = media("photo", caption="@example_cycle_bot mira", entities=mention) + else: + msg = media("photo", caption="mira", reply_to_bot=True) + bot.handle({"message": msg}) + bot.join_idle() + [said] = sent(bot) + assert said["text"] == bot.t("read_no_answer") + assert said["reply_parameters"]["message_id"] == msg["message_id"] + assert reactions(bot) == [] + + +def test_a_voice_note_left_without_words_gets_words(make_bot): + # MUT: `"asks": False` on the voice line → the voice note gets only 👀 + from test_bot import voice + + bot = make_bot(profile=ALL) + bot.tg.files["v1"] = b"OggS fictional" + plugin(bot).VOICES[:] = ["me ha bajado hoy"] + plugin(bot).REPLIES[:] = ["NO_REPLY"] + msg = voice(bot, mime_type="audio/ogg") + echo, said = sent(bot) + assert echo["text"] == "🎤 me ha bajado hoy" + assert said["text"] == bot.t("read_no_answer") + assert said["reply_parameters"]["message_id"] == msg["message_id"] + assert reactions(bot) == [] + + +def test_a_line_queued_by_0_8_without_asks_still_counts_by_its_mark(): + from supercycletracker.bot import _asking + + lines = [ + {"id": 1, "content": "[no mention] Marta: hola"}, + {"id": 2, "content": "Lucía [replying to the bot: ¿Te ha bajado?] sí"}, + {"id": 3, "content": "Lucía: [file: inbox/3-sticker.webp]", "asks": False}, + ] + assert _asking(lines) == [2] + + +def test_an_unchanged_record_with_a_gesture_that_fails_still_marks_the_line_read(make_bot): + # MUT: `shown = texted` at the start of act → the failed sticker counts as shown + from test_bot import record + + bot = make_bot(profile=ALL) + before = bot.repo.log_path.read_bytes() + answer = record("```telegram\nsticker: 🥰\n```", {"date": "2027-03-03", "flow": "light"}) + plugin(bot).REPLIES[:] = [answer] + msg = group_text(bot, "el miércoles poco", user=HER) + assert bot.repo.log_path.read_bytes() == before # the day already said so + assert bot.tg.of("sendSticker") == [] and sent(bot) == [] + assert reactions(bot) == [(msg["message_id"], "👀")] diff --git a/tests/test_privacy.py b/tests/test_privacy.py index db086d4..3e51165 100644 --- a/tests/test_privacy.py +++ b/tests/test_privacy.py @@ -190,9 +190,9 @@ def test_the_service_log_holds_no_words_dates_values_or_names( if planted: # the positive control: one careless line must turn the test red original = Bot.queue_text - def leaky(self, chat, msg, content, role, *rest): + def leaky(self, chat, msg, content, role, *rest, **kw): bot_module.log.info("queued %s", content) - return original(self, chat, msg, content, role, *rest) + return original(self, chat, msg, content, role, *rest, **kw) monkeypatch.setattr(Bot, "queue_text", leaky) caplog.set_level(logging.DEBUG) From c5ce640548ff7d8cf58b43f16e71b4d434e4c202 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:25:04 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix(bot):=20an=20edit=20that=20adds=20a=20m?= =?UTF-8?q?ention=20gets=20words,=20not=20=F0=9F=91=80,=20when=20the=20tur?= =?UTF-8?q?n=20stays=20silent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESIGN.md | 3 ++- supercycletracker/bot.py | 19 +++++++++++-------- tests/test_answer_scope.py | 27 +++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index dcd4186..f2c0750 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -465,7 +465,8 @@ gets words, never only 👀: when such a turn showed nothing, the bot posts `rea No tengo nada que añadir.») as a reply to the newest one, and the turn's other messages get 👀. Not `send_failed`: nothing failed, the assistant chose no words, and the rules tell it a mention, a reply or a voice note always gets words. A pending line records whether it called the bot (`asks`); -a sticker, GIF or photo without a mention does not, so silence on it gets 👀, not an error. +a sticker, GIF or photo without a mention does not, so silence on it gets 👀, not an error. An edit that adds a mention to an answered line counts as calling the bot, on the message it +edits; a plain edit does not. A failed turn (an exception, a timeout, `kept: false`, a busy plugin) puts no 👀, since nothing was read: its lines stay in `pending:` and go with the next turn. Private chats and `asked` keep the 0.8 behaviour: an empty answer shows nothing there. The word diff --git a/supercycletracker/bot.py b/supercycletracker/bot.py index 61ec3d4..e8e38b1 100644 --- a/supercycletracker/bot.py +++ b/supercycletracker/bot.py @@ -493,15 +493,15 @@ def _people(lines: Sequence[Mapping]) -> list[int]: def _asking(lines: Sequence[Mapping]) -> list[int]: """The messages among `lines` that called the bot (a mention, a reply to it, a command, a - voice note): they always get words. A photo, sticker or file without a mention does not. - A line queued by 0.8 has no `asks`: then any line not marked «[no mention]» did.""" - return [ - x["id"] + voice note, an edit that addresses it): they always get words. A photo, sticker or file + without a mention does not. A line queued by 0.8 has no `asks`: then any line not marked + «[no mention]» did. An edit counts as the message it edited.""" + mids = [ + _mid(x) for x in lines - if isinstance(x.get("id"), int) - and not x.get("tap") - and x.get("asks", not str(x.get("content", "")).startswith(NO_MENTION)) + if not x.get("tap") and x.get("asks", not str(x.get("content", "")).startswith(NO_MENTION)) ] + return list(dict.fromkeys(m for m in mids if isinstance(m, int))) def _unmentioned_only(lines: Sequence[Mapping]) -> bool: @@ -3128,12 +3128,15 @@ def on_edit(self, msg: Mapping) -> None: return key = f"pending:{chat_id}" found: list[dict] = [] + calls = self.addressed(msg, text, private, role) is not None # the edit calls the bot def swap(cur): out = [] for x in cur or []: if x.get("id") == mid and "content" in x and "body" in x: found.append(dict(x)) + if calls: + x = {**x, "asks": True} if _words(x["body"]) != _words(body): head = x["content"][: len(x["content"]) - len(x["body"])] if not x["content"].endswith(x["body"]): @@ -3157,7 +3160,7 @@ def swap(cur): self.worker.submit(chat_id) log.info("edit: queued line") return - line = {"mid": mid, "admin": role == ADMIN, "uid": user, "body": body} + line = {"mid": mid, "admin": role == ADMIN, "uid": user, "body": body, "asks": calls} line["ts"] = int(self.now().timestamp()) line["id"] = f"e{mid}.{secrets.token_hex(3)}" if running: diff --git a/tests/test_answer_scope.py b/tests/test_answer_scope.py index d9cab1f..69d34bd 100644 --- a/tests/test_answer_scope.py +++ b/tests/test_answer_scope.py @@ -655,3 +655,30 @@ def test_an_unchanged_record_with_a_gesture_that_fails_still_marks_the_line_read assert bot.repo.log_path.read_bytes() == before # the day already said so assert bot.tg.of("sendSticker") == [] and sent(bot) == [] assert reactions(bot) == [(msg["message_id"], "👀")] + + +def test_an_edit_that_adds_a_mention_to_an_answered_line_gets_words_when_left_silent(make_bot): + # MUT: `"asks": False` on the edit line in on_edit → the edit that calls the bot gets 👀 + bot = make_bot(profile=ALL) + plugin(bot).REPLIES[:] = ["Vale.", "NO_REPLY"] + msg = group_text(bot, "quedan 10 raciones") + mention = [{"type": "mention", "offset": 0, "length": 18}] + text = "@example_cycle_bot quedan 8 raciones" + bot.handle({"edited_message": {**msg, "text": text, "entities": mention}}) + bot.join_idle() + assert len(plugin(bot).CALLS) == 2 + said = sent(bot)[-1] + assert said["text"] == bot.t("read_no_answer") + assert said["reply_parameters"]["message_id"] == msg["message_id"] + assert reactions(bot) == [] + + +def test_a_plain_edit_of_an_answered_line_left_silent_is_marked_read(make_bot): + # MUT: `_asking` keeps the 0.8 rule for edit lines → the plain edit gets «read_no_answer» + bot = make_bot(profile=ALL) + plugin(bot).REPLIES[:] = ["Vale.", "NO_REPLY"] + msg = group_text(bot, "quedan 10 raciones") + bot.handle({"edited_message": {**msg, "text": "quedan 8 raciones"}}) + bot.join_idle() + assert bot.tg.texts() == ["Vale."] + assert reactions(bot) == [(msg["message_id"], "👀")]