Skip to content

Commit 6681f28

Browse files
committed
fix(telegram): disable DM topic mode when last binding is pruned
Follow-up to #31501. When the send-fallback prune removes a chat's final telegram_dm_topic_bindings row, also flip telegram_dm_topic_mode.enabled to 0 in the same transaction. Without this, a user who turns topics off in the Telegram client (rather than via /topic off) leaves enabled=1 with zero lanes: _recover_telegram_topic_thread_id keeps treating the chat as topic-enabled and lobby messages keep hunting for bindings that no longer exist. Clearing the flag makes recovery fully stand down once the dead topics are gone. Adds 3 regression tests covering the last-binding clear, the multi-binding no-op, and the unmatched-prune no-op.
1 parent 11246db commit 6681f28

2 files changed

Lines changed: 101 additions & 2 deletions

File tree

hermes_state.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4615,8 +4615,19 @@ def delete_telegram_topic_binding(
46154615
topic, causing tool progress, approvals, and replies to land
46164616
in the wrong place. Issue #31501.
46174617
4618-
Returns the number of rows deleted (0 when the binding was
4619-
already absent or the topic-mode tables haven't been
4618+
When this prune removes the chat's *last* remaining binding,
4619+
the chat's row in ``telegram_dm_topic_mode`` is also flipped to
4620+
``enabled = 0`` in the same transaction. Otherwise the chat
4621+
would be left in topic mode with zero lanes — and
4622+
``gateway.run._recover_telegram_topic_thread_id`` keeps treating
4623+
the chat as topic-enabled, lobby messages keep hunting for a
4624+
binding that no longer exists, and a user who disabled topics in
4625+
the Telegram client (rather than via ``/topic off``) stays stuck
4626+
until the next send happens to fail. Clearing the flag makes
4627+
recovery fully stand down once the dead topics are gone.
4628+
4629+
Returns the number of binding rows deleted (0 when the binding
4630+
was already absent or the topic-mode tables haven't been
46204631
migrated yet — both are silent no-ops; we never raise from
46214632
a cleanup hot path).
46224633
"""
@@ -4637,6 +4648,29 @@ def _do(conn):
46374648
except sqlite3.OperationalError:
46384649
# Tables don't exist yet — nothing to prune.
46394650
deleted["count"] = 0
4651+
return
4652+
if not deleted["count"]:
4653+
return
4654+
# If that was the chat's last binding, disable topic mode for
4655+
# the chat so recovery stops steering lobby messages at a now
4656+
# empty lane set. Same transaction → no read-after-prune race.
4657+
try:
4658+
remaining = conn.execute(
4659+
"""
4660+
SELECT 1 FROM telegram_dm_topic_bindings
4661+
WHERE chat_id = ? LIMIT 1
4662+
""",
4663+
(chat_id,),
4664+
).fetchone()
4665+
if remaining is None:
4666+
conn.execute(
4667+
"UPDATE telegram_dm_topic_mode "
4668+
"SET enabled = 0, updated_at = ? WHERE chat_id = ?",
4669+
(time.time(), chat_id),
4670+
)
4671+
except sqlite3.OperationalError:
4672+
# telegram_dm_topic_mode absent — binding prune still stands.
4673+
pass
46404674

46414675
self._execute_write(_do)
46424676
return deleted["count"]

tests/gateway/test_telegram_prune_stale_topic_binding_31501.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,71 @@ def test_idempotent_under_repeated_calls(self, tmp_path):
155155
db.close()
156156

157157

158+
class TestPruneClearsTopicModeWhenLastBindingGone:
159+
"""Proactive cleanup (#31501 follow-up): pruning the chat's final
160+
binding must also flip ``telegram_dm_topic_mode.enabled`` to 0 so
161+
recovery fully stands down — covers the user who disabled topics in
162+
the Telegram client without ever running ``/topic off``."""
163+
164+
def test_clears_enabled_when_last_binding_pruned(self, tmp_path):
165+
db = SessionDB(db_path=tmp_path / "state.db")
166+
db.enable_telegram_topic_mode(
167+
chat_id="5595856929", user_id="5595856929",
168+
)
169+
_seed_binding(db, thread_id="15287")
170+
assert db.is_telegram_topic_mode_enabled(
171+
chat_id="5595856929", user_id="5595856929",
172+
) is True
173+
174+
removed = db.delete_telegram_topic_binding(
175+
chat_id="5595856929", thread_id="15287",
176+
)
177+
178+
assert removed == 1
179+
assert db.is_telegram_topic_mode_enabled(
180+
chat_id="5595856929", user_id="5595856929",
181+
) is False
182+
db.close()
183+
184+
def test_keeps_enabled_while_other_bindings_remain(self, tmp_path):
185+
# Deleting one of several topics must NOT disable topic mode —
186+
# the chat still has healthy lanes that recovery should serve.
187+
db = SessionDB(db_path=tmp_path / "state.db")
188+
db.enable_telegram_topic_mode(
189+
chat_id="5595856929", user_id="5595856929",
190+
)
191+
_seed_binding(db, thread_id="15287", session_id="sess-stale")
192+
_seed_binding(db, thread_id="15418", session_id="sess-fresh")
193+
194+
db.delete_telegram_topic_binding(
195+
chat_id="5595856929", thread_id="15287",
196+
)
197+
198+
assert db.is_telegram_topic_mode_enabled(
199+
chat_id="5595856929", user_id="5595856929",
200+
) is True
201+
db.close()
202+
203+
def test_noop_prune_leaves_enabled_untouched(self, tmp_path):
204+
# A prune that matches no row must not flip the flag — there's
205+
# still a live binding the (wrong) thread_id didn't match.
206+
db = SessionDB(db_path=tmp_path / "state.db")
207+
db.enable_telegram_topic_mode(
208+
chat_id="5595856929", user_id="5595856929",
209+
)
210+
_seed_binding(db, thread_id="15287")
211+
212+
removed = db.delete_telegram_topic_binding(
213+
chat_id="5595856929", thread_id="99999",
214+
)
215+
216+
assert removed == 0
217+
assert db.is_telegram_topic_mode_enabled(
218+
chat_id="5595856929", user_id="5595856929",
219+
) is True
220+
db.close()
221+
222+
158223
# ---------------------------------------------------------------------------
159224
# Adapter glue — _prune_stale_dm_topic_binding
160225
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)