This commit is contained in:
2024-11-17 01:48:35 +01:00
parent e54dd7e8e0
commit 3197a788f8
15 changed files with 42 additions and 22 deletions

View File

@@ -0,0 +1,7 @@
from .synchronizer import start_synchronizer
start = start_synchronizer
__all__ = ["start"]

View File

@@ -0,0 +1,62 @@
from datetime import datetime
import logging
from .discord_events import DiscordEvent, CreateDiscordEvent, RecurrenceRule
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
def is_repeated(start: datetime, target: datetime, rule: RecurrenceRule) -> bool:
start_utc = start.astimezone(datetime.now().astimezone().tzinfo)
target_utc = target.astimezone(datetime.now().astimezone().tzinfo)
return start_utc.time() == target_utc.time() and target.weekday() in rule.by_weekday
def compare(create_event: CreateDiscordEvent, event: DiscordEvent) -> bool:
if create_event.name != event.name:
logger.debug(f"Name is different: {create_event.name} != {event.name}")
return False
if create_event.description != event.description:
logger.debug(f"Description is different: {create_event.description} != {event.description}")
return False
if create_event.recurrence_rule is not None:
if event.recurrence_rule is None:
logger.debug(f"Recurrence rule is different: {create_event.recurrence_rule} != {event.recurrence_rule}")
return False
ce_rr = create_event.recurrence_rule
e_rr = event.recurrence_rule
if ce_rr.by_weekday != e_rr.by_weekday:
logger.debug(f"Recurrence rule is different: {ce_rr.by_weekday} != {e_rr.by_weekday}")
return False
if ce_rr.interval != e_rr.interval:
logger.debug(f"Recurrence rule is different: {ce_rr.interval} != {e_rr.interval}")
return False
if ce_rr.frequency != e_rr.frequency:
logger.debug(f"Recurrence rule is different: {ce_rr.frequency} != {e_rr.frequency}")
return False
if not is_repeated(ce_rr.start, e_rr.start, ce_rr):
logger.debug(f"Recurrence rule is different: {ce_rr.start} != {e_rr.start}")
return False
else:
if event.recurrence_rule is not None:
logger.debug(f"Recurrence rule is different: {create_event.recurrence_rule} != {event.recurrence_rule}")
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):
logger.debug(f"Scheduled start time is different: {create_event.scheduled_start_time} != {event.scheduled_start_time}")
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):
logger.debug(f"Scheduled end time is different: {create_event.scheduled_end_time} != {event.scheduled_end_time}")
return False
return True

View File

@@ -0,0 +1,173 @@
from typing import Self
from datetime import datetime, timedelta
import logging
from httpx import AsyncClient
from pydantic import BaseModel, field_serializer, SerializationInfo
from core.config import config
from .twitch_events import TwitchEvent
logger = logging.getLogger(__name__)
class RecurrenceRule(BaseModel):
start: datetime
by_weekday: list[int]
interval: int
frequency: int
@field_serializer("start", when_used="always")
def serialize_datetime(self, value: datetime, info: SerializationInfo) -> str:
return value.isoformat()
def next_date(self, start: datetime) -> datetime:
next_date = start
while True:
next_date += timedelta(days=1)
if next_date <= datetime.now(start.tzinfo):
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(guild_id: int) -> list[DiscordEvent]:
async with AsyncClient() as client:
response = await client.get(
f"https://discord.com/api/v10/guilds/{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(guild_id: int, event_id: str):
async with AsyncClient() as client:
response = await client.delete(
f"https://discord.com/api/v10/guilds/{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
@field_serializer("scheduled_start_time", "scheduled_end_time", when_used="always")
def serialize_datetime(self, value: datetime, info: SerializationInfo) -> str:
return value.isoformat()
@classmethod
def parse_from_twitch_event(cls, event: TwitchEvent, channel_name: str) -> 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=f"https://twitch.tv/{channel_name}"),
scheduled_start_time=event.start_at,
scheduled_end_time=event.end_at,
recurrence_rule=recurrence_rule
)
async def create_discord_event(guild_id: int, event: CreateDiscordEvent):
async with AsyncClient() as client:
response = await client.post(
f"https://discord.com/api/v10/guilds/{guild_id}/scheduled-events",
json=event.model_dump(),
headers={
"Authorization": f"Bot {config.DISCORD_BOT_TOKEN}",
"Content-Type": "application/json"
}
)
if response.status_code == 400:
raise ValueError({
"status_code": response.status_code,
"response": response.json(),
"event": event.model_dump()
})
return response.json()
class UpdateDiscordEvent(BaseModel):
name: str
description: str
scheduled_start_time: datetime
scheduled_end_time: datetime
recurrence_rule: RecurrenceRule | None
@field_serializer("scheduled_start_time", "scheduled_end_time", when_used="always")
def serialize_datetime(self, value: datetime, info: SerializationInfo) -> str:
return value.isoformat()
async def edit_discord_event(guild_id: int, event_id: str, event: UpdateDiscordEvent):
async with AsyncClient() as client:
response = await client.patch(
f"https://discord.com/api/v10/guilds/{guild_id}/scheduled-events/{event_id}",
json=event.model_dump(),
headers={
"Authorization": f"Bot {config.DISCORD_BOT_TOKEN}",
"Content-Type": "application/json"
}
)
if response.status_code == 400:
raise ValueError({
"status_code": response.status_code,
"response": response.json(),
"event": event.model_dump()
})
return response.json()

View File

@@ -0,0 +1,112 @@
from asyncio import sleep
import logging
from datetime import datetime
from core.config import config, TwitchConfig
from .twitch_events import get_twitch_events, TwitchEvent
from .discord_events import (
get_discord_events, DiscordEvent,
delete_discord_event,
create_discord_event, CreateDiscordEvent,
edit_discord_event, UpdateDiscordEvent
)
from .comparators import compare
logger = logging.getLogger(__name__)
async def add_events(
guild_id: int,
twitch_channel_name: str,
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 event.start_at > datetime.now(event.start_at.tzinfo):
continue
if uid not in discord_events_ids:
create_event = CreateDiscordEvent.parse_from_twitch_event(event, twitch_channel_name)
await create_discord_event(guild_id, create_event)
async def remove_events(
guild_id: int,
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(guild_id, uid)
async def edit_events(
guild_id: int,
twitch_channel_name: str,
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, twitch_channel_name)
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(guild_id, discord_event.id, update_event)
async def syncronize(twitch: TwitchConfig, discord_guild_id: int):
twitch_events = await get_twitch_events(str(twitch.id))
discord_events = await get_discord_events(discord_guild_id)
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(discord_guild_id, twitch.name, twitch_events_with_id, discord_events_with_id)
await remove_events(discord_guild_id, twitch_events_with_id, discord_events_with_id)
await edit_events(discord_guild_id, twitch.name, twitch_events_with_id, discord_events_with_id)
async def start_synchronizer():
logger.info("Starting events syncronizer...")
while True:
try:
for streamer in config.STREAMERS:
if (integration := streamer.integrations.discord) is None:
continue
await syncronize(streamer.twitch, integration.guild_id)
except Exception as e:
logging.error(e)
await sleep(5 * 30)

View File

@@ -0,0 +1,77 @@
from typing import Optional
from datetime import datetime
from enum import StrEnum
import icalendar
from httpx import AsyncClient
from pydantic import BaseModel
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(twitch_channel_id: str) -> list[TwitchEvent]:
async with AsyncClient() as client:
response = await client.get(
f"https://api.twitch.tv/helix/schedule/icalendar?broadcaster_id={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").cats[0],
repeat_rule=None
)
if raw_event.get("RRULE"):
if raw_event.get("RRULE")["FREQ"][0] == "WEEKLY":
value = raw_event.get("RRULE")["BYDAY"][0]
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 is not None:
events.append(event)
return events