This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

archiver-bot: reliably accept invites across restarts and federation races

Three problems were stacked on top of each other:

1. matrix-nio's client.join doesn't expose ?server_name=, so when our
homeserver hadn't fully processed the federation invite by the time
we turned around to join, it returned M_FORBIDDEN with no retry.
2. Even with retry, on_invite only fires once per sync delivery; if
joining failed we never tried again.
3. client.invited_rooms is populated incrementally from the persisted
next_batch, so synapse won't re-send invites that arrived in a prior
session, which means the startup loop was a no-op for stuck invites.

Add a try_join helper that calls /join/{room_id}?server_name=<inviter>
directly via _send and retries with exponential backoff, and a
fetch_pending_invites helper that does a fresh /sync (no since token)
to ask the server for the real invite set. Use both in on_invite and
in the startup loop.

+52 -6
+52 -6
5pkgs/archiver-bot/archiver_bot/__init__.py
··· 6 6 import sys 7 7 from pathlib import Path 8 8 9 + from urllib.parse import quote 10 + 9 11 import aiohttp 10 12 from aiohttp import web 11 13 from nio import ( ··· 17 19 MegolmEvent, 18 20 RoomMessageText, 19 21 ) 22 + from nio.api import Api 23 + from nio.responses import JoinError, JoinResponse 20 24 21 25 logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") 22 26 log = logging.getLogger("archiver-bot") ··· 520 524 ) 521 525 522 526 527 + def _server_name(matrix_id: str | None) -> str | None: 528 + if matrix_id and ":" in matrix_id: 529 + return matrix_id.split(":", 1)[1] 530 + return None 531 + 532 + 533 + async def try_join(client: AsyncClient, room_id: str, via: str | None = None, attempts: int = 6) -> bool: 534 + # client.join doesn't expose server_name; pass it through to help our 535 + # homeserver resolve the room over federation. Retry with backoff because 536 + # invites delivered via sync can briefly race against the server's own 537 + # room-state view (M_FORBIDDEN even though the invite just arrived). 538 + method, path = Api.join(client.access_token, room_id) 539 + if via: 540 + path += "&server_name=" + quote(via) 541 + for attempt in range(1, attempts + 1): 542 + result = await client._send(JoinResponse, method, path) 543 + if not isinstance(result, JoinError): 544 + log.info(f"Joined {room_id} on attempt {attempt}") 545 + return True 546 + log.warning(f"Join attempt {attempt}/{attempts} for {room_id} failed: {result}") 547 + if attempt < attempts: 548 + await asyncio.sleep(2 ** (attempt - 1)) 549 + log.error(f"Gave up joining {room_id} after {attempts} attempts") 550 + return False 551 + 552 + 553 + async def fetch_pending_invites(client: AsyncClient, http: aiohttp.ClientSession) -> list[str]: 554 + # nio's incremental sync uses the persisted next_batch and synapse will 555 + # not re-deliver invites that were already seen in a prior session, so 556 + # client.invited_rooms misses anything the bot saw and failed to join 557 + # before a restart. Ask the server explicitly with a fresh /sync. 558 + url = f"{client.homeserver}/_matrix/client/v3/sync?timeout=0" 559 + headers = {"Authorization": f"Bearer {client.access_token}"} 560 + try: 561 + async with http.get(url, headers=headers) as resp: 562 + data = await resp.json() 563 + return list(data.get("rooms", {}).get("invite", {}).keys()) 564 + except Exception as e: 565 + log.warning(f"Failed to fetch pending invites: {e}") 566 + return [] 567 + 568 + 523 569 async def on_invite(client: AsyncClient, room: MatrixRoom, event: InviteMemberEvent): 524 570 """Auto-join rooms when invited.""" 525 571 log.info(f"Invite event in {room.room_id}: state_key={event.state_key}, sender={event.sender}, membership={event.membership}, our_id={client.user_id}") 526 572 if event.state_key != client.user_id: 527 573 return 528 574 log.info(f"Joining {room.room_id} (invited by {event.sender})...") 529 - result = await client.join(room.room_id) 530 - log.info(f"Join result for {room.room_id}: {result}") 575 + await try_join(client, room.room_id, via=_server_name(event.sender)) 531 576 532 577 533 578 async def handle_radarr_webhook(request): ··· 653 698 await client.close() 654 699 sys.exit(1) 655 700 656 - # Join any rooms we were invited to while offline 657 - for room_id, room in client.invited_rooms.items(): 701 + # Join any rooms we have a pending invite for (server-side truth; see 702 + # fetch_pending_invites for why we can't rely on client.invited_rooms). 703 + pending = await fetch_pending_invites(client, http) 704 + for room_id in pending: 658 705 log.info(f"Joining pending invite for {room_id}...") 659 - result = await client.join(room_id) 660 - log.info(f"Join result for {room_id}: {result}") 706 + await try_join(client, room_id, via=_server_name(room_id)) 661 707 662 708 # E2EE setup 663 709 if client.olm: