mirror of
https://github.com/kurbezz/discord-bot.git
synced 2025-12-06 07:05:36 +01:00
Add event sync
This commit is contained in:
28
poetry.lock
generated
28
poetry.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
4
src/services/scheduler_sync/__init__.py
Normal file
4
src/services/scheduler_sync/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from synchronizer import start_synchronizer
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["start_synchronizer"]
|
||||||
44
src/services/scheduler_sync/comparators.py
Normal file
44
src/services/scheduler_sync/comparators.py
Normal 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
|
||||||
139
src/services/scheduler_sync/discord_events.py
Normal file
139
src/services/scheduler_sync/discord_events.py
Normal 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()
|
||||||
87
src/services/scheduler_sync/synchronizer.py
Normal file
87
src/services/scheduler_sync/synchronizer.py
Normal 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)
|
||||||
79
src/services/scheduler_sync/twitch_events.py
Normal file
79
src/services/scheduler_sync/twitch_events.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user