This repository has no description
24 kB
568 lines
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())