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