This repository has no description
0

Configure Feed

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

1import asyncio 2import json 3import logging 4import os 5import re 6import sys 7from pathlib import Path 8 9import aiohttp 10from aiohttp import web 11from nio import ( 12 AsyncClient, 13 AsyncClientConfig, 14 InviteMemberEvent, 15 LoginResponse, 16 MatrixRoom, 17 RoomMessageText, 18) 19 20logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") 21log = logging.getLogger("archiver-bot") 22 23IMDB_RE = re.compile(r"https?://(?:www\.)?imdb\.com/title/(tt\d+)") 24TMDB_MOVIE_RE = re.compile(r"https?://(?:www\.)?themoviedb\.org/movie/(\d+)") 25TMDB_TV_RE = re.compile(r"https?://(?:www\.)?themoviedb\.org/tv/(\d+)") 26 27JELLYSEERR_URL = os.environ.get("JELLYSEERR_URL", "http://localhost:5055") 28JELLYSEERR_API_KEY = os.environ["JELLYSEERR_API_KEY"] 29RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "") 30SONARR_API_KEY = os.environ.get("SONARR_API_KEY", "") 31MATRIX_HOMESERVER = os.environ["MATRIX_HOMESERVER"] 32MATRIX_ACCESS_TOKEN = os.environ["MATRIX_ACCESS_TOKEN"] 33MATRIX_USER_ID = os.environ.get("MATRIX_USER_ID", "@archiver:lassul.us") 34MATRIX_DEVICE_ID = os.environ.get("MATRIX_DEVICE_ID", "ARCHIVER") 35STORE_PATH = os.environ.get("STORE_PATH", "/var/lib/archiver-bot") 36WEBHOOK_PORT = int(os.environ.get("WEBHOOK_PORT", "8099")) 37FLAX_URL = os.environ.get("FLAX_URL", "https://flax.lassul.us") 38FLIX_URL = os.environ.get("FLIX_URL", "https://flix.lassul.us") 39JELLYFIN_URL = os.environ.get("JELLYFIN_URL", "http://localhost:8096") 40JELLYFIN_API_KEY = os.environ.get("JELLYFIN_API_KEY", "") 41IPFS_GATEWAY_URL = os.environ.get("IPFS_GATEWAY_URL", "https://ipfs.lassul.us") 42MEDIA_PATH_PREFIX = os.environ.get("MEDIA_PATH_PREFIX", "/var/download/") 43IPFS_CID_MAP = os.environ.get("IPFS_CID_MAP", "/var/lib/ipfs/cid-map.txt") 44IPFS_PATH_PREFIX = os.environ.get("IPFS_PATH_PREFIX", "/var/lib/ipfs/download/") 45 46# Persistent tracking of requested media -> rooms 47TRACKING_FILE = Path(STORE_PATH) / "tracked_requests.json" 48 49 50def load_tracked() -> dict: 51 """Load tracked requests from disk. Keys are 'movie:TMDBID' or 'tv:TMDBID', values are lists of room_ids.""" 52 try: 53 return json.loads(TRACKING_FILE.read_text()) 54 except Exception: 55 return {} 56 57 58def save_tracked(tracked: dict): 59 """Save tracked requests to disk.""" 60 TRACKING_FILE.write_text(json.dumps(tracked)) 61 62 63def track_request(media_type: str, tmdb_id: int, room_id: str): 64 """Track that a room requested this media.""" 65 tracked = load_tracked() 66 key = f"{media_type}:{tmdb_id}" 67 rooms = tracked.get(key, []) 68 if room_id not in rooms: 69 rooms.append(room_id) 70 tracked[key] = rooms 71 save_tracked(tracked) 72 73 74def pop_tracked(media_type: str, tmdb_id: int) -> list[str]: 75 """Get and remove tracked rooms for this media.""" 76 tracked = load_tracked() 77 key = f"{media_type}:{tmdb_id}" 78 rooms = tracked.pop(key, []) 79 save_tracked(tracked) 80 return rooms 81 82 83def make_flax_link(path: str) -> str: 84 """Turn a local path like /var/download/movies/Title (Year)/file.mp4 into a flax.lassul.us URL.""" 85 from urllib.parse import quote 86 relative = path 87 if relative.startswith(MEDIA_PATH_PREFIX): 88 relative = relative[len(MEDIA_PATH_PREFIX):] 89 return f"{FLAX_URL}/{quote(relative)}" 90 91 92def lookup_cid(path: str) -> str | None: 93 """Look up the IPFS CID for a file from the cid-map.""" 94 ipfs_path = path 95 if ipfs_path.startswith(MEDIA_PATH_PREFIX): 96 ipfs_path = IPFS_PATH_PREFIX + ipfs_path[len(MEDIA_PATH_PREFIX):] 97 try: 98 with open(IPFS_CID_MAP) as f: 99 for line in f: 100 line = line.strip() 101 if not line: 102 continue 103 cid, _, file_path = line.partition(" ") 104 if file_path == ipfs_path: 105 return cid 106 except Exception as e: 107 log.warning(f"Failed to look up CID for {path}: {e}") 108 return None 109 110 111async def trigger_jellyfin_scan(session: aiohttp.ClientSession): 112 """Trigger a Jellyfin library scan and Jellyseerr sync.""" 113 try: 114 async with session.post( 115 f"{JELLYFIN_URL}/Library/Refresh", 116 headers={"X-Emby-Token": JELLYFIN_API_KEY}, 117 ) as resp: 118 log.info(f"Triggered Jellyfin library scan: {resp.status}") 119 # also trigger Jellyseerr sync so it picks up the new jellyfinMediaId 120 await jellyseerr_request(session, "/settings/jellyfin/sync", method="POST") 121 log.info("Triggered Jellyseerr sync") 122 except Exception as e: 123 log.warning(f"Failed to trigger library scan: {e}") 124 125 126async def lookup_jellyfin_id(session: aiohttp.ClientSession, media_type: str, tmdb_id: int) -> str | None: 127 """Look up Jellyfin item ID via Jellyseerr's mediaInfo or Jellyfin API.""" 128 # Try Jellyseerr first — it tracks the Jellyfin media ID 129 # Try Jellyseerr first 130 try: 131 endpoint = f"/movie/{tmdb_id}" if media_type == "movie" else f"/tv/{tmdb_id}" 132 details = await jellyseerr_request(session, endpoint) 133 media_info = details.get("mediaInfo") 134 if media_info: 135 jf_id = media_info.get("jellyfinMediaId") 136 if jf_id: 137 log.info(f"Jellyfin ID from Jellyseerr for {media_type}:{tmdb_id} = {jf_id}") 138 return jf_id 139 except Exception as e: 140 log.warning(f"Jellyseerr Jellyfin ID lookup failed for {media_type}:{tmdb_id}: {e}") 141 142 # Fallback: query Jellyfin directly, searching by provider ID 143 if JELLYFIN_API_KEY: 144 try: 145 item_type = "Movie" if media_type == "movie" else "Series" 146 async with session.get( 147 f"{JELLYFIN_URL}/Items", 148 params={ 149 "IncludeItemTypes": item_type, 150 "Recursive": "true", 151 "Fields": "ProviderIds", 152 }, 153 headers={"X-Emby-Token": JELLYFIN_API_KEY}, 154 ) as resp: 155 data = await resp.json() 156 for item in data.get("Items", []): 157 provider_ids = item.get("ProviderIds", {}) 158 if str(provider_ids.get("Tmdb")) == str(tmdb_id): 159 jf_id = item.get("Id") 160 log.info(f"Jellyfin ID from Jellyfin API for {media_type}:{tmdb_id} = {jf_id}") 161 return jf_id 162 except Exception as e: 163 log.warning(f"Jellyfin API lookup failed for {media_type}:{tmdb_id}: {e}") 164 165 log.info(f"No Jellyfin ID found for {media_type}:{tmdb_id}") 166 return None 167 168 169def make_media_links(path: str, is_dir: bool = False, jellyfin_id: str | None = None) -> tuple[str, str]: 170 """Generate plain and HTML text with all available links for a media path.""" 171 from urllib.parse import quote 172 173 lines_plain = [] 174 lines_html = [] 175 176 # Flax link (direct file download) 177 flax = make_flax_link(path) 178 lines_plain.append(f"📁 Download: {flax}") 179 lines_html.append(f'📁 Download: <a href="{flax}">{flax}</a>') 180 181 # Jellyfin link (streaming) 182 if jellyfin_id: 183 flix_link = f"{FLIX_URL}/web/#/details?id={jellyfin_id}" 184 else: 185 flix_link = FLIX_URL 186 lines_plain.append(f"🎬 Stream: {flix_link}") 187 lines_html.append(f'🎬 Stream: <a href="{flix_link}">{flix_link}</a>') 188 189 # IPFS link (compute CID for the file) 190 if not is_dir: 191 cid = lookup_cid(path) 192 if cid: 193 ipfs_link = f"{IPFS_GATEWAY_URL}/ipfs/{cid}" 194 lines_plain.append(f"🌐 IPFS: {ipfs_link}") 195 lines_html.append(f'🌐 IPFS: <a href="{ipfs_link}">{ipfs_link}</a>') 196 lines_plain.append(f" ipfs get {cid}") 197 lines_html.append(f" <code>ipfs get {cid}</code>") 198 199 return ("\n".join(lines_plain), "<br>".join(lines_html)) 200 201 202async def jellyseerr_request(session: aiohttp.ClientSession, path: str, **kwargs) -> dict: 203 """Make an authenticated request to Jellyseerr.""" 204 headers = { 205 "X-Api-Key": JELLYSEERR_API_KEY, 206 "Content-Type": "application/json", 207 "Accept": "application/json", 208 } 209 method = kwargs.pop("method", "GET") 210 url = f"{JELLYSEERR_URL}/api/v1{path}" 211 async with session.request(method, url, headers=headers, **kwargs) as resp: 212 return await resp.json() 213 214 215async def find_by_imdb(session: aiohttp.ClientSession, imdb_id: str) -> dict | None: 216 """Look up a movie or TV show by IMDb ID via Radarr/Sonarr, returning Jellyseerr-compatible data.""" 217 218 # Try Radarr (movies) first 219 try: 220 async with session.get( 221 f"http://localhost:7878/api/v3/movie/lookup?term=imdb:{imdb_id}", 222 headers={"X-Api-Key": RADARR_API_KEY}, 223 ) as resp: 224 results = await resp.json() 225 if results and len(results) > 0: 226 tmdb_id = results[0].get("tmdbId") 227 title = results[0].get("title", "Unknown") 228 year = str(results[0].get("year", "")) 229 if tmdb_id: 230 log.info(f"Radarr resolved {imdb_id} -> TMDB {tmdb_id} ({title})") 231 return { 232 "id": tmdb_id, 233 "mediaType": "movie", 234 "title": title, 235 "releaseDate": f"{year}-01-01" if year else "", 236 } 237 except Exception as e: 238 log.warning(f"Radarr lookup failed: {e}") 239 240 # Try Sonarr (TV shows) 241 try: 242 async with session.get( 243 f"http://localhost:8989/api/v3/series/lookup?term=imdb:{imdb_id}", 244 headers={"X-Api-Key": SONARR_API_KEY}, 245 ) as resp: 246 results = await resp.json() 247 if results and len(results) > 0: 248 tmdb_id = results[0].get("tmdbId", 0) 249 title = results[0].get("title", "Unknown") 250 year = str(results[0].get("year", "")) 251 if tmdb_id: 252 log.info(f"Sonarr resolved {imdb_id} -> TMDB {tmdb_id} ({title})") 253 return { 254 "id": tmdb_id, 255 "mediaType": "tv", 256 "name": title, 257 "firstAirDate": f"{year}-01-01" if year else "", 258 } 259 except Exception as e: 260 log.warning(f"Sonarr lookup failed: {e}") 261 262 return None 263 264 265async def lookup_media_path(session: aiohttp.ClientSession, media_type: str, tmdb_id: int) -> str | None: 266 """Look up the path for a movie file or show folder from Radarr/Sonarr by TMDB ID.""" 267 try: 268 if media_type == "movie": 269 async with session.get( 270 f"http://localhost:7878/api/v3/movie?tmdbId={tmdb_id}", 271 headers={"X-Api-Key": RADARR_API_KEY}, 272 ) as resp: 273 results = await resp.json() 274 if results and len(results) > 0: 275 # Return direct file path if available, otherwise folder 276 movie_file = results[0].get("movieFile", {}) 277 if movie_file and movie_file.get("path"): 278 return movie_file["path"] 279 return results[0].get("path") or results[0].get("folderName") 280 else: 281 async with session.get( 282 f"http://localhost:8989/api/v3/series?tvdbId={tmdb_id}", 283 headers={"X-Api-Key": SONARR_API_KEY}, 284 ) as resp: 285 results = await resp.json() 286 if results and len(results) > 0: 287 return results[0].get("path") 288 except Exception as e: 289 log.warning(f"Media path lookup failed for {media_type}:{tmdb_id}: {e}") 290 return None 291 292 293async def request_media(session: aiohttp.ClientSession, media: dict, room_id: str) -> tuple[str, str]: 294 """Request a movie or TV show via Jellyseerr. Returns (plain, html) status message.""" 295 media_type = media["mediaType"] 296 media_id = media["id"] 297 title = media.get("title") or media.get("name") or "Unknown" 298 year = (media.get("releaseDate") or media.get("firstAirDate") or "")[:4] 299 display = f"{title} ({year})" if year else title 300 301 # Check if already available or requested 302 existing = media.get("mediaInfo") 303 if existing: 304 status = existing.get("status") 305 # status 5 = available, 4 = partially available, 3 = processing, 2 = pending 306 if status == 5: 307 path = await lookup_media_path(session, media_type, media_id) 308 if path: 309 is_dir = media_type == "tv" 310 jf_id = await lookup_jellyfin_id(session, media_type, media_id) 311 links_plain, links_html = make_media_links(path, is_dir=is_dir, jellyfin_id=jf_id) 312 return (f"✅ {display} is already available!\n{links_plain}", f"✅ <b>{display}</b> is already available!<br>{links_html}") 313 return (f"✅ {display} is already available!", f"✅ <b>{display}</b> is already available!") 314 elif status in (2, 3, 4): 315 track_request(media_type, media_id, room_id) 316 return (f"⏳ {display} has already been requested and is being processed.", f"⏳ <b>{display}</b> has already been requested and is being processed.") 317 318 payload = {"mediaType": media_type, "mediaId": media_id} 319 if media_type == "tv": 320 payload["seasons"] = "all" 321 322 # Check if Radarr/Sonarr already has the file before requesting 323 existing_path = await lookup_media_path(session, media_type, media_id) 324 if existing_path: 325 is_dir = media_type == "tv" 326 jf_id = await lookup_jellyfin_id(session, media_type, media_id) 327 links_plain, links_html = make_media_links(existing_path, is_dir=is_dir, jellyfin_id=jf_id) 328 # Still send the request to Jellyseerr so it's tracked there too 329 await jellyseerr_request(session, "/request", method="POST", json=payload) 330 return (f"✅ {display} is already available!\n{links_plain}", f"✅ <b>{display}</b> is already available!<br>{links_html}") 331 332 result = await jellyseerr_request(session, "/request", method="POST", json=payload) 333 334 if "id" in result: 335 track_request(media_type, media_id, room_id) 336 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.") 337 else: 338 error = result.get("message", json.dumps(result)) 339 return (f"❌ Failed to request {display}: {error}", f"❌ Failed to request <b>{display}</b>: {error}") 340 341 342async def trust_room_devices(client: AsyncClient, room_id: str): 343 """Trust all devices of all members in a room.""" 344 if not client.olm: 345 return 346 room = client.rooms.get(room_id) 347 if not room: 348 return 349 for user_id in room.users: 350 for device in client.device_store.active_user_devices(user_id): 351 if not client.olm.is_device_verified(device): 352 client.verify_device(device) 353 log.info(f"Verified device {device.device_id} of {user_id}") 354 355 356async def send_notice(client: AsyncClient, room_id: str, plain: str, html: str): 357 """Send a notice message to a room.""" 358 await trust_room_devices(client, room_id) 359 await client.room_send( 360 room_id, 361 "m.room.message", 362 {"msgtype": "m.notice", "body": plain, "format": "org.matrix.custom.html", "formatted_body": html}, 363 ) 364 365 366async def on_message(client: AsyncClient, room: MatrixRoom, event: RoomMessageText, http: aiohttp.ClientSession): 367 """Handle messages in rooms, looking for IMDb/TMDB links.""" 368 # Ignore our own messages 369 if event.sender == client.user_id: 370 return 371 372 body = event.body 373 log.info(f"Message from {event.sender} in {room.display_name}: {body[:100]}") 374 375 # Help command 376 if body.strip().lower() in ("help", "!help"): 377 plain = ( 378 "Archiver Bot\n\n" 379 "Send me an IMDb or TMDB link and I'll request the movie/show on Jellyseerr.\n" 380 "I'll notify you when the download is complete.\n\n" 381 "Examples:\n" 382 " https://www.imdb.com/title/tt0133093/\n" 383 " https://www.themoviedb.org/movie/603\n" 384 " https://www.themoviedb.org/tv/1399" 385 ) 386 html = ( 387 "<b>Archiver Bot</b><br><br>" 388 "Send me an IMDb or TMDB link and I'll request the movie/show on Jellyseerr.<br>" 389 "I'll notify you when the download is complete.<br><br>" 390 "Examples:<br>" 391 "<code>https://www.imdb.com/title/tt0133093/</code><br>" 392 "<code>https://www.themoviedb.org/movie/603</code><br>" 393 "<code>https://www.themoviedb.org/tv/1399</code>" 394 ) 395 await send_notice(client, room.room_id, plain, html) 396 return 397 398 # Collect all media references from the message 399 results = [] 400 401 for imdb_id in set(IMDB_RE.findall(body)): 402 log.info(f"Found IMDb link {imdb_id} in {room.display_name} from {event.sender}") 403 media = await find_by_imdb(http, imdb_id) 404 if media is None: 405 results.append((f"❓ Could not find anything for {imdb_id}.", f"❓ Could not find anything for <code>{imdb_id}</code>.")) 406 else: 407 results.append(await request_media(http, media, room.room_id)) 408 409 for tmdb_id in set(TMDB_MOVIE_RE.findall(body)): 410 log.info(f"Found TMDB movie link {tmdb_id} in {room.display_name} from {event.sender}") 411 media = {"id": int(tmdb_id), "mediaType": "movie"} 412 try: 413 details = await jellyseerr_request(http, f"/movie/{tmdb_id}") 414 media["title"] = details.get("title", "Unknown") 415 media["releaseDate"] = details.get("releaseDate", "") 416 media["mediaInfo"] = details.get("mediaInfo") 417 except Exception as e: 418 log.warning(f"Failed to fetch TMDB movie {tmdb_id}: {e}") 419 results.append(await request_media(http, media, room.room_id)) 420 421 for tmdb_id in set(TMDB_TV_RE.findall(body)): 422 log.info(f"Found TMDB TV link {tmdb_id} in {room.display_name} from {event.sender}") 423 media = {"id": int(tmdb_id), "mediaType": "tv"} 424 try: 425 details = await jellyseerr_request(http, f"/tv/{tmdb_id}") 426 media["name"] = details.get("name", "Unknown") 427 media["firstAirDate"] = details.get("firstAirDate", "") 428 media["mediaInfo"] = details.get("mediaInfo") 429 except Exception as e: 430 log.warning(f"Failed to fetch TMDB TV {tmdb_id}: {e}") 431 results.append(await request_media(http, media, room.room_id)) 432 433 for plain, html in results: 434 await send_notice(client, room.room_id, plain, html) 435 436 437async def on_invite(client: AsyncClient, room: MatrixRoom, event: InviteMemberEvent): 438 """Auto-join rooms when invited.""" 439 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}") 440 if event.state_key != client.user_id: 441 return 442 log.info(f"Joining {room.room_id} (invited by {event.sender})...") 443 result = await client.join(room.room_id) 444 log.info(f"Join result for {room.room_id}: {result}") 445 446 447async def handle_radarr_webhook(request): 448 """Handle Radarr webhook for download completion.""" 449 try: 450 data = await request.json() 451 event_type = data.get("eventType", "") 452 log.info(f"Radarr webhook: {event_type}") 453 454 if event_type in ("Download", "MovieFileDelete"): 455 movie = data.get("movie", {}) 456 tmdb_id = movie.get("tmdbId") 457 title = movie.get("title", "Unknown") 458 year = movie.get("year", "") 459 display = f"{title} ({year})" if year else title 460 # Prefer the actual movie file path, fall back to folder 461 movie_file = data.get("movieFile", {}) 462 file_path = movie_file.get("path") or movie.get("folderPath", "") 463 464 if event_type == "Download" and tmdb_id: 465 rooms = pop_tracked("movie", tmdb_id) 466 if rooms: 467 client = request.app["matrix_client"] 468 http = request.app["http_session"] 469 # trigger library scan so Jellyfin picks up the new file 470 await trigger_jellyfin_scan(http) 471 await asyncio.sleep(10) 472 jf_id = await lookup_jellyfin_id(http, "movie", tmdb_id) 473 links_plain, links_html = make_media_links(file_path, jellyfin_id=jf_id) 474 plain = f"✅ {display} has been downloaded and is now available!\n{links_plain}" 475 html = f"✅ <b>{display}</b> has been downloaded and is now available!<br>{links_html}" 476 for room_id in rooms: 477 try: 478 await send_notice(client, room_id, plain, html) 479 except Exception as e: 480 log.warning(f"Failed to notify room {room_id}: {e}") 481 except Exception as e: 482 log.exception(f"Error handling Radarr webhook: {e}") 483 484 return web.Response(text="ok") 485 486 487async def handle_sonarr_webhook(request): 488 """Handle Sonarr webhook for download completion.""" 489 try: 490 data = await request.json() 491 event_type = data.get("eventType", "") 492 log.info(f"Sonarr webhook: {event_type}") 493 494 if event_type in ("Download", "EpisodeFileDelete"): 495 series = data.get("series", {}) 496 tmdb_id = series.get("tmdbId") 497 title = series.get("title", "Unknown") 498 year = series.get("year", "") 499 display = f"{title} ({year})" if year else title 500 501 episodes = data.get("episodes", []) 502 ep_info = "" 503 if episodes: 504 ep_nums = [f"S{ep.get('seasonNumber', 0):02d}E{ep.get('episodeNumber', 0):02d}" for ep in episodes] 505 ep_info = f" ({', '.join(ep_nums)})" 506 507 if event_type == "Download" and tmdb_id: 508 rooms = pop_tracked("tv", tmdb_id) 509 if rooms: 510 client = request.app["matrix_client"] 511 http = request.app["http_session"] 512 folder_path = series.get("path", "") 513 # trigger library scan so Jellyfin picks up the new file 514 await trigger_jellyfin_scan(http) 515 await asyncio.sleep(10) 516 jf_id = await lookup_jellyfin_id(http, "tv", tmdb_id) 517 links_plain, links_html = make_media_links(folder_path, is_dir=True, jellyfin_id=jf_id) 518 plain = f"✅ {display}{ep_info} has been downloaded and is now available!\n{links_plain}" 519 html = f"✅ <b>{display}</b>{ep_info} has been downloaded and is now available!<br>{links_html}" 520 for room_id in rooms: 521 try: 522 await send_notice(client, room_id, plain, html) 523 except Exception as e: 524 log.warning(f"Failed to notify room {room_id}: {e}") 525 except Exception as e: 526 log.exception(f"Error handling Sonarr webhook: {e}") 527 528 return web.Response(text="ok") 529 530 531async def run(): 532 """Main bot loop.""" 533 client_config = AsyncClientConfig( 534 store_sync_tokens=True, 535 encryption_enabled=True, 536 ) 537 client = AsyncClient( 538 MATRIX_HOMESERVER, 539 MATRIX_USER_ID, 540 device_id=MATRIX_DEVICE_ID, 541 store_path=STORE_PATH, 542 config=client_config, 543 ) 544 client.access_token = MATRIX_ACCESS_TOKEN 545 client.user_id = MATRIX_USER_ID 546 client.load_store() 547 log.info(f"Crypto store loaded, olm={client.olm is not None}") 548 549 http = aiohttp.ClientSession() 550 551 # Do an initial sync BEFORE registering callbacks to skip old messages 552 log.info("Performing initial sync...") 553 resp = await client.sync(timeout=10000, full_state=True) 554 if hasattr(resp, "next_batch"): 555 log.info(f"Initial sync complete, next_batch: {resp.next_batch}") 556 else: 557 log.error(f"Initial sync failed: {resp}") 558 await http.close() 559 await client.close() 560 sys.exit(1) 561 562 # E2EE setup 563 if client.olm: 564 log.info("Uploading device keys...") 565 key_upload = await client.keys_upload() 566 log.info(f"Key upload result: {key_upload}") 567 568 # Trust all known device keys so we can decrypt messages 569 for user_id in client.device_store.users: 570 for device in client.device_store.active_user_devices(user_id): 571 if not client.olm.is_device_verified(device): 572 client.verify_device(device) 573 log.info(f"Verified device {device.device_id} of {user_id}") 574 else: 575 log.warning("Olm not available after sync — E2EE will not work") 576 577 # Register event callbacks only after initial sync 578 client.add_event_callback(lambda room, event: on_message(client, room, event, http), RoomMessageText) 579 client.add_event_callback(lambda room, event: on_invite(client, room, event), InviteMemberEvent) 580 581 # Start webhook server for Radarr/Sonarr notifications 582 app = web.Application() 583 app["matrix_client"] = client 584 app["http_session"] = http 585 app.router.add_post("/webhook/radarr", handle_radarr_webhook) 586 app.router.add_post("/webhook/sonarr", handle_sonarr_webhook) 587 runner = web.AppRunner(app) 588 await runner.setup() 589 site = web.TCPSite(runner, "127.0.0.1", WEBHOOK_PORT) 590 await site.start() 591 log.info(f"Webhook server listening on 127.0.0.1:{WEBHOOK_PORT}") 592 593 log.info(f"Bot {MATRIX_USER_ID} is running!") 594 595 try: 596 await client.sync_forever(timeout=30000, full_state=True) 597 except Exception: 598 log.exception("sync_forever crashed") 599 finally: 600 await runner.cleanup() 601 await http.close() 602 await client.close() 603 604 605def main(): 606 asyncio.run(run())