feat(matrix): refresh typing indicator while processing

This commit is contained in:
Alexander Minges
2026-02-10 17:21:13 +01:00
parent a482a89df6
commit ca66ddb0bf
2 changed files with 82 additions and 5 deletions

View File

@@ -38,6 +38,7 @@ from nanobot.utils.helpers import safe_filename
LOGGING_STACK_BASE_DEPTH = 2
TYPING_NOTICE_TIMEOUT_MS = 30_000
TYPING_KEEPALIVE_INTERVAL_SECONDS = 20.0
MATRIX_HTML_FORMAT = "org.matrix.custom.html"
MATRIX_ATTACHMENT_MARKER_TEMPLATE = "[attachment: {}]"
MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE = "[attachment: {} - too large]"
@@ -224,6 +225,7 @@ class MatrixChannel(BaseChannel):
super().__init__(config, bus)
self.client: AsyncClient | None = None
self._sync_task: asyncio.Task | None = None
self._typing_tasks: dict[str, asyncio.Task] = {}
async def start(self) -> None:
"""Start Matrix client and begin sync loop."""
@@ -272,6 +274,9 @@ class MatrixChannel(BaseChannel):
"""Stop the Matrix channel with graceful sync shutdown."""
self._running = False
for room_id in list(self._typing_tasks):
await self._stop_typing_keepalive(room_id, clear_typing=False)
if self.client:
# Request sync_forever loop to exit cleanly.
self.client.stop_sync_forever()
@@ -306,7 +311,7 @@ class MatrixChannel(BaseChannel):
ignore_unverified_devices=True,
)
finally:
await self._set_typing(msg.chat_id, False)
await self._stop_typing_keepalive(msg.chat_id, clear_typing=True)
def _register_event_callbacks(self) -> None:
"""Register Matrix event callbacks used by this channel."""
@@ -368,6 +373,41 @@ class MatrixChannel(BaseChannel):
str(e),
)
async def _start_typing_keepalive(self, room_id: str) -> None:
"""Start periodic Matrix typing refresh for a room."""
await self._stop_typing_keepalive(room_id, clear_typing=False)
await self._set_typing(room_id, True)
if not self._running:
return
async def _typing_loop() -> None:
try:
while self._running:
await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_SECONDS)
await self._set_typing(room_id, True)
except asyncio.CancelledError:
pass
self._typing_tasks[room_id] = asyncio.create_task(_typing_loop())
async def _stop_typing_keepalive(
self,
room_id: str,
*,
clear_typing: bool,
) -> None:
"""Stop periodic Matrix typing refresh for a room."""
task = self._typing_tasks.pop(room_id, None)
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
if clear_typing:
await self._set_typing(room_id, False)
async def _sync_loop(self) -> None:
while self._running:
try:
@@ -684,7 +724,7 @@ class MatrixChannel(BaseChannel):
if not self._should_process_message(room, event):
return
await self._set_typing(room.room_id, True)
await self._start_typing_keepalive(room.room_id)
try:
await self._handle_message(
sender_id=event.sender,
@@ -693,7 +733,7 @@ class MatrixChannel(BaseChannel):
metadata={"room": getattr(room, "display_name", room.room_id)},
)
except Exception:
await self._set_typing(room.room_id, False)
await self._stop_typing_keepalive(room.room_id, clear_typing=True)
raise
async def _on_media_message(self, room: MatrixRoom, event: Any) -> None:
@@ -718,7 +758,7 @@ class MatrixChannel(BaseChannel):
# TODO: Optionally add audio transcription support for Matrix attachments,
# similar to Telegram's voice/audio flow, behind explicit config.
await self._set_typing(room.room_id, True)
await self._start_typing_keepalive(room.room_id)
try:
await self._handle_message(
sender_id=event.sender,
@@ -731,5 +771,5 @@ class MatrixChannel(BaseChannel):
},
)
except Exception:
await self._set_typing(room.room_id, False)
await self._stop_typing_keepalive(room.room_id, clear_typing=True)
raise

View File

@@ -1,3 +1,4 @@
import asyncio
from pathlib import Path
from types import SimpleNamespace
@@ -224,6 +225,24 @@ async def test_on_message_sets_typing_for_allowed_sender() -> None:
]
@pytest.mark.asyncio
async def test_typing_keepalive_refreshes_periodically(monkeypatch) -> None:
channel = MatrixChannel(_make_config(), MessageBus())
client = _FakeAsyncClient("", "", "", None)
channel.client = client
channel._running = True
monkeypatch.setattr(matrix_module, "TYPING_KEEPALIVE_INTERVAL_SECONDS", 0.01)
await channel._start_typing_keepalive("!room:matrix.org")
await asyncio.sleep(0.03)
await channel._stop_typing_keepalive("!room:matrix.org", clear_typing=True)
true_updates = [call for call in client.typing_calls if call[1] is True]
assert len(true_updates) >= 2
assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS)
@pytest.mark.asyncio
async def test_on_message_skips_typing_for_self_message() -> None:
channel = MatrixChannel(_make_config(), MessageBus())
@@ -612,6 +631,24 @@ async def test_send_clears_typing_after_send() -> None:
assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS)
@pytest.mark.asyncio
async def test_send_stops_typing_keepalive_task() -> None:
channel = MatrixChannel(_make_config(), MessageBus())
client = _FakeAsyncClient("", "", "", None)
channel.client = client
channel._running = True
await channel._start_typing_keepalive("!room:matrix.org")
assert "!room:matrix.org" in channel._typing_tasks
await channel.send(
OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content="Hi")
)
assert "!room:matrix.org" not in channel._typing_tasks
assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS)
@pytest.mark.asyncio
async def test_send_clears_typing_when_send_fails() -> None:
channel = MatrixChannel(_make_config(), MessageBus())