From ddc2150a53791de476ac32a2486d2bd53e85056a Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:39:57 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(bot):=20put=20=F0=9F=91=80=20on=20a=20?= =?UTF-8?q?message=20the=20moment=20a=20turn=20starts=20on=20it,=20so=20a?= =?UTF-8?q?=20long=20turn=20never=20looks=20like=20silence=20(0.10.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every person's message a turn covers gets ๐Ÿ‘€ as the turn starts, in every chat, and a line handed over mid-turn gets it when the plugin takes it. It goes on its own thread so the turn never waits, is never put on a tap or twice on a message, and a refusal is only logged. A text answer leaves it, a reaction-only answer replaces it, silence keeps it as ยซreadยป (๐Ÿ‘ once where it was refused), and a failed turn keeps it. --- AGENTS.md | 2 +- README.md | 4 +- docs/DESIGN.md | 67 +++++++----- supercycletracker/__init__.py | 2 +- supercycletracker/assistant.py | 4 +- supercycletracker/bot.py | 102 ++++++++++++++++-- tests/test_answer_scope.py | 71 +++++++------ tests/test_eyes.py | 184 +++++++++++++++++++++++++++++++++ tests/test_gestures.py | 6 +- 9 files changed, 369 insertions(+), 73 deletions(-) create mode 100644 tests/test_eyes.py diff --git a/AGENTS.md b/AGENTS.md index 0a0cb6a..c661c93 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, 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 ๐Ÿ‘€. +- 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. When a turn starts, every person's message it covers gets ๐Ÿ‘€ at once, in every chat (a line handed over mid-turn when it is taken; never a tap or a bot's message, never twice), on its own thread so the turn never waits, with no ๐Ÿ‘ or text if Telegram refuses. A text answer leaves it, a reaction-only answer replaces it, silence keeps it as ยซreadยป (๐Ÿ‘ once where the ๐Ÿ‘€ was refused, in the `all` group), and a mention, reply or voice note that got nothing gets `read_no_answer`. Bots never start a turn, and a failed turn of only `[no mention]` lines posts no error and keeps its ๐Ÿ‘€. - 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 71cd938..c5ddb2b 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ SuperCycleTracker is a menstrual cycle tracker that lives in a Telegram group. I - In the group it reads replies to the bot, answers to its questions, commands, and messages that mention it when Telegram delivers them; as a group admin it reads all of her messages. Voice notes from her or an admin are always heard, when the plugin can transcribe, and their photos, videos, stickers and files of any kind always reach the assistant. It also answers private chats with her or an admin. - Works without any AI: buttons and `/anotar` write records directly. An optional assistant plugin reads free text and answers questions. - 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. +- Every message the bot starts working on shows a ๐Ÿ‘€ at once, so nobody wonders during a long answer whether it was seen. 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 marks talk that has nothing for it with a ๐Ÿ‘€ (ยซreadยป), so no message is left with nothing under 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 leaves a ๐Ÿ‘€ (ยซreadยป) on talk that has nothing for it, 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 f2c0750..e94620f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -429,9 +429,10 @@ The rules tell the assistant what such a line wants: take in what it says and ac (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 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 +only `NO_REPLY`, and the messages keep the ๐Ÿ‘€ the bot put on them when the turn started (5.2), now +meaning ยซ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), and it replaces that ๐Ÿ‘€ (Telegram +keeps one reaction per bot on a message). 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 @@ -451,25 +452,28 @@ When nothing but punctuation, quotes, brackets or an empty code fence is left (` 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 (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 -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. 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 +With `answer_scope: all`, a person's message in the group is never left with nothing visible. Every +person's message a turn covers already shows ๐Ÿ‘€ from the moment the turn started (5.2). When a kept +turn shows nothing else in the chat (no text, no reaction, sticker, photo or file, no ยซAnotado:ยป, no +settings or reminder message), that ๐Ÿ‘€ stays and now means ยซreadยป. 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 (a gesture that failed beside it +does not count as shown). The end of such a turn puts nothing on a message whose ๐Ÿ‘€ is there. Where +Telegram refused the ๐Ÿ‘€ at the start (the chat's allowed reactions, a deleted message), it tries ๐Ÿ‘ +once on that message, then logs the refusal and goes on; it never posts a text instead. A message +the bot has no record of marking (one it forgot, past `EYED_MAX`) gets ๐Ÿ‘€ then, with the same ๐Ÿ‘ +fallback. A message that called the bot (a mention, a reply to it, a 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. 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 +keeps its ๐Ÿ‘€ and gets no text. 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) leaves the ๐Ÿ‘€ of its start: +its lines stay in `pending:` and go with the next turn, which adds no second ๐Ÿ‘€. Private +chats and `asked` get the ๐Ÿ‘€ of the start like the group, and an empty answer there shows nothing +more. 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. @@ -556,6 +560,22 @@ first tick after the wait (at least 60 s) runs the chat's turn again by itself; meanwhile wait and go with it. A `typing` chat action is sent once at the start. +**๐Ÿ‘€ at the start.** A turn can take minutes, so as soon as it starts the bot puts ๐Ÿ‘€ on every +person's message it covers, in every chat: private chats, the group in `asked` and in `all` mode, +mentions, replies, plain lines, stickers, photos and files, a voice note (on the note itself, never +on the ยซ๐ŸŽคยป echo) and an edit (on the message edited). A line handed over mid-turn gets it the +moment the plugin takes it (`incoming`). Never on a tap (its message is the bot's own), so a turn +of only taps shows none, and bots never start a turn. The reactions go on their own thread +(`eyes`), so the turn never waits on Telegram, and the turn's end waits for them (`EYES_WAIT_S`, at +most 10 s) so a late ๐Ÿ‘€ never lands over the answer's own reaction. A refusal is logged and nothing +more: no ๐Ÿ‘ then, since ๐Ÿ‘ says ยซnotedยป, and no text. The bot remembers in memory which messages +carry its ๐Ÿ‘€ (the newest `EYED_MAX`, 1,000), so a message never gets it twice: not from the next +turn after a failed one, nor from an edit's turn. At the end a text answer leaves the ๐Ÿ‘€ there; a +reaction-only answer replaces it on the messages it reacts on (every covered one in the `all` group, +the last one elsewhere), and a later turn on such a message shows ๐Ÿ‘€ again; silence keeps it (4.3). +After a restart the memory is empty, and a message that goes again gets the same ๐Ÿ‘€ again, which +changes nothing in the chat. + **Restarts.** A redeploy can replace the container at any moment, and it loses no message. Telegram's offset is stored only after the update's line is written to `pending:`, and a line leaves that list only after its answer is sent, so a turn the process never started and a turn it died in both @@ -1281,7 +1301,8 @@ 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 `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_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 keeps its ๐Ÿ‘€ and its lines go with the next, `asked` and private chats keep the ๐Ÿ‘€ of the start and get no other, and the rules say `NO_REPLY` marks the messages read | +| `test_eyes.py` | section 5.2 ยซ๐Ÿ‘€ at the startยป: ๐Ÿ‘€ reaches the message while the AI still works, in the `all` group (a plain line, a mention), `asked` (a reply to the bot) and private chats in both modes, and a text answer leaves it; a line handed over mid-turn gets it at once; a Telegram call held for seconds never holds the turn; a refused ๐Ÿ‘€ neither blocks the turn nor posts text nor tries ๐Ÿ‘; a bot listed as admin starts no turn and gets none; a turn of only a tap puts none on the bot's message; a failed turn, the silent one after it and an edit's turn never add a second ๐Ÿ‘€; a reaction-only answer replaces it and an edit's later turn shows it again | | `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 c260b0f..87ae197 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.9.0" +__version__ = "0.10.0" diff --git a/supercycletracker/assistant.py b/supercycletracker/assistant.py index 69a7352..80423da 100644 --- a/supercycletracker/assistant.py +++ b/supercycletracker/assistant.py @@ -62,8 +62,8 @@ # 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. +# chat. An answer left empty still counts as answered, and every message of the turn keeps the ๐Ÿ‘€ +# the bot put on it when the turn started (ยซreadยป), so nobody's message is left with nothing. NO_REPLY = "NO_REPLY" _NO_REPLY = re.compile( r"(? its newest wording closed: bool = False # taps on a calendar button: each approves the additions it named, once @@ -657,6 +660,9 @@ def __init__( self.asks: dict[str, dict] = {} # the plugin's open asks, by the key on their buttons self.hearer = Worker(self._hear_chat, name="sct-hear") self.side = Worker(lambda job: job(), name="sct-asks") # answers to asks, off the poll loop + self.eyes = Worker(lambda job: job(), name="sct-eyes") # a turn's ๐Ÿ‘€, so it never waits + # (chat, message) -> the ๐Ÿ‘€ the bot put on it: True shown, False refused, None on its way + self._eyed: OrderedDict[tuple[int, int], bool | None] = OrderedDict() def start(self) -> None: """`getMe`, then `getChatMember` for the bot in the group: Telegram delivers every group @@ -724,13 +730,14 @@ def close(self) -> None: self.worker.stop() self.later.stop() self.side.stop() + self.eyes.stop() self.kv.close() def join_idle(self, timeout: float = 10.0) -> None: """Wait until no note is being heard, no turn runs and no answer to an ask is on its way (tests drain the bot with this).""" deadline = time.monotonic() + timeout - workers = (self.hearer, self.worker, self.side) + workers = (self.hearer, self.worker, self.side, self.eyes) while not all(w.idle() for w in workers): for w in workers: w.join_idle(max(0.01, deadline - time.monotonic())) @@ -2004,6 +2011,7 @@ def _turn(self, chat: int) -> None: def _converse(self, chat: int, key: str, lines: list[dict], state: Turn) -> None: p = self.profile block_cfg = p.assistant or {} + self.look(chat, lines, state) # ๐Ÿ‘€ at once, before anything that takes time try: self.tg.call("sendChatAction", chat_id=chat, action="typing") except TelegramError as e: @@ -2087,6 +2095,7 @@ def _converse(self, chat: int, key: str, lines: list[dict], state: Turn) -> None self.tell_down(chat, lines) return log.info("ai turn ok %ds", round(time.monotonic() - started)) + self.looked(state) # a ๐Ÿ‘€ still on its way must not land over the answer's own reaction self.last_ai = self.now() with self._conv_lock: if self._resets[chat] != resets: @@ -2164,15 +2173,75 @@ def mark_read(self, chat: int, covered: Sequence[Mapping], people: Sequence[int] 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] + with self._q: # the ๐Ÿ‘€ put when the turn started stays; where it was refused, ๐Ÿ‘ once + fresh = [m for m in read if (chat, m) not in self._eyed] + refused = [m for m in read if self._eyed.get((chat, m)) is False] + if fresh: + self.react(chat, fresh, READ) + if refused: + self.react(chat, refused, NOTED) 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: + def look(self, chat: int, lines: Sequence[Mapping], state: Turn) -> None: + """๐Ÿ‘€ on every person's message of `lines` that has none from the bot yet, as a turn + starts, so the chat sees at once that the bot is on it: a text, voice note, photo or + file, an edit on the message edited; never a tap (its message is the bot's).""" + with self._q: + job = self._look(chat, lines, state) + if job is not None: + self.eyes.submit(job) + + def _look(self, chat: int, lines: Sequence[Mapping], state: Turn) -> Callable | None: + """With `_q` held: claim the messages of `lines` that need ๐Ÿ‘€ and return the job that + puts it, for the `eyes` thread, so the turn never waits on Telegram (None: nothing to + do). A refusal is logged and nothing else: no ๐Ÿ‘ (that says ยซnotedยป), no text.""" + mids = [m for m in _people(lines) if (chat, m) not in self._eyed] + for m in mids: + self._eyed[(chat, m)] = None + while len(self._eyed) > EYED_MAX: + self._eyed.popitem(last=False) + if not mids: + return None + done = threading.Event() + state.looks.append(done) # under `_q`, so the end of the turn always waits for it + + def job() -> None: + try: + for m in mids: + with self._q: + if (chat, m) not in self._eyed: + continue # the answer's own reaction is already on it + ok = False + try: + self.tg.call( + "setMessageReaction", + chat_id=chat, + message_id=m, + reaction=[{"type": "emoji", "emoji": READ}], + ) + ok = True + except Exception as e: # noqa: BLE001 - a refusal never touches the turn + log.info("start read mark failed: %s", safe(e)) + with self._q: + if (chat, m) in self._eyed: + self._eyed[(chat, m)] = ok + finally: + done.set() + + return job + + def looked(self, state: Turn) -> None: + """Wait, EYES_WAIT_S at most, until the turn's ๐Ÿ‘€ reached Telegram or were refused.""" + deadline = time.monotonic() + EYES_WAIT_S + for done in list(state.looks): + done.wait(max(0.0, deadline - time.monotonic())) + + def react(self, chat: int, mids: Sequence[int], emoji: str) -> list[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 + the next message goes on. The messages that got a reaction.""" + done = [] for mid in mids: for one in dict.fromkeys((emoji, NOTED)): try: @@ -2182,12 +2251,19 @@ def react(self, chat: int, mids: Sequence[int], emoji: str) -> int: message_id=mid, reaction=[{"type": "emoji", "emoji": one}], ) - done += 1 + done.append(mid) break except TelegramError as e: log.info("reaction failed: %s", safe(e)) return done + def unlook(self, chat: int, mids: Sequence[int]) -> None: + """The answer's reaction replaced the bot's ๐Ÿ‘€ on `mids` (Telegram keeps one reaction + per bot): forget it, so a later turn on the same message shows ๐Ÿ‘€ again.""" + with self._q: + for m in mids: + self._eyed.pop((chat, m), None) + 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 @@ -2300,6 +2376,9 @@ def incoming() -> list[dict]: for uid in _users(got): if uid not in state.users: state.users.append(uid) + look = self._look(state.chat, got, state) # a line that joins shows ๐Ÿ‘€ at once + if look is not None: + self.eyes.submit(look) return [ { "id": x["id"], @@ -3426,7 +3505,9 @@ def act( except TelegramError as e: log.warning("%s failed: %s", method, safe(e)) if acts["react"] and everyone: - if self.react(chat, everyone, acts["react"]): + got = self.react(chat, everyone, acts["react"]) + if got: + self.unlook(chat, got) return True elif acts["react"] and to is not None: try: @@ -3436,6 +3517,7 @@ def act( message_id=to, reaction=[{"type": "emoji", "emoji": acts["react"]}], ) + self.unlook(chat, [to]) return True except TelegramError as e: log.info("reaction failed: %s", safe(e)) diff --git a/tests/test_answer_scope.py b/tests/test_answer_scope.py index 69d34bd..dd7cd59 100644 --- a/tests/test_answer_scope.py +++ b/tests/test_answer_scope.py @@ -120,7 +120,8 @@ def test_a_burst_of_unmentioned_lines_is_one_turn_and_a_reaction_goes_on_each(ma "[no mention] Lucรญa: y he sacado 100 g de merluza", ] assert sent(bot) == [] # a reaction only: no text - assert reactions(bot) == [(first["message_id"], "๐Ÿ‘"), (second["message_id"], "๐Ÿ‘")] + ids = [first["message_id"], second["message_id"]] + assert reactions(bot) == [(m, "๐Ÿ‘€") for m in ids] + [(m, "๐Ÿ‘") for m in ids] assert bot.kv.get_json(f"pending:{GROUP}") is None @@ -398,12 +399,12 @@ def test_a_reaction_only_answer_goes_on_a_line_that_joined_mid_turn_too(make_bot 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 reactions(bot) == [(m, "๐Ÿ‘€") for m in ids] + [(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 + # MUT: drop `self.look(...)` in _incoming โ†’ the line handed over mid-turn shows nothing 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) @@ -423,7 +424,7 @@ def test_no_reply_marks_every_covered_message_read(make_bot): ], ) 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 + # MUT: drop `self.look(...)` in _converse โ†’ the empty answer leaves them with nothing bot = make_bot(profile=ALL) plugin(bot).REPLIES[:] = [answer] msg = group_text(bot, "ยฟcompras pan?") @@ -432,7 +433,6 @@ def test_every_answer_that_would_show_nothing_marks_the_lines_read(make_bot, ans 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) @@ -449,33 +449,34 @@ def test_a_record_that_changes_something_shows_anotado_and_no_read_mark(make_bot bot = make_bot(profile=ALL) plugin(bot).REPLIES[:] = [record("", {"date": "2027-03-04", "flow": "heavy"})] - group_text(bot, "hoy mucho", user=HER) + msg = group_text(bot, "hoy mucho", user=HER) assert bot.tg.texts()[0].startswith("Anotado:") - assert reactions(bot) == [] + assert reactions(bot) == [(msg["message_id"], "๐Ÿ‘€")] # the one of the start, no other -def test_a_text_answer_adds_no_reactions(make_bot): - # MUT: call `self.mark_read(...)` whatever was shown โ†’ ๐Ÿ‘€ under a worded answer +def test_a_text_answer_leaves_the_eyes_and_adds_no_reactions(make_bot): + # MUT: call `self.mark_read(...)` whatever was shown, with `fresh` taking every line โ†’ a + # second ๐Ÿ‘€ 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) == [] + assert reactions(bot) == [(a["message_id"], "๐Ÿ‘€"), (b["message_id"], "๐Ÿ‘€")] 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 + # MUT: drop the `refused` branch in mark_read โ†’ the refused messages show nothing 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 + # the start: both ๐Ÿ‘€ refused, never a ๐Ÿ‘ then; the end: a's ๐Ÿ‘ refused, b's taken, once each assert reactions(bot) == [ (a["message_id"], "๐Ÿ‘€"), - (a["message_id"], "๐Ÿ‘"), (b["message_id"], "๐Ÿ‘€"), + (a["message_id"], "๐Ÿ‘"), (b["message_id"], "๐Ÿ‘"), ] assert sent(bot) == [] # never a text instead of ๐Ÿ‘€ @@ -490,17 +491,19 @@ def test_a_mention_never_gets_only_a_read_mark(make_bot): # 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 reactions(bot) == [(chat["message_id"], "๐Ÿ‘€"), (asked["message_id"], "๐Ÿ‘€")] 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) == [] + a, b = message("ยฟvienes a cenar?"), addressed("ยฟme ha bajado?") + burst(bot, a, b, reply="Aรบn no.") + assert bot.tg.texts() == ["Aรบn no."] + assert reactions(bot) == [(a["message_id"], "๐Ÿ‘€"), (b["message_id"], "๐Ÿ‘€")] -def test_a_failed_turn_marks_nothing_and_its_lines_go_with_the_next(make_bot): +def test_a_failed_turn_keeps_its_eyes_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) @@ -511,7 +514,8 @@ def test_a_failed_turn_marks_nothing_and_its_lines_go_with_the_next(make_bot): ] 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 + eyes = [(m["message_id"], "๐Ÿ‘€") for m in (a, b)] + assert reactions(bot) == eyes and sent(bot) == [] # the ๐Ÿ‘€ of the start stay, nothing said c = group_text(bot, "vale") assert contents(mod.CALLS[2]) == [ "[no mention] Marta: ยฟvienes a cenar?", @@ -522,33 +526,36 @@ def test_a_failed_turn_marks_nothing_and_its_lines_go_with_the_next(make_bot): assert bot.kv.get_json(f"pending:{GROUP}") is None -def test_a_busy_turn_marks_nothing(make_bot): +def test_a_busy_turn_keeps_its_eyes_and_says_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) == [] + msg = group_text(bot, "ยฟvienes a cenar?") + assert reactions(bot) == [(msg["message_id"], "๐Ÿ‘€")] 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 +def test_asked_mode_keeps_the_eyes_of_the_start_and_reacts_on_the_last_only(make_bot): + # MUT: drop `self.answer_all and` from `people` in _converse โ†’ ยซread_no_answerยป in asked mode 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 + first = say(bot, "hoy poco", reply_to=QUESTION) + assert sent(bot) == [] # no text, as in 0.8.0 + assert reactions(bot) == [(first["message_id"], "๐Ÿ‘€")] # the start's, and no other a, b = addressed("hoy poco"), addressed("y dolor 3") burst(bot, a, b, reply=REACT) - assert reactions(bot) == [(b["message_id"], "๐Ÿ‘")] + assert reactions(bot)[1:] == [(a["message_id"], "๐Ÿ‘€"), (b["message_id"], "๐Ÿ‘€")] + [ + (b["message_id"], "๐Ÿ‘") # the answer's reaction replaces the ๐Ÿ‘€ of the last only + ] -def test_a_private_chat_in_all_mode_gets_no_read_mark(make_bot): +def test_a_private_chat_in_all_mode_keeps_only_the_eyes_of_the_start(make_bot): # 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) + msg = 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 + assert reactions(bot) == [(msg["message_id"], "๐Ÿ‘€")] and sent(bot) == [] def test_the_all_rules_say_no_reply_marks_the_messages_read(): @@ -613,7 +620,7 @@ def test_a_photo_that_calls_the_bot_still_gets_words(make_bot, how): [said] = sent(bot) assert said["text"] == bot.t("read_no_answer") assert said["reply_parameters"]["message_id"] == msg["message_id"] - assert reactions(bot) == [] + assert reactions(bot) == [(msg["message_id"], "๐Ÿ‘€")] # the start's, no second one def test_a_voice_note_left_without_words_gets_words(make_bot): @@ -629,7 +636,7 @@ def test_a_voice_note_left_without_words_gets_words(make_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) == [] + assert reactions(bot) == [(msg["message_id"], "๐Ÿ‘€")] # on the note itself, not the echo def test_a_line_queued_by_0_8_without_asks_still_counts_by_its_mark(): @@ -670,7 +677,7 @@ def test_an_edit_that_adds_a_mention_to_an_answered_line_gets_words_when_left_si said = sent(bot)[-1] assert said["text"] == bot.t("read_no_answer") assert said["reply_parameters"]["message_id"] == msg["message_id"] - assert reactions(bot) == [] + assert reactions(bot) == [(msg["message_id"], "๐Ÿ‘€")] # once, at the first turn's start def test_a_plain_edit_of_an_answered_line_left_silent_is_marked_read(make_bot): diff --git a/tests/test_eyes.py b/tests/test_eyes.py new file mode 100644 index 0000000..c1c9718 --- /dev/null +++ b/tests/test_eyes.py @@ -0,0 +1,184 @@ +"""๐Ÿ‘€ the moment a turn starts: every person's message the turn covers shows it at once, in every +chat, so nobody waits on a long turn wondering whether the bot saw them. It goes on its own thread, +never on bot messages or taps, never twice, and a refusal never touches the turn.""" + +import threading +import time + +import pytest +from conftest import ADMIN, HER +from test_answer_scope import ALL, REACT, addressed, message, reactions, sent +from test_bot import QUESTION, buttons, plugin, say, tap + +MENTION = [{"type": "mention", "offset": 0, "length": 18}] + + +def eyes_on(bot, mid, timeout=5.0): + """Whether ๐Ÿ‘€ reaches message `mid` within `timeout` (it goes on its own thread).""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if (mid, "๐Ÿ‘€") in reactions(bot): + return True + time.sleep(0.01) + return False + + +def private(text, user=HER): + msg = message(text, user=user) + msg["chat"] = {"id": user, "type": "private"} + return msg + + +def mentioned(text): + msg = message(f"@example_cycle_bot {text}", user=HER) + msg["entities"] = MENTION + return msg + + +CASES = { + "all, a plain line": (ALL, lambda: message("ยฟcompras pan?")), + "all, a mention": (ALL, lambda: mentioned("ยฟme ha bajado?")), + "asked, a reply to the bot": (None, lambda: addressed("hoy poco")), + "asked, a private chat": (None, lambda: private("hola")), + "all, a private chat": (ALL, lambda: private("hola", user=ADMIN)), +} + + +@pytest.mark.parametrize("case", list(CASES)) +def test_eyes_land_when_the_turn_starts_before_the_ai_answers(make_bot, case): + # MUT: move `self.look(...)` in _converse below the plugin call โ†’ no ๐Ÿ‘€ while the AI works + profile, make = CASES[case] + bot = make_bot(profile=profile) if profile else make_bot() + msg = make() + seen = [] + + def answer(turn): + seen.append(eyes_on(bot, msg["message_id"])) # the AI has not answered yet + return "Vale." + + plugin(bot).REPLIES[:] = [answer] + bot.handle({"message": msg}) + bot.join_idle() + assert seen == [True] + assert bot.tg.texts() == ["Vale."] + assert reactions(bot) == [(msg["message_id"], "๐Ÿ‘€")] # a text answer leaves it there + + +def test_a_line_that_joins_mid_turn_gets_eyes_at_once(make_bot): + # MUT: drop `self._look(...)` in _incoming โ†’ the late line shows nothing until the end + bot = make_bot(profile=ALL) + first, late = message("quedan 10 raciones"), message("y pan", user=HER) + seen = [] + + def handover(turn): + bot.handle({"message": late}) + got = turn["incoming"]() + seen.append(eyes_on(bot, late["message_id"])) # the turn is still running + return {"text": "Anotado.", "conversation": "c", "kept": True, "covers": [got[0]["id"]]} + + plugin(bot).REPLIES[:] = [handover] + bot.handle({"message": first}) + bot.join_idle() + assert seen == [True] + assert reactions(bot) == [(first["message_id"], "๐Ÿ‘€"), (late["message_id"], "๐Ÿ‘€")] + + +def test_a_slow_eyes_call_never_holds_the_turn(make_bot): + # MUT: run the ๐Ÿ‘€ job inline in `look` instead of `self.eyes.submit` โ†’ the AI waits on it + bot = make_bot(profile=ALL) + release, called = threading.Event(), threading.Event() + call = bot.tg.call + + def slow(method, **params): + if method == "setMessageReaction": + release.wait(10) + return call(method, **params) + + bot.tg.call = slow + + def answer(turn): + called.set() + return "Vale." + + plugin(bot).REPLIES[:] = [answer] + msg = message("ยฟcompras pan?") + bot.handle({"message": msg}) + try: + assert called.wait(5) # the AI got the turn while Telegram still held the ๐Ÿ‘€ + finally: + release.set() + bot.join_idle() + assert bot.tg.texts() == ["Vale."] + assert reactions(bot) == [(msg["message_id"], "๐Ÿ‘€")] + + +def test_a_refused_start_eyes_never_blocks_the_turn_or_posts_text(make_bot): + # MUT: `dict.fromkeys((READ, NOTED))` tries in the ๐Ÿ‘€ job โ†’ a ๐Ÿ‘ (ยซnotedยป) at the start + bot = make_bot() + bot.tg.fail_next("setMessageReaction", 400, "Bad Request: REACTION_INVALID") + msg = say(bot, "hoy poco", reply_to=QUESTION) + assert len(plugin(bot).CALLS) == 1 + assert bot.tg.texts() == ["Vale."] # the answer, and no word about the refusal + assert reactions(bot) == [(msg["message_id"], "๐Ÿ‘€")] # the refused try, then nothing + + +def test_bot_messages_get_no_eyes(make_bot): + # MUT: `self.unmentioned` takes bots โ†’ the bot's line starts a turn and gets ๐Ÿ‘€ + bot = make_bot(profile=ALL) + other = message("Recordatorio: pastilla", user=ADMIN, is_bot=True) # a bot listed as admin + bot.handle({"message": other}) + bot.join_idle() + assert plugin(bot).CALLS == [] and reactions(bot) == [] + msg = message("ยฟcompras pan?") + bot.handle({"message": msg}) + bot.join_idle() + assert reactions(bot) == [(msg["message_id"], "๐Ÿ‘€")] + + +def test_a_turn_of_only_a_tap_puts_no_eyes_on_the_bots_message(make_bot): + # MUT: drop `not x.get("tap")` from `_people` โ†’ ๐Ÿ‘€ on the bot's own question + bot = make_bot() + mod = plugin(bot) + mod.REPLIES[:] = ["ยฟCuรกnto?\n\n```options\n- Poco\n- Mucho\n```", "Apuntado."] + first = say(bot, "me ha bajado", reply_to=QUESTION) + post = sent(bot)[-1] + question = 1001 + len(sent(bot)) - 1 # FakeTelegram's ids count up from 1001 + tap(bot, buttons(post["reply_markup"])[0], message_id=question) + bot.join_idle() + assert len(mod.CALLS) == 2 # the tap ran a turn of its own, covering no person's message + assert reactions(bot) == [(first["message_id"], "๐Ÿ‘€")] + + +def test_a_message_never_gets_two_eyes(make_bot): + # MUT: drop the `not in self._eyed` filter in `_look` โ†’ a second ๐Ÿ‘€ on each kept line + bot = make_bot(profile=ALL) + mod = plugin(bot) + mod.REPLIES[:] = [RuntimeError("down"), "NO_REPLY", "Vale.", "NO_REPLY"] + a = message("ยฟvienes a cenar?") + bot.handle({"message": a}) + bot.join_idle() # failed: the ๐Ÿ‘€ stays and the line waits + b = message("sรญ", user=HER) + bot.handle({"message": b}) + bot.join_idle() # both go, silent: the end adds nothing + c = message("quedan 10 raciones") + bot.handle({"message": c}) + bot.join_idle() + bot.handle({"edited_message": {**c, "text": "quedan 8 raciones"}}) + bot.join_idle() # the edit's turn covers the same message: its ๐Ÿ‘€ is already there + assert len(mod.CALLS) == 4 + assert reactions(bot) == [(m["message_id"], "๐Ÿ‘€") for m in (a, b, c)] + + +def test_a_reaction_only_answer_replaces_the_eyes_and_a_later_turn_shows_them_again(make_bot): + # MUT: drop `self.unlook(...)` in act โ†’ the edit's turn shows no ๐Ÿ‘€, the ๐Ÿ‘ stays meanwhile + bot = make_bot(profile=ALL) + plugin(bot).REPLIES[:] = [REACT, "Vale."] + msg = message("quedan 10 raciones") + bot.handle({"message": msg}) + bot.join_idle() + assert sent(bot) == [] + assert reactions(bot) == [(msg["message_id"], "๐Ÿ‘€"), (msg["message_id"], "๐Ÿ‘")] + bot.handle({"edited_message": {**msg, "text": "quedan 8 raciones"}}) + bot.join_idle() + assert bot.tg.texts() == ["Vale."] + assert reactions(bot)[2:] == [(msg["message_id"], "๐Ÿ‘€")] diff --git a/tests/test_gestures.py b/tests/test_gestures.py index e53a3ca..352e3ac 100644 --- a/tests/test_gestures.py +++ b/tests/test_gestures.py @@ -96,11 +96,13 @@ def test_a_reaction_alone_says_nothing_and_a_failed_one_says_the_emoji(make_bot) plugin(bot).REPLIES[:] = ["```telegram\nreact: โค๏ธ\n```"] before = len(sent(bot)) msg = say(bot, "gracias", reply_to=QUESTION) - [r] = bot.tg.of("setMessageReaction") + eyes, r = bot.tg.of("setMessageReaction") # ๐Ÿ‘€ when the turn started, then the answer's + assert (eyes["message_id"], eyes["reaction"][0]["emoji"]) == (msg["message_id"], "๐Ÿ‘€") assert (r["chat_id"], r["message_id"]) == (GROUP, msg["message_id"]) assert r["reaction"] == [{"type": "emoji", "emoji": "โค"}] assert len(sent(bot)) == before - bot.tg.fail_next("setMessageReaction", 400, "Bad Request: REACTION_INVALID") + bot.tg.fail_next("setMessageReaction", 400, "Bad Request: REACTION_INVALID") # the ๐Ÿ‘€ + bot.tg.fail_next("setMessageReaction", 400, "Bad Request: REACTION_INVALID") # the ๐Ÿ™ plugin(bot).REPLIES[:] = ["```telegram\nreact: ๐Ÿ™\n```"] say(bot, "gracias otra vez", reply_to=QUESTION) assert sent(bot)[-1]["text"] == "๐Ÿ™" From 2a4cafb971fe35ac3557fc8449603eebcb022d81 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:48:27 +0100 Subject: [PATCH 2/2] =?UTF-8?q?fix(bot):=20never=20forget=20a=20?= =?UTF-8?q?=F0=9F=91=80=20still=20on=20its=20way=20when=20the=20memory=20o?= =?UTF-8?q?f=20old=20ones=20is=20full?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESIGN.md | 4 ++-- supercycletracker/bot.py | 7 +++++-- tests/test_eyes.py | 25 +++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e94620f..142d921 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -569,7 +569,7 @@ of only taps shows none, and bots never start a turn. The reactions go on their (`eyes`), so the turn never waits on Telegram, and the turn's end waits for them (`EYES_WAIT_S`, at most 10 s) so a late ๐Ÿ‘€ never lands over the answer's own reaction. A refusal is logged and nothing more: no ๐Ÿ‘ then, since ๐Ÿ‘ says ยซnotedยป, and no text. The bot remembers in memory which messages -carry its ๐Ÿ‘€ (the newest `EYED_MAX`, 1,000), so a message never gets it twice: not from the next +carry its ๐Ÿ‘€ (the newest `EYED_MAX`, 1,000, never forgetting one still on its way), so a message never gets it twice: not from the next turn after a failed one, nor from an edit's turn. At the end a text answer leaves the ๐Ÿ‘€ there; a reaction-only answer replaces it on the messages it reacts on (every covered one in the `all` group, the last one elsewhere), and a later turn on such a message shows ๐Ÿ‘€ again; silence keeps it (4.3). @@ -1302,7 +1302,7 @@ joins a group or its abilities change. | `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 `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 keeps its ๐Ÿ‘€ and its lines go with the next, `asked` and private chats keep the ๐Ÿ‘€ of the start and get no other, and the rules say `NO_REPLY` marks the messages read | -| `test_eyes.py` | section 5.2 ยซ๐Ÿ‘€ at the startยป: ๐Ÿ‘€ reaches the message while the AI still works, in the `all` group (a plain line, a mention), `asked` (a reply to the bot) and private chats in both modes, and a text answer leaves it; a line handed over mid-turn gets it at once; a Telegram call held for seconds never holds the turn; a refused ๐Ÿ‘€ neither blocks the turn nor posts text nor tries ๐Ÿ‘; a bot listed as admin starts no turn and gets none; a turn of only a tap puts none on the bot's message; a failed turn, the silent one after it and an edit's turn never add a second ๐Ÿ‘€; a reaction-only answer replaces it and an edit's later turn shows it again | +| `test_eyes.py` | section 5.2 ยซ๐Ÿ‘€ at the startยป: ๐Ÿ‘€ reaches the message while the AI still works, in the `all` group (a plain line, a mention), `asked` (a reply to the bot) and private chats in both modes, and a text answer leaves it; a line handed over mid-turn gets it at once; a Telegram call held for seconds never holds the turn; a refused ๐Ÿ‘€ neither blocks the turn nor posts text nor tries ๐Ÿ‘; a bot listed as admin starts no turn and gets none; a turn of only a tap puts none on the bot's message; a failed turn, the silent one after it and an edit's turn never add a second ๐Ÿ‘€; a reaction-only answer replaces it and an edit's later turn shows it again; forgetting old ๐Ÿ‘€ past `EYED_MAX` never drops one still on its way | | `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/bot.py b/supercycletracker/bot.py index db2a55f..db5bd9f 100644 --- a/supercycletracker/bot.py +++ b/supercycletracker/bot.py @@ -2199,8 +2199,11 @@ def _look(self, chat: int, lines: Sequence[Mapping], state: Turn) -> Callable | mids = [m for m in _people(lines) if (chat, m) not in self._eyed] for m in mids: self._eyed[(chat, m)] = None - while len(self._eyed) > EYED_MAX: - self._eyed.popitem(last=False) + while len(self._eyed) > EYED_MAX: # forget the oldest done, never one on its way + old = next((k for k, v in self._eyed.items() if v is not None), None) + if old is None: + break + del self._eyed[old] if not mids: return None done = threading.Event() diff --git a/tests/test_eyes.py b/tests/test_eyes.py index c1c9718..b646ce3 100644 --- a/tests/test_eyes.py +++ b/tests/test_eyes.py @@ -182,3 +182,28 @@ def test_a_reaction_only_answer_replaces_the_eyes_and_a_later_turn_shows_them_ag bot.join_idle() assert bot.tg.texts() == ["Vale."] assert reactions(bot)[2:] == [(msg["message_id"], "๐Ÿ‘€")] + + +def test_forgetting_old_eyes_never_drops_one_still_on_its_way(make_bot, monkeypatch): + # MUT: `self._eyed.popitem(last=False)` in `_look` โ†’ the queued ๐Ÿ‘€ is forgotten and skipped + from supercycletracker import bot as module + + monkeypatch.setattr(module, "EYED_MAX", 1) + bot = make_bot(profile=ALL) + release = threading.Event() + call = bot.tg.call + + def slow(method, **params): + if method == "setMessageReaction": + release.wait(10) + return call(method, **params) + + bot.tg.call = slow + a, b = message("quedan 10 raciones"), message("y pan", user=HER) + bot.debounce_s = 0.3 + bot.handle({"message": a}) + bot.handle({"message": b}) # one turn, two claims on their way, room for one + time.sleep(0.5) + release.set() + bot.join_idle() + assert reactions(bot)[:2] == [(a["message_id"], "๐Ÿ‘€"), (b["message_id"], "๐Ÿ‘€")]