Add event sync

This commit is contained in:
2024-08-10 18:01:09 +02:00
parent dd4b29c669
commit c683e61c96
10 changed files with 387 additions and 3 deletions

28
poetry.lock generated
View File

@@ -355,6 +355,21 @@ cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"] http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"] 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]] [[package]]
name = "idna" name = "idna"
version = "3.7" version = "3.7"
@@ -636,6 +651,17 @@ files = [
[package.extras] [package.extras]
cli = ["click (>=5.0)"] 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]] [[package]]
name = "six" name = "six"
version = "1.16.0" version = "1.16.0"
@@ -791,4 +817,4 @@ multidict = ">=4.0"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "20a20db770b371c4b3e388034c4bd30335349d9a69f61a52c997ea3d2fdcd4fe" content-hash = "04d4bb1f82ad0116f8e6aed7cf1d73d767b23f0e82e33ee0c0af2bb1db659969"

View File

@@ -14,6 +14,8 @@ pydantic = "^2.8.2"
pydantic-settings = "^2.4.0" pydantic-settings = "^2.4.0"
aiofiles = "^24.1.0" aiofiles = "^24.1.0"
httpx = "^0.27.0" httpx = "^0.27.0"
icalendar = "^5.0.13"
pytz = "^2024.1"
[build-system] [build-system]

View File

@@ -3,6 +3,7 @@ from pydantic_settings import BaseSettings
class Config(BaseSettings): class Config(BaseSettings):
DISCORD_BOT_TOKEN: str DISCORD_BOT_TOKEN: str
DISCORD_BOT_ID: str
DISCORD_GUILD_ID: int DISCORD_GUILD_ID: int
DISCORD_CHANNEL_ID: int DISCORD_CHANNEL_ID: int

View File

@@ -3,6 +3,7 @@ import logging
from services.discord import start_discord_sevice from services.discord import start_discord_sevice
from services.twitch import start_twitch_service from services.twitch import start_twitch_service
from services.scheduler_sync import start_synchronizer
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -15,7 +16,8 @@ async def main():
await wait([ await wait([
create_task(start_discord_sevice()), create_task(start_discord_sevice()),
create_task(start_twitch_service()) create_task(start_twitch_service()),
create_task(start_synchronizer())
], return_when="FIRST_COMPLETED") ], return_when="FIRST_COMPLETED")

View File

@@ -0,0 +1,4 @@
from synchronizer import start_synchronizer
__all__ = ["start_synchronizer"]

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -7,7 +7,7 @@ from twitchAPI.helper import first
from twitchAPI.eventsub.webhook import EventSubWebhook from twitchAPI.eventsub.webhook import EventSubWebhook
from twitchAPI.twitch import Twitch from twitchAPI.twitch import Twitch
from twitchAPI.type import AuthScope from twitchAPI.type import AuthScope
from twitchAPI.object.eventsub import ChannelChatMessageEvent, StreamOnlineEvent, StreamOfflineEvent, ChannelUpdateEvent from twitchAPI.object.eventsub import StreamOnlineEvent, StreamOfflineEvent, ChannelUpdateEvent
import aiofiles import aiofiles