This repository has no description
0

Configure Feed

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

archiver-bot: Matrix bot for requesting movies/shows via Jellyseerr

- Auto-joins rooms when invited
- Detects IMDb and TMDB links, requests media via Jellyseerr
- Resolves IMDb IDs through Radarr/Sonarr
- Checks if media already exists and replies with direct flax.lassul.us link
- Webhook listener for Radarr/Sonarr download notifications
- Tracks requests and notifies rooms when downloads complete
- Uses LoadCredential for secrets (matrix token, API keys)

+535
+59
2configs/services/archiver-bot.nix
··· 1 + { 2 + self, 3 + config, 4 + pkgs, 5 + ... 6 + }: 7 + { 8 + clan.core.vars.generators.archiver-matrix = { 9 + prompts.matrix-access-token = { 10 + description = "Matrix access token for @archiver:lassul.us - register the user on synapse, then get a token via: curl -XPOST 'https://matrix.lassul.us/_matrix/client/r0/login' -d '{\"type\":\"m.login.password\",\"user\":\"archiver\",\"password\":\"...\"}' -H 'Content-Type: application/json'"; 11 + type = "hidden"; 12 + persist = true; 13 + }; 14 + }; 15 + 16 + systemd.services.archiver-bot = { 17 + description = "Archiver Matrix Bot - requests movies via Jellyseerr"; 18 + wantedBy = [ "multi-user.target" ]; 19 + after = [ 20 + "network-online.target" 21 + "jellyseerr.service" 22 + ]; 23 + wants = [ "network-online.target" ]; 24 + 25 + environment = { 26 + MATRIX_HOMESERVER = "https://matrix.lassul.us"; 27 + MATRIX_USER_ID = "@archiver:lassul.us"; 28 + MATRIX_DEVICE_ID = "ARCHIVER"; 29 + JELLYSEERR_URL = "http://localhost:5055"; 30 + FLAX_URL = "https://flax.lassul.us"; 31 + MEDIA_PATH_PREFIX = "/var/download/"; 32 + WEBHOOK_PORT = "8099"; 33 + STORE_PATH = "/var/lib/archiver-bot"; 34 + }; 35 + 36 + script = '' 37 + export MATRIX_ACCESS_TOKEN="$(cat "$CREDENTIALS_DIRECTORY/matrix-access-token")" 38 + export JELLYSEERR_API_KEY="$(${pkgs.jq}/bin/jq -r '.main.apiKey' "$CREDENTIALS_DIRECTORY/jellyseerr-settings")" 39 + export RADARR_API_KEY="$(${pkgs.libxml2}/bin/xmllint --xpath 'string(//ApiKey)' "$CREDENTIALS_DIRECTORY/radarr-config")" 40 + export SONARR_API_KEY="$(${pkgs.libxml2}/bin/xmllint --xpath 'string(//ApiKey)' "$CREDENTIALS_DIRECTORY/sonarr-config")" 41 + exec ${self.packages.${pkgs.system}.archiver-bot}/bin/archiver-bot 42 + ''; 43 + 44 + serviceConfig = { 45 + Restart = "on-failure"; 46 + RestartSec = 10; 47 + DynamicUser = true; 48 + StateDirectory = "archiver-bot"; 49 + LoadCredential = [ 50 + "matrix-access-token:${ 51 + config.clan.core.vars.generators.archiver-matrix.files."matrix-access-token".path 52 + }" 53 + "jellyseerr-settings:/var/lib/private/jellyseerr/config/settings.json" 54 + "radarr-config:/var/lib/radarr/.config/Radarr/config.xml" 55 + "sonarr-config:/var/lib/sonarr/.config/NzbDrone/config.xml" 56 + ]; 57 + }; 58 + }; 59 + }
+24
5pkgs/archiver-bot/package.nix
··· 1 + { 2 + python3Packages, 3 + lib, 4 + }: 5 + python3Packages.buildPythonApplication { 6 + pname = "archiver-bot"; 7 + version = "0.1.0"; 8 + pyproject = true; 9 + 10 + src = ./.; 11 + 12 + build-system = [ python3Packages.setuptools ]; 13 + 14 + dependencies = with python3Packages; [ 15 + matrix-nio 16 + aiohttp 17 + ]; 18 + 19 + meta = { 20 + description = "Matrix bot that requests movies/shows via Jellyseerr when IMDb links are posted"; 21 + mainProgram = "archiver-bot"; 22 + platforms = lib.platforms.all; 23 + }; 24 + }
+14
5pkgs/archiver-bot/pyproject.toml
··· 1 + [build-system] 2 + requires = ["setuptools"] 3 + build-backend = "setuptools.build_meta" 4 + 5 + [project] 6 + name = "archiver-bot" 7 + version = "0.1.0" 8 + dependencies = [ 9 + "matrix-nio", 10 + "aiohttp", 11 + ] 12 + 13 + [project.scripts] 14 + archiver-bot = "archiver_bot:main"
+1
machines/yellow/config.nix
··· 15 15 ../../2configs/retiolum.nix 16 16 ../../2configs/services/flix 17 17 ../../2configs/autoupdate.nix 18 + ../../2configs/services/archiver-bot.nix 18 19 ]; 19 20 20 21 # we need to configure another port for the mycelium admin interface, because it conflicts with sonarr.
+437
5pkgs/archiver-bot/archiver_bot/__init__.py
··· 1 + import asyncio 2 + import json 3 + import logging 4 + import os 5 + import re 6 + import sys 7 + from pathlib import Path 8 + 9 + import aiohttp 10 + from aiohttp import web 11 + from nio import ( 12 + AsyncClient, 13 + InviteMemberEvent, 14 + LoginResponse, 15 + MatrixRoom, 16 + RoomMessageText, 17 + ) 18 + 19 + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") 20 + log = logging.getLogger("archiver-bot") 21 + 22 + IMDB_RE = re.compile(r"https?://(?:www\.)?imdb\.com/title/(tt\d+)") 23 + TMDB_MOVIE_RE = re.compile(r"https?://(?:www\.)?themoviedb\.org/movie/(\d+)") 24 + TMDB_TV_RE = re.compile(r"https?://(?:www\.)?themoviedb\.org/tv/(\d+)") 25 + 26 + JELLYSEERR_URL = os.environ.get("JELLYSEERR_URL", "http://localhost:5055") 27 + JELLYSEERR_API_KEY = os.environ["JELLYSEERR_API_KEY"] 28 + RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "") 29 + SONARR_API_KEY = os.environ.get("SONARR_API_KEY", "") 30 + MATRIX_HOMESERVER = os.environ["MATRIX_HOMESERVER"] 31 + MATRIX_ACCESS_TOKEN = os.environ["MATRIX_ACCESS_TOKEN"] 32 + MATRIX_USER_ID = os.environ.get("MATRIX_USER_ID", "@archiver:lassul.us") 33 + MATRIX_DEVICE_ID = os.environ.get("MATRIX_DEVICE_ID", "ARCHIVER") 34 + STORE_PATH = os.environ.get("STORE_PATH", "/var/lib/archiver-bot") 35 + WEBHOOK_PORT = int(os.environ.get("WEBHOOK_PORT", "8099")) 36 + FLAX_URL = os.environ.get("FLAX_URL", "https://flax.lassul.us") 37 + MEDIA_PATH_PREFIX = os.environ.get("MEDIA_PATH_PREFIX", "/var/download/") 38 + 39 + # Persistent tracking of requested media -> rooms 40 + TRACKING_FILE = Path(STORE_PATH) / "tracked_requests.json" 41 + 42 + 43 + def load_tracked() -> dict: 44 + """Load tracked requests from disk. Keys are 'movie:TMDBID' or 'tv:TMDBID', values are lists of room_ids.""" 45 + try: 46 + return json.loads(TRACKING_FILE.read_text()) 47 + except Exception: 48 + return {} 49 + 50 + 51 + def save_tracked(tracked: dict): 52 + """Save tracked requests to disk.""" 53 + TRACKING_FILE.write_text(json.dumps(tracked)) 54 + 55 + 56 + def track_request(media_type: str, tmdb_id: int, room_id: str): 57 + """Track that a room requested this media.""" 58 + tracked = load_tracked() 59 + key = f"{media_type}:{tmdb_id}" 60 + rooms = tracked.get(key, []) 61 + if room_id not in rooms: 62 + rooms.append(room_id) 63 + tracked[key] = rooms 64 + save_tracked(tracked) 65 + 66 + 67 + def pop_tracked(media_type: str, tmdb_id: int) -> list[str]: 68 + """Get and remove tracked rooms for this media.""" 69 + tracked = load_tracked() 70 + key = f"{media_type}:{tmdb_id}" 71 + rooms = tracked.pop(key, []) 72 + save_tracked(tracked) 73 + return rooms 74 + 75 + 76 + def make_flax_link(path: str) -> str: 77 + """Turn a local path like /var/download/movies/Title (Year)/file.mp4 into a flax.lassul.us URL.""" 78 + from urllib.parse import quote 79 + relative = path 80 + if relative.startswith(MEDIA_PATH_PREFIX): 81 + relative = relative[len(MEDIA_PATH_PREFIX):] 82 + return f"{FLAX_URL}/{quote(relative)}" 83 + 84 + 85 + async def jellyseerr_request(session: aiohttp.ClientSession, path: str, **kwargs) -> dict: 86 + """Make an authenticated request to Jellyseerr.""" 87 + headers = { 88 + "X-Api-Key": JELLYSEERR_API_KEY, 89 + "Content-Type": "application/json", 90 + "Accept": "application/json", 91 + } 92 + method = kwargs.pop("method", "GET") 93 + url = f"{JELLYSEERR_URL}/api/v1{path}" 94 + async with session.request(method, url, headers=headers, **kwargs) as resp: 95 + return await resp.json() 96 + 97 + 98 + async def find_by_imdb(session: aiohttp.ClientSession, imdb_id: str) -> dict | None: 99 + """Look up a movie or TV show by IMDb ID via Radarr/Sonarr, returning Jellyseerr-compatible data.""" 100 + 101 + # Try Radarr (movies) first 102 + try: 103 + async with session.get( 104 + f"http://localhost:7878/api/v3/movie/lookup?term=imdb:{imdb_id}", 105 + headers={"X-Api-Key": RADARR_API_KEY}, 106 + ) as resp: 107 + results = await resp.json() 108 + if results and len(results) > 0: 109 + tmdb_id = results[0].get("tmdbId") 110 + title = results[0].get("title", "Unknown") 111 + year = str(results[0].get("year", "")) 112 + if tmdb_id: 113 + log.info(f"Radarr resolved {imdb_id} -> TMDB {tmdb_id} ({title})") 114 + return { 115 + "id": tmdb_id, 116 + "mediaType": "movie", 117 + "title": title, 118 + "releaseDate": f"{year}-01-01" if year else "", 119 + } 120 + except Exception as e: 121 + log.warning(f"Radarr lookup failed: {e}") 122 + 123 + # Try Sonarr (TV shows) 124 + try: 125 + async with session.get( 126 + f"http://localhost:8989/api/v3/series/lookup?term=imdb:{imdb_id}", 127 + headers={"X-Api-Key": SONARR_API_KEY}, 128 + ) as resp: 129 + results = await resp.json() 130 + if results and len(results) > 0: 131 + tmdb_id = results[0].get("tmdbId", 0) 132 + title = results[0].get("title", "Unknown") 133 + year = str(results[0].get("year", "")) 134 + if tmdb_id: 135 + log.info(f"Sonarr resolved {imdb_id} -> TMDB {tmdb_id} ({title})") 136 + return { 137 + "id": tmdb_id, 138 + "mediaType": "tv", 139 + "name": title, 140 + "firstAirDate": f"{year}-01-01" if year else "", 141 + } 142 + except Exception as e: 143 + log.warning(f"Sonarr lookup failed: {e}") 144 + 145 + return None 146 + 147 + 148 + async def lookup_media_path(session: aiohttp.ClientSession, media_type: str, tmdb_id: int) -> str | None: 149 + """Look up the path for a movie file or show folder from Radarr/Sonarr by TMDB ID.""" 150 + try: 151 + if media_type == "movie": 152 + async with session.get( 153 + f"http://localhost:7878/api/v3/movie?tmdbId={tmdb_id}", 154 + headers={"X-Api-Key": RADARR_API_KEY}, 155 + ) as resp: 156 + results = await resp.json() 157 + if results and len(results) > 0: 158 + # Return direct file path if available, otherwise folder 159 + movie_file = results[0].get("movieFile", {}) 160 + if movie_file and movie_file.get("path"): 161 + return movie_file["path"] 162 + return results[0].get("path") or results[0].get("folderName") 163 + else: 164 + async with session.get( 165 + f"http://localhost:8989/api/v3/series?tvdbId={tmdb_id}", 166 + headers={"X-Api-Key": SONARR_API_KEY}, 167 + ) as resp: 168 + results = await resp.json() 169 + if results and len(results) > 0: 170 + return results[0].get("path") 171 + except Exception as e: 172 + log.warning(f"Media path lookup failed for {media_type}:{tmdb_id}: {e}") 173 + return None 174 + 175 + 176 + async def request_media(session: aiohttp.ClientSession, media: dict, room_id: str) -> tuple[str, str]: 177 + """Request a movie or TV show via Jellyseerr. Returns (plain, html) status message.""" 178 + media_type = media["mediaType"] 179 + media_id = media["id"] 180 + title = media.get("title") or media.get("name") or "Unknown" 181 + year = (media.get("releaseDate") or media.get("firstAirDate") or "")[:4] 182 + display = f"{title} ({year})" if year else title 183 + 184 + # Check if already available or requested 185 + existing = media.get("mediaInfo") 186 + if existing: 187 + status = existing.get("status") 188 + # status 5 = available, 4 = partially available, 3 = processing, 2 = pending 189 + if status == 5: 190 + path = await lookup_media_path(session, media_type, media_id) 191 + if path: 192 + link = make_flax_link(path) 193 + return (f"✅ {display} is already available!\n{link}", f'✅ <b>{display}</b> is already available!<br><a href="{link}">{link}</a>') 194 + return (f"✅ {display} is already available!", f"✅ <b>{display}</b> is already available!") 195 + elif status in (2, 3, 4): 196 + track_request(media_type, media_id, room_id) 197 + return (f"⏳ {display} has already been requested and is being processed.", f"⏳ <b>{display}</b> has already been requested and is being processed.") 198 + 199 + payload = {"mediaType": media_type, "mediaId": media_id} 200 + if media_type == "tv": 201 + payload["seasons"] = "all" 202 + 203 + # Check if Radarr/Sonarr already has the file before requesting 204 + existing_path = await lookup_media_path(session, media_type, media_id) 205 + if existing_path: 206 + link = make_flax_link(existing_path) 207 + # Still send the request to Jellyseerr so it's tracked there too 208 + await jellyseerr_request(session, "/request", method="POST", json=payload) 209 + return (f"✅ {display} is already available!\n{link}", f'✅ <b>{display}</b> is already available!<br><a href="{link}">{link}</a>') 210 + 211 + result = await jellyseerr_request(session, "/request", method="POST", json=payload) 212 + 213 + if "id" in result: 214 + track_request(media_type, media_id, room_id) 215 + 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.") 216 + else: 217 + error = result.get("message", json.dumps(result)) 218 + return (f"❌ Failed to request {display}: {error}", f"❌ Failed to request <b>{display}</b>: {error}") 219 + 220 + 221 + async def send_notice(client: AsyncClient, room_id: str, plain: str, html: str): 222 + """Send a notice message to a room.""" 223 + await client.room_send( 224 + room_id, 225 + "m.room.message", 226 + {"msgtype": "m.notice", "body": plain, "format": "org.matrix.custom.html", "formatted_body": html}, 227 + ) 228 + 229 + 230 + async def on_message(client: AsyncClient, room: MatrixRoom, event: RoomMessageText, http: aiohttp.ClientSession): 231 + """Handle messages in rooms, looking for IMDb/TMDB links.""" 232 + # Ignore our own messages 233 + if event.sender == client.user_id: 234 + return 235 + 236 + body = event.body 237 + log.info(f"Message from {event.sender} in {room.display_name}: {body[:100]}") 238 + 239 + # Help command 240 + if body.strip().lower() in ("help", "!help"): 241 + plain = ( 242 + "Archiver Bot\n\n" 243 + "Send me an IMDb or TMDB link and I'll request the movie/show on Jellyseerr.\n" 244 + "I'll notify you when the download is complete.\n\n" 245 + "Examples:\n" 246 + " https://www.imdb.com/title/tt0133093/\n" 247 + " https://www.themoviedb.org/movie/603\n" 248 + " https://www.themoviedb.org/tv/1399" 249 + ) 250 + html = ( 251 + "<b>Archiver Bot</b><br><br>" 252 + "Send me an IMDb or TMDB link and I'll request the movie/show on Jellyseerr.<br>" 253 + "I'll notify you when the download is complete.<br><br>" 254 + "Examples:<br>" 255 + "<code>https://www.imdb.com/title/tt0133093/</code><br>" 256 + "<code>https://www.themoviedb.org/movie/603</code><br>" 257 + "<code>https://www.themoviedb.org/tv/1399</code>" 258 + ) 259 + await send_notice(client, room.room_id, plain, html) 260 + return 261 + 262 + # Collect all media references from the message 263 + results = [] 264 + 265 + for imdb_id in set(IMDB_RE.findall(body)): 266 + log.info(f"Found IMDb link {imdb_id} in {room.display_name} from {event.sender}") 267 + media = await find_by_imdb(http, imdb_id) 268 + if media is None: 269 + results.append((f"❓ Could not find anything for {imdb_id}.", f"❓ Could not find anything for <code>{imdb_id}</code>.")) 270 + else: 271 + results.append(await request_media(http, media, room.room_id)) 272 + 273 + for tmdb_id in set(TMDB_MOVIE_RE.findall(body)): 274 + log.info(f"Found TMDB movie link {tmdb_id} in {room.display_name} from {event.sender}") 275 + media = {"id": int(tmdb_id), "mediaType": "movie"} 276 + try: 277 + details = await jellyseerr_request(http, f"/movie/{tmdb_id}") 278 + media["title"] = details.get("title", "Unknown") 279 + media["releaseDate"] = details.get("releaseDate", "") 280 + media["mediaInfo"] = details.get("mediaInfo") 281 + except Exception as e: 282 + log.warning(f"Failed to fetch TMDB movie {tmdb_id}: {e}") 283 + results.append(await request_media(http, media, room.room_id)) 284 + 285 + for tmdb_id in set(TMDB_TV_RE.findall(body)): 286 + log.info(f"Found TMDB TV link {tmdb_id} in {room.display_name} from {event.sender}") 287 + media = {"id": int(tmdb_id), "mediaType": "tv"} 288 + try: 289 + details = await jellyseerr_request(http, f"/tv/{tmdb_id}") 290 + media["name"] = details.get("name", "Unknown") 291 + media["firstAirDate"] = details.get("firstAirDate", "") 292 + media["mediaInfo"] = details.get("mediaInfo") 293 + except Exception as e: 294 + log.warning(f"Failed to fetch TMDB TV {tmdb_id}: {e}") 295 + results.append(await request_media(http, media, room.room_id)) 296 + 297 + for plain, html in results: 298 + await send_notice(client, room.room_id, plain, html) 299 + 300 + 301 + async def on_invite(client: AsyncClient, room: MatrixRoom, event: InviteMemberEvent): 302 + """Auto-join rooms when invited.""" 303 + 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}") 304 + if event.state_key != client.user_id: 305 + return 306 + log.info(f"Joining {room.room_id} (invited by {event.sender})...") 307 + result = await client.join(room.room_id) 308 + log.info(f"Join result for {room.room_id}: {result}") 309 + 310 + 311 + async def handle_radarr_webhook(request): 312 + """Handle Radarr webhook for download completion.""" 313 + try: 314 + data = await request.json() 315 + event_type = data.get("eventType", "") 316 + log.info(f"Radarr webhook: {event_type}") 317 + 318 + if event_type in ("Download", "MovieFileDelete"): 319 + movie = data.get("movie", {}) 320 + tmdb_id = movie.get("tmdbId") 321 + title = movie.get("title", "Unknown") 322 + year = movie.get("year", "") 323 + display = f"{title} ({year})" if year else title 324 + # Prefer the actual movie file path, fall back to folder 325 + movie_file = data.get("movieFile", {}) 326 + file_path = movie_file.get("path") or movie.get("folderPath", "") 327 + 328 + if event_type == "Download" and tmdb_id: 329 + rooms = pop_tracked("movie", tmdb_id) 330 + if rooms: 331 + client = request.app["matrix_client"] 332 + link = make_flax_link(file_path) 333 + plain = f"✅ {display} has been downloaded and is now available!\n{link}" 334 + html = f'✅ <b>{display}</b> has been downloaded and is now available!<br><a href="{link}">{link}</a>' 335 + for room_id in rooms: 336 + try: 337 + await send_notice(client, room_id, plain, html) 338 + except Exception as e: 339 + log.warning(f"Failed to notify room {room_id}: {e}") 340 + except Exception as e: 341 + log.exception(f"Error handling Radarr webhook: {e}") 342 + 343 + return web.Response(text="ok") 344 + 345 + 346 + async def handle_sonarr_webhook(request): 347 + """Handle Sonarr webhook for download completion.""" 348 + try: 349 + data = await request.json() 350 + event_type = data.get("eventType", "") 351 + log.info(f"Sonarr webhook: {event_type}") 352 + 353 + if event_type in ("Download", "EpisodeFileDelete"): 354 + series = data.get("series", {}) 355 + tmdb_id = series.get("tmdbId") 356 + title = series.get("title", "Unknown") 357 + year = series.get("year", "") 358 + display = f"{title} ({year})" if year else title 359 + 360 + episodes = data.get("episodes", []) 361 + ep_info = "" 362 + if episodes: 363 + ep_nums = [f"S{ep.get('seasonNumber', 0):02d}E{ep.get('episodeNumber', 0):02d}" for ep in episodes] 364 + ep_info = f" ({', '.join(ep_nums)})" 365 + 366 + if event_type == "Download" and tmdb_id: 367 + rooms = pop_tracked("tv", tmdb_id) 368 + if rooms: 369 + client = request.app["matrix_client"] 370 + folder_path = series.get("path", "") 371 + link = make_flax_link(folder_path) 372 + plain = f"✅ {display}{ep_info} has been downloaded and is now available!\n{link}" 373 + html = f'✅ <b>{display}</b>{ep_info} has been downloaded and is now available!<br><a href="{link}">{link}</a>' 374 + for room_id in rooms: 375 + try: 376 + await send_notice(client, room_id, plain, html) 377 + except Exception as e: 378 + log.warning(f"Failed to notify room {room_id}: {e}") 379 + except Exception as e: 380 + log.exception(f"Error handling Sonarr webhook: {e}") 381 + 382 + return web.Response(text="ok") 383 + 384 + 385 + async def run(): 386 + """Main bot loop.""" 387 + client = AsyncClient( 388 + MATRIX_HOMESERVER, 389 + MATRIX_USER_ID, 390 + device_id=MATRIX_DEVICE_ID, 391 + store_path=STORE_PATH, 392 + ) 393 + client.access_token = MATRIX_ACCESS_TOKEN 394 + client.user_id = MATRIX_USER_ID 395 + 396 + http = aiohttp.ClientSession() 397 + 398 + # Do an initial sync BEFORE registering callbacks to skip old messages 399 + log.info("Performing initial sync...") 400 + resp = await client.sync(timeout=10000, full_state=True) 401 + if hasattr(resp, "next_batch"): 402 + log.info(f"Initial sync complete, next_batch: {resp.next_batch}") 403 + else: 404 + log.error(f"Initial sync failed: {resp}") 405 + await http.close() 406 + await client.close() 407 + sys.exit(1) 408 + 409 + # Register event callbacks only after initial sync 410 + client.add_event_callback(lambda room, event: on_message(client, room, event, http), RoomMessageText) 411 + client.add_event_callback(lambda room, event: on_invite(client, room, event), InviteMemberEvent) 412 + 413 + # Start webhook server for Radarr/Sonarr notifications 414 + app = web.Application() 415 + app["matrix_client"] = client 416 + app.router.add_post("/webhook/radarr", handle_radarr_webhook) 417 + app.router.add_post("/webhook/sonarr", handle_sonarr_webhook) 418 + runner = web.AppRunner(app) 419 + await runner.setup() 420 + site = web.TCPSite(runner, "127.0.0.1", WEBHOOK_PORT) 421 + await site.start() 422 + log.info(f"Webhook server listening on 127.0.0.1:{WEBHOOK_PORT}") 423 + 424 + log.info(f"Bot {MATRIX_USER_ID} is running!") 425 + 426 + try: 427 + await client.sync_forever(timeout=30000, full_state=True) 428 + except Exception: 429 + log.exception("sync_forever crashed") 430 + finally: 431 + await runner.cleanup() 432 + await http.close() 433 + await client.close() 434 + 435 + 436 + def main(): 437 + asyncio.run(run())