This repository has no description
0

Configure Feed

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

archiver-bot: reply in threads

Make every bot response a thread reply instead of a fresh top-level
message. send_notice gains a thread_root kwarg that adds m.relates_to
with rel_type=m.thread plus the m.in_reply_to fallback. on_message
picks the thread root via thread_root_for(): if the user's message is
already inside a thread we reply into that one, otherwise we start a
new thread rooted on their message.

For Radarr/Sonarr download notifications to thread back to the
original request, tracked_requests.json now stores the request's
event_id alongside the room. load_tracked normalizes legacy bare-
string entries so existing tracked items keep working (just without
threading on the eventual notification).

+76 -35
+76 -35
5pkgs/archiver-bot/archiver_bot/__init__.py
··· 52 52 53 53 54 54 def load_tracked() -> dict: 55 - """Load tracked requests from disk. Keys are 'movie:TMDBID' or 'tv:TMDBID', values are lists of room_ids.""" 55 + """Load tracked requests. Schema: {key: [{"room": id, "event": id|None}, ...]}. 56 + Legacy entries (bare room_id strings) are normalized to event=None.""" 56 57 try: 57 - return json.loads(TRACKING_FILE.read_text()) 58 + raw = json.loads(TRACKING_FILE.read_text()) 58 59 except Exception: 59 60 return {} 61 + out = {} 62 + for key, entries in raw.items(): 63 + normalized = [] 64 + for e in entries: 65 + if isinstance(e, str): 66 + normalized.append({"room": e, "event": None}) 67 + elif isinstance(e, dict) and e.get("room"): 68 + normalized.append({"room": e["room"], "event": e.get("event")}) 69 + out[key] = normalized 70 + return out 60 71 61 72 62 73 def save_tracked(tracked: dict): ··· 64 75 TRACKING_FILE.write_text(json.dumps(tracked)) 65 76 66 77 67 - def track_request(media_type: str, tmdb_id: int, room_id: str): 68 - """Track that a room requested this media.""" 78 + def track_request(media_type: str, tmdb_id: int, room_id: str, event_id: str | None = None): 79 + """Track that a room requested this media; event_id is the originating 80 + message so download notifications can thread back into that conversation.""" 69 81 tracked = load_tracked() 70 82 key = f"{media_type}:{tmdb_id}" 71 - rooms = tracked.get(key, []) 72 - if room_id not in rooms: 73 - rooms.append(room_id) 74 - tracked[key] = rooms 83 + entries = tracked.get(key, []) 84 + for entry in entries: 85 + if entry["room"] == room_id: 86 + if event_id is not None: 87 + entry["event"] = event_id 88 + break 89 + else: 90 + entries.append({"room": room_id, "event": event_id}) 91 + tracked[key] = entries 75 92 save_tracked(tracked) 76 93 77 94 78 - def pop_tracked(media_type: str, tmdb_id: int) -> list[str]: 79 - """Get and remove tracked rooms for this media.""" 95 + def pop_tracked(media_type: str, tmdb_id: int) -> list[dict]: 96 + """Get and remove tracked entries for this media.""" 80 97 tracked = load_tracked() 81 98 key = f"{media_type}:{tmdb_id}" 82 - rooms = tracked.pop(key, []) 99 + entries = tracked.pop(key, []) 83 100 save_tracked(tracked) 84 - return rooms 101 + return entries 85 102 86 103 87 104 def make_flax_link(path: str) -> str: ··· 351 368 return None 352 369 353 370 354 - async def request_media(session: aiohttp.ClientSession, media: dict, room_id: str) -> tuple[str, str]: 371 + async def request_media(session: aiohttp.ClientSession, media: dict, room_id: str, event_id: str | None = None) -> tuple[str, str]: 355 372 """Request a movie or TV show via Jellyseerr. Returns (plain, html) status message.""" 356 373 media_type = media["mediaType"] 357 374 media_id = media["id"] ··· 376 393 return (f"✅ {display} is already available!\n{links_plain}", f"✅ <b>{display}</b> is already available!<br>{links_html}") 377 394 return (f"✅ {display} is already available!", f"✅ <b>{display}</b> is already available!") 378 395 elif status in (2, 3, 4): 379 - track_request(media_type, media_id, room_id) 396 + track_request(media_type, media_id, room_id, event_id) 380 397 return (f"⏳ {display} has already been requested and is being processed.", f"⏳ <b>{display}</b> has already been requested and is being processed.") 381 398 382 399 payload = {"mediaType": media_type, "mediaId": media_id} ··· 399 416 result = await jellyseerr_request(session, "/request", method="POST", json=payload) 400 417 401 418 if "id" in result: 402 - track_request(media_type, media_id, room_id) 419 + track_request(media_type, media_id, room_id, event_id) 403 420 return (f"🎬 Successfully requested {display}! I'll notify you when it's downloaded.", f"🎬 Successfully requested <b>{display}</b>! I'll notify you when it's downloaded.") 404 421 else: 405 422 error = result.get("message", json.dumps(result)) ··· 420 437 log.info(f"Verified device {device.device_id} of {user_id}") 421 438 422 439 423 - async def send_notice(client: AsyncClient, room_id: str, plain: str, html: str): 424 - """Send a notice message to a room.""" 440 + async def send_notice(client: AsyncClient, room_id: str, plain: str, html: str, thread_root: str | None = None): 441 + """Send a notice message to a room. If thread_root is given, the message 442 + becomes a reply within that thread (with the in_reply_to fallback so 443 + non-threaded clients still render a quote).""" 425 444 await trust_room_devices(client, room_id) 426 - await client.room_send( 427 - room_id, 428 - "m.room.message", 429 - {"msgtype": "m.notice", "body": plain, "format": "org.matrix.custom.html", "formatted_body": html}, 430 - ) 445 + content = { 446 + "msgtype": "m.notice", 447 + "body": plain, 448 + "format": "org.matrix.custom.html", 449 + "formatted_body": html, 450 + } 451 + if thread_root: 452 + content["m.relates_to"] = { 453 + "rel_type": "m.thread", 454 + "event_id": thread_root, 455 + "is_falling_back": True, 456 + "m.in_reply_to": {"event_id": thread_root}, 457 + } 458 + await client.room_send(room_id, "m.room.message", content) 459 + 460 + 461 + def thread_root_for(event) -> str: 462 + """If event is already part of a thread, return that thread's root; 463 + otherwise return event's own id (start a new thread on it).""" 464 + rel = (getattr(event, "source", None) or {}).get("content", {}).get("m.relates_to") or {} 465 + if rel.get("rel_type") == "m.thread" and rel.get("event_id"): 466 + return rel["event_id"] 467 + return event.event_id 431 468 432 469 433 470 async def on_message(client: AsyncClient, room: MatrixRoom, event: RoomMessageText, http: aiohttp.ClientSession): ··· 438 475 439 476 body = event.body 440 477 log.info(f"Message from {event.sender} in {room.display_name}: {body[:100]}") 478 + thread_root = thread_root_for(event) 441 479 442 480 # Help command 443 481 if body.strip().lower() in ("help", "!help"): ··· 459 497 "<code>https://www.themoviedb.org/movie/603</code><br>" 460 498 "<code>https://www.themoviedb.org/tv/1399</code>" 461 499 ) 462 - await send_notice(client, room.room_id, plain, html) 500 + await send_notice(client, room.room_id, plain, html, thread_root=thread_root) 463 501 return 464 502 465 503 # Collect all media references from the message ··· 471 509 if media is None: 472 510 results.append((f"❓ Could not find anything for {imdb_id}.", f"❓ Could not find anything for <code>{imdb_id}</code>.")) 473 511 else: 474 - results.append(await request_media(http, media, room.room_id)) 512 + results.append(await request_media(http, media, room.room_id, thread_root)) 475 513 476 514 for tmdb_id in set(TMDB_MOVIE_RE.findall(body)): 477 515 log.info(f"Found TMDB movie link {tmdb_id} in {room.display_name} from {event.sender}") ··· 483 521 media["mediaInfo"] = details.get("mediaInfo") 484 522 except Exception as e: 485 523 log.warning(f"Failed to fetch TMDB movie {tmdb_id}: {e}") 486 - results.append(await request_media(http, media, room.room_id)) 524 + results.append(await request_media(http, media, room.room_id, thread_root)) 487 525 488 526 for tmdb_id in set(TMDB_TV_RE.findall(body)): 489 527 log.info(f"Found TMDB TV link {tmdb_id} in {room.display_name} from {event.sender}") ··· 495 533 media["mediaInfo"] = details.get("mediaInfo") 496 534 except Exception as e: 497 535 log.warning(f"Failed to fetch TMDB TV {tmdb_id}: {e}") 498 - results.append(await request_media(http, media, room.room_id)) 536 + results.append(await request_media(http, media, room.room_id, thread_root)) 499 537 500 538 for result in results: 501 539 if result is not None: 502 540 plain, html = result 503 - await send_notice(client, room.room_id, plain, html) 541 + await send_notice(client, room.room_id, plain, html, thread_root=thread_root) 504 542 505 543 506 544 async def on_megolm(client: AsyncClient, room: MatrixRoom, event: MegolmEvent): ··· 521 559 room.room_id, 522 560 "🔒 I couldn't decrypt your message. Please try again or re-invite me to this room.", 523 561 "🔒 I couldn't decrypt your message. Please try again or re-invite me to this room.", 562 + thread_root=event.event_id, 524 563 ) 525 564 526 565 ··· 593 632 file_path = movie_file.get("path") or movie.get("folderPath", "") 594 633 595 634 if event_type == "Download" and tmdb_id: 596 - rooms = pop_tracked("movie", tmdb_id) 597 - if rooms: 635 + entries = pop_tracked("movie", tmdb_id) 636 + if entries: 598 637 client = request.app["matrix_client"] 599 638 http = request.app["http_session"] 600 639 # trigger library scan so Jellyfin picks up the new file ··· 608 647 links_plain, links_html = links 609 648 plain = f"✅ {display} has been downloaded and is now available!\n{links_plain}" 610 649 html = f"✅ <b>{display}</b> has been downloaded and is now available!<br>{links_html}" 611 - for room_id in rooms: 650 + for entry in entries: 651 + room_id = entry["room"] 612 652 try: 613 - await send_notice(client, room_id, plain, html) 653 + await send_notice(client, room_id, plain, html, thread_root=entry.get("event")) 614 654 except Exception as e: 615 655 log.warning(f"Failed to notify room {room_id}: {e}") 616 656 except Exception as e: ··· 640 680 ep_info = f" ({', '.join(ep_nums)})" 641 681 642 682 if event_type == "Download" and tmdb_id: 643 - rooms = pop_tracked("tv", tmdb_id) 644 - if rooms: 683 + entries = pop_tracked("tv", tmdb_id) 684 + if entries: 645 685 client = request.app["matrix_client"] 646 686 http = request.app["http_session"] 647 687 folder_path = series.get("path", "") ··· 656 696 links_plain, links_html = links 657 697 plain = f"✅ {display}{ep_info} has been downloaded and is now available!\n{links_plain}" 658 698 html = f"✅ <b>{display}</b>{ep_info} has been downloaded and is now available!<br>{links_html}" 659 - for room_id in rooms: 699 + for entry in entries: 700 + room_id = entry["room"] 660 701 try: 661 - await send_notice(client, room_id, plain, html) 702 + await send_notice(client, room_id, plain, html, thread_root=entry.get("event")) 662 703 except Exception as e: 663 704 log.warning(f"Failed to notify room {room_id}: {e}") 664 705 except Exception as e: