diff --git a/poetry.lock b/poetry.lock index 35c0814..ffb2f00 100644 --- a/poetry.lock +++ b/poetry.lock @@ -355,6 +355,21 @@ cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] +[[package]] +name = "icalendar" +version = "5.0.13" +description = "iCalendar parser/generator" +optional = false +python-versions = ">=3.7" +files = [ + {file = "icalendar-5.0.13-py3-none-any.whl", hash = "sha256:5ded5415e2e1edef5ab230024a75878a7a81d518a3b1ae4f34bf20b173c84dc2"}, + {file = "icalendar-5.0.13.tar.gz", hash = "sha256:92799fde8cce0b61daa8383593836d1e19136e504fa1671f471f98be9b029706"}, +] + +[package.dependencies] +python-dateutil = "*" +pytz = "*" + [[package]] name = "idna" version = "3.7" @@ -636,6 +651,17 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "pytz" +version = "2024.1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, + {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, +] + [[package]] name = "six" version = "1.16.0" @@ -791,4 +817,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "20a20db770b371c4b3e388034c4bd30335349d9a69f61a52c997ea3d2fdcd4fe" +content-hash = "04d4bb1f82ad0116f8e6aed7cf1d73d767b23f0e82e33ee0c0af2bb1db659969" diff --git a/pyproject.toml b/pyproject.toml index bb03162..0c0c603 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,8 @@ pydantic = "^2.8.2" pydantic-settings = "^2.4.0" aiofiles = "^24.1.0" httpx = "^0.27.0" +icalendar = "^5.0.13" +pytz = "^2024.1" [build-system] diff --git a/src/config.py b/src/config.py index d38cb73..0477b93 100644 --- a/src/config.py +++ b/src/config.py @@ -3,6 +3,7 @@ from pydantic_settings import BaseSettings class Config(BaseSettings): DISCORD_BOT_TOKEN: str + DISCORD_BOT_ID: str DISCORD_GUILD_ID: int DISCORD_CHANNEL_ID: int diff --git a/src/main.py b/src/main.py index 2815270..b31646f 100644 --- a/src/main.py +++ b/src/main.py @@ -3,6 +3,7 @@ import logging from services.discord import start_discord_sevice from services.twitch import start_twitch_service +from services.scheduler_sync import start_synchronizer logging.basicConfig(level=logging.INFO) @@ -15,7 +16,8 @@ async def main(): await wait([ create_task(start_discord_sevice()), - create_task(start_twitch_service()) + create_task(start_twitch_service()), + create_task(start_synchronizer()) ], return_when="FIRST_COMPLETED") diff --git a/src/services/scheduler_sync/__init__.py b/src/services/scheduler_sync/__init__.py new file mode 100644 index 0000000..50eeb64 --- /dev/null +++ b/src/services/scheduler_sync/__init__.py @@ -0,0 +1,4 @@ +from synchronizer import start_synchronizer + + +__all__ = ["start_synchronizer"] diff --git a/src/services/scheduler_sync/comparators.py b/src/services/scheduler_sync/comparators.py new file mode 100644 index 0000000..030ff90 --- /dev/null +++ b/src/services/scheduler_sync/comparators.py @@ -0,0 +1,44 @@ +from datetime import datetime + +from services.scheduler_sync.discord_events import DiscordEvent, CreateDiscordEvent, RecurrenceRule + + +def is_repeated(start: datetime, target: datetime, rule: RecurrenceRule) -> bool: + return start.time() == target.time() and target.weekday() in rule.by_weekday + + +def compare(create_event: CreateDiscordEvent, event: DiscordEvent) -> bool: + if create_event.name != event.name: + return False + + if create_event.description != event.description: + return False + + if create_event.recurrence_rule is not None: + if event.recurrence_rule is None: + return False + + ce_rr = create_event.recurrence_rule + e_rr = event.recurrence_rule + + if ce_rr.by_weekday != e_rr.by_weekday: + return False + if ce_rr.interval != e_rr.interval: + return False + if ce_rr.frequency != e_rr.frequency: + return False + if not is_repeated(ce_rr.start, e_rr.start, ce_rr): + return False + else: + if event.recurrence_rule is not None: + return False + + if create_event.scheduled_start_time != event.scheduled_start_time: + if create_event.recurrence_rule is None or not is_repeated(create_event.scheduled_start_time, event.scheduled_start_time, create_event.recurrence_rule): + return False + + if create_event.scheduled_end_time != event.scheduled_end_time: + if create_event.recurrence_rule is None or not is_repeated(create_event.scheduled_end_time, event.scheduled_end_time, create_event.recurrence_rule): + return False + + return True diff --git a/src/services/scheduler_sync/discord_events.py b/src/services/scheduler_sync/discord_events.py new file mode 100644 index 0000000..7aa3c8a --- /dev/null +++ b/src/services/scheduler_sync/discord_events.py @@ -0,0 +1,139 @@ +from typing import Self +from datetime import datetime, timedelta + +from httpx import AsyncClient +from pydantic import BaseModel + +from config import config + +from services.scheduler_sync.twitch_events import TwitchEvent + + +class RecurrenceRule(BaseModel): + start: datetime + by_weekday: list[int] + interval: int + frequency: int + + def next_date(self, start: datetime) -> datetime: + next_date = start + + while True: + next_date += timedelta(days=1) + + if next_date < start: + continue + + if next_date.weekday() in self.by_weekday: + return next_date + + +class DiscordEvent(BaseModel): + id: str + name: str + description: str + scheduled_start_time: datetime + scheduled_end_time: datetime + recurrence_rule: RecurrenceRule | None + creator_id: str + + +async def get_discord_events() -> list[DiscordEvent]: + async with AsyncClient() as client: + response = await client.get( + f"https://discord.com/api/v10/guilds/{config.DISCORD_GUILD_ID}/scheduled-events", + headers={"Authorization": f"Bot {config.DISCORD_BOT_TOKEN}"} + ) + + response.raise_for_status() + + events = [DiscordEvent(**event) for event in response.json()] + + return [event for event in events if event.creator_id == config.DISCORD_BOT_ID] + + +async def delete_discord_event(event_id: str): + async with AsyncClient() as client: + response = await client.delete( + f"https://discord.com/api/v10/guilds/{config.DISCORD_GUILD_ID}/scheduled-events/{event_id}", + headers={"Authorization": f"Bot {config.DISCORD_BOT_TOKEN}"} + ) + + response.raise_for_status() + return response.json() + + +class EntityMetadata(BaseModel): + location: str + + +class CreateDiscordEvent(BaseModel): + name: str + description: str + privacy_level: int + entity_type: int + entity_metadata: EntityMetadata + scheduled_start_time: datetime + scheduled_end_time: datetime + recurrence_rule: RecurrenceRule | None + + @classmethod + def parse_from_twitch_event(cls, event: TwitchEvent) -> Self: + if event.categories: + name = f"{event.name} | {event.categories}" + else: + name = event.name + + if event.repeat_rule: + recurrence_rule = RecurrenceRule( + start=event.start_at, + by_weekday=[event.repeat_rule.weekday.get_number()], + interval=1, + frequency=2 + ) + else: + recurrence_rule = None + + return cls( + name=name, + description=f"{event.description or ''}\n\n\n\n#{event.uid}", + privacy_level=2, + entity_type=3, + entity_metadata=EntityMetadata(location="https://twitch.tv/hafmc"), + scheduled_start_time=event.start_at, + scheduled_end_time=event.end_at, + recurrence_rule=recurrence_rule + ) + + + +async def create_discord_event(event: CreateDiscordEvent): + async with AsyncClient() as client: + response = await client.post( + f"https://discord.com/api/v10/guilds/{config.DISCORD_GUILD_ID}/scheduled-events", + json=event.model_dump(), + headers={"Authorization": f"Bot {config.DISCORD_BOT_TOKEN}"} + ) + + response.raise_for_status() + return response.json() + + +class UpdateDiscordEvent(BaseModel): + name: str + description: str + scheduled_start_time: datetime + scheduled_end_time: datetime + recurrence_rule: RecurrenceRule | None + + +async def edit_discord_event(event_id: str, event: UpdateDiscordEvent): + async with AsyncClient() as client: + response = await client.patch( + f"https://discord.com/api/v10/guilds/{config.DISCORD_GUILD_ID}/scheduled-events/{event_id}", + json=event.model_dump(), + headers={"Authorization": f"Bot {config.DISCORD_BOT_TOKEN}"} + ) + + response.raise_for_status() + return response.json() diff --git a/src/services/scheduler_sync/synchronizer.py b/src/services/scheduler_sync/synchronizer.py new file mode 100644 index 0000000..69869c9 --- /dev/null +++ b/src/services/scheduler_sync/synchronizer.py @@ -0,0 +1,87 @@ +from asyncio import sleep + +from services.scheduler_sync.twitch_events import get_twitch_events, TwitchEvent +from services.scheduler_sync.discord_events import ( + get_discord_events, DiscordEvent, + delete_discord_event, + create_discord_event, CreateDiscordEvent, + edit_discord_event, UpdateDiscordEvent +) +from services.scheduler_sync.comparators import compare + + +async def add_events( + twitch_events: list[tuple[str, TwitchEvent]], + discord_events: list[tuple[str, DiscordEvent]] +): + discord_events_ids = [event[0] for event in discord_events] + + for (uid, event) in twitch_events: + if uid not in discord_events_ids: + create_event = CreateDiscordEvent.parse_from_twitch_event(event) + await create_discord_event(create_event) + + +async def remove_events( + twith_events: list[tuple[str, TwitchEvent]], + discord_events: list[tuple[str, DiscordEvent]] +): + twith_events_ids = [event[0] for event in twith_events] + + for (uid, event) in discord_events: + if uid not in twith_events_ids: + await delete_discord_event(uid) + + +async def edit_events( + twith_events: list[tuple[str, TwitchEvent]], + discord_events: list[tuple[str, DiscordEvent]] +): + for (uid, twitch_event) in twith_events: + for (discord_id, discord_event) in discord_events: + if uid != discord_id: + continue + + create_event = CreateDiscordEvent.parse_from_twitch_event(twitch_event) + + if compare(create_event, discord_event): + continue + + update_event = UpdateDiscordEvent( + name=create_event.name, + description=create_event.description, + scheduled_start_time=create_event.scheduled_start_time, + scheduled_end_time=create_event.scheduled_end_time, + recurrence_rule=create_event.recurrence_rule + ) + + if update_event.recurrence_rule is not None: + duration = update_event.scheduled_end_time - update_event.scheduled_start_time + + update_event.scheduled_start_time = update_event.recurrence_rule.next_date(update_event.scheduled_start_time) + update_event.scheduled_end_time = update_event.scheduled_start_time + duration + + update_event.recurrence_rule.start = update_event.scheduled_start_time + + await edit_discord_event(discord_event.id, update_event) + + +async def syncronize(): + twitch_events = await get_twitch_events() + discord_events = await get_discord_events() + + twitch_events_with_id = [(event.uid, event) for event in twitch_events] + discord_events_with_id = [ + (event.description.rsplit("#")[1], event) + for event in discord_events + ] + + await add_events(twitch_events_with_id, discord_events_with_id) + await remove_events(twitch_events_with_id, discord_events_with_id) + await edit_events(twitch_events_with_id, discord_events_with_id) + + +async def start_synchronizer(): + while True: + await syncronize() + await sleep(5 * 30) diff --git a/src/services/scheduler_sync/twitch_events.py b/src/services/scheduler_sync/twitch_events.py new file mode 100644 index 0000000..861b1bb --- /dev/null +++ b/src/services/scheduler_sync/twitch_events.py @@ -0,0 +1,79 @@ +from typing import Optional +from datetime import datetime +from enum import StrEnum + +import icalendar + +from httpx import AsyncClient +from pydantic import BaseModel + +from config import config + + +class Weekday(StrEnum): + Mon = "MO" + Tue = "TU" + Wed = "WE" + Thu = "TH" + Fri = "FR" + Sat = "SA" + Sun = "SU" + + def get_number(self) -> int: + return { + Weekday.Mon: 0, + Weekday.Tue: 1, + Weekday.Wed: 2, + Weekday.Thu: 3, + Weekday.Fri: 4, + Weekday.Sat: 5, + Weekday.Sun: 6 + }[self] + + +class WeeklyRepeatRule(BaseModel): + weekday: Weekday + + +class TwitchEvent(BaseModel): + uid: str + start_at: datetime + end_at: datetime + name: str + description: Optional[str] + categories: Optional[str] + repeat_rule: Optional[WeeklyRepeatRule] + + +async def get_twitch_events() -> list[TwitchEvent]: + async with AsyncClient() as client: + response = await client.get( + f"https://api.twitch.tv/helix/schedule/icalendar?broadcaster_id={config.TWITCH_CHANNEL_ID}" + ) + + events: list[TwitchEvent] = [] + + calendar = icalendar.Calendar.from_ical(response.text) + + for raw_event in calendar.walk("VEVENT"): + event = TwitchEvent( + uid=raw_event.get("UID"), + start_at=raw_event.get("DTSTART").dt, + end_at=raw_event.get("DTEND").dt, + name=raw_event.get("SUMMARY"), + description=raw_event.get("DESCRIPTION"), + categories=raw_event.get("CATEGORIES"), + repeat_rule=None + ) + + if raw_event.get("RRULE"): + if raw_event.get("RRULE").startswith("FREQ=WEEKLY"): + value = raw_event.get("RRULE").split(";")[1].split("=")[1] + event.repeat_rule = WeeklyRepeatRule(weekday=Weekday(value)) + else: + raise ValueError("Invalid repeat rule") + + if event.start_at > datetime.now(event.start_at.tzinfo) or event.repeat_rule: + events.append(event) + + return events diff --git a/src/services/twitch.py b/src/services/twitch.py index a79212f..12c2df7 100644 --- a/src/services/twitch.py +++ b/src/services/twitch.py @@ -7,7 +7,7 @@ from twitchAPI.helper import first from twitchAPI.eventsub.webhook import EventSubWebhook from twitchAPI.twitch import Twitch from twitchAPI.type import AuthScope -from twitchAPI.object.eventsub import ChannelChatMessageEvent, StreamOnlineEvent, StreamOfflineEvent, ChannelUpdateEvent +from twitchAPI.object.eventsub import StreamOnlineEvent, StreamOfflineEvent, ChannelUpdateEvent import aiofiles