mirror of
https://github.com/kurbezz/discord-bot.git
synced 2026-03-03 20:00:46 +01:00
Refactor
This commit is contained in:
7
src/modules/games_list/__init__.py
Normal file
7
src/modules/games_list/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .discord import start_discord_sevice
|
||||
|
||||
|
||||
start = start_discord_sevice
|
||||
|
||||
|
||||
__all__ = ["start"]
|
||||
161
src/modules/games_list/discord.py
Normal file
161
src/modules/games_list/discord.py
Normal file
@@ -0,0 +1,161 @@
|
||||
import logging
|
||||
|
||||
import discord
|
||||
from discord.abc import Messageable
|
||||
from discord import Object
|
||||
from discord import app_commands
|
||||
|
||||
from modules.games_list.games_list import GameList, GameItem
|
||||
|
||||
from core.config import config
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_game_list_channel_to_message_map() -> dict[int, int]:
|
||||
result = {}
|
||||
|
||||
for streamer in config.STREAMERS:
|
||||
if (integration := streamer.integrations.discord) is None:
|
||||
continue
|
||||
|
||||
if (games_list := integration.games_list) is None:
|
||||
continue
|
||||
|
||||
if games_list.channel_id is None or games_list.message_id is None:
|
||||
continue
|
||||
|
||||
result[games_list.channel_id] = games_list.message_id
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class DiscordClient(discord.Client):
|
||||
def __init__(self) -> None:
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
|
||||
super().__init__(intents=intents)
|
||||
|
||||
self.tree = app_commands.CommandTree(self)
|
||||
|
||||
async def setup_hook(self):
|
||||
for streamer in config.STREAMERS:
|
||||
if (integration := streamer.integrations.discord) is None:
|
||||
continue
|
||||
|
||||
if integration.games_list is None:
|
||||
continue
|
||||
|
||||
self.tree.copy_global_to(guild=Object(id=integration.guild_id))
|
||||
await self.tree.sync(guild=Object(id=integration.guild_id))
|
||||
|
||||
async def on_ready(self):
|
||||
await self.change_presence(
|
||||
activity=discord.Game(config.DISCORD_BOT_ACTIVITY),
|
||||
status=discord.Status.online,
|
||||
)
|
||||
|
||||
|
||||
client = DiscordClient()
|
||||
|
||||
|
||||
@client.tree.command()
|
||||
@app_commands.describe(
|
||||
category="Раздел",
|
||||
customer="Кто заказал",
|
||||
game="Игра",
|
||||
date="Дата заказа"
|
||||
)
|
||||
@app_commands.choices(
|
||||
category=[
|
||||
app_commands.Choice(name="Заказ за баллы", value="points"),
|
||||
app_commands.Choice(name="Проплачены", value="paids"),
|
||||
app_commands.Choice(name="Подарки", value="gifts"),
|
||||
],
|
||||
)
|
||||
async def add(
|
||||
interaction: discord.Interaction,
|
||||
category: str,
|
||||
customer: str,
|
||||
game: str,
|
||||
date: str | None = None
|
||||
):
|
||||
channel_to_message = get_game_list_channel_to_message_map()
|
||||
|
||||
if interaction.channel is None:
|
||||
await interaction.response.send_message("Команда не доступна в этом канале (#1)", ephemeral=True)
|
||||
return
|
||||
|
||||
message_id = channel_to_message.get(interaction.channel.id)
|
||||
if message_id is None:
|
||||
await interaction.response.send_message("Команда не доступна в этом канале (#3)", ephemeral=True)
|
||||
return
|
||||
|
||||
if not isinstance(interaction.channel, Messageable):
|
||||
await interaction.response.send_message("Команда не доступна в этом канале (#2)", ephemeral=True)
|
||||
return
|
||||
|
||||
game_list_message = await interaction.channel.fetch_message(message_id)
|
||||
|
||||
game_list = GameList.parse(game_list_message.content)
|
||||
game_list.add_game(category, GameItem(name=game, customer=customer, date=date))
|
||||
|
||||
await game_list_message.edit(content=str(game_list))
|
||||
|
||||
await interaction.response.send_message("Игра добавлена!", ephemeral=True)
|
||||
|
||||
|
||||
async def game_list_autocomplete(
|
||||
interaction: discord.Interaction,
|
||||
current: str,
|
||||
) -> list[app_commands.Choice[str]]:
|
||||
if not isinstance(interaction.channel, Messageable):
|
||||
return []
|
||||
|
||||
channel_to_message = get_game_list_channel_to_message_map()
|
||||
message_id = channel_to_message.get(interaction.channel.id)
|
||||
if message_id is None:
|
||||
return []
|
||||
|
||||
game_list_message = await interaction.channel.fetch_message(message_id)
|
||||
|
||||
game_list = GameList.parse(game_list_message.content)
|
||||
|
||||
return game_list.get_choices(current)
|
||||
|
||||
|
||||
@client.tree.command()
|
||||
@app_commands.describe(game="Игра")
|
||||
@app_commands.autocomplete(game=game_list_autocomplete)
|
||||
async def delete(interaction: discord.Interaction, game: str):
|
||||
channel_to_message = get_game_list_channel_to_message_map()
|
||||
|
||||
if interaction.channel is None:
|
||||
await interaction.response.send_message("Команда не доступна в этом канале (#1)", ephemeral=True)
|
||||
return
|
||||
|
||||
message_id = channel_to_message.get(interaction.channel.id)
|
||||
if message_id is None:
|
||||
await interaction.response.send_message("Команда не доступна в этом канале (#3)", ephemeral=True)
|
||||
return
|
||||
|
||||
if not isinstance(interaction.channel, Messageable):
|
||||
await interaction.response.send_message("Команда не доступна в этом канале (#2)", ephemeral=True)
|
||||
return
|
||||
|
||||
game_list_message = await interaction.channel.fetch_message(message_id)
|
||||
|
||||
game_list = GameList.parse(game_list_message.content)
|
||||
game_list.delete_game(game)
|
||||
|
||||
await game_list_message.edit(content=str(game_list))
|
||||
|
||||
await interaction.response.send_message("Игра удалена!", ephemeral=True)
|
||||
|
||||
|
||||
async def start_discord_sevice():
|
||||
logger.info("Starting Discord service...")
|
||||
|
||||
await client.start(config.DISCORD_BOT_TOKEN)
|
||||
103
src/modules/games_list/games_list.py
Normal file
103
src/modules/games_list/games_list.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from typing import Self
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
from discord import app_commands
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class GameItem(BaseModel):
|
||||
name: str
|
||||
customer: str
|
||||
date: str | None
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.date is not None:
|
||||
return f"* {self.name} ({self.customer}) | {self.date}"
|
||||
else:
|
||||
return f"* {self.name} ({self.customer})"
|
||||
|
||||
@classmethod
|
||||
def parse(cls, line: str) -> Self:
|
||||
regex_result_with_date = re.search(r"^\* (.+) \((.+)\) \| (.+)$", line)
|
||||
if regex_result_with_date is not None:
|
||||
name, customer, date = regex_result_with_date.groups()
|
||||
return cls(name=name, customer=customer, date=date)
|
||||
|
||||
regex_result_without_date = re.search(r"^\* (.+) \((.+)\)$", line)
|
||||
if regex_result_without_date is not None:
|
||||
name, customer = regex_result_without_date.groups()
|
||||
return cls(name=name, customer=customer, date=None)
|
||||
|
||||
raise ValueError(f"Invalid line: {line}")
|
||||
|
||||
|
||||
class Category(BaseModel):
|
||||
name: str
|
||||
games: list[GameItem]
|
||||
|
||||
|
||||
class GameList:
|
||||
CATEGORY_MAP = {
|
||||
"points": "Заказанные игры (за 12к)",
|
||||
"paids": "Проплачены 🤑 ",
|
||||
"gifts": "Подарки",
|
||||
}
|
||||
|
||||
def __init__(self, data: list[Category]):
|
||||
self.data = data
|
||||
|
||||
@classmethod
|
||||
def parse(cls, message: str) -> Self:
|
||||
categories = []
|
||||
|
||||
for line in message.split("\n"):
|
||||
if line == "".strip():
|
||||
continue
|
||||
|
||||
if not line.startswith("*"):
|
||||
name = line.replace(":", "")
|
||||
categories.append(Category(name=name, games=[]))
|
||||
else:
|
||||
categories[-1].games.append(GameItem.parse(line.strip()))
|
||||
|
||||
return cls(data=categories)
|
||||
|
||||
def add_game(self, category: str, game_item: GameItem):
|
||||
_category = self.CATEGORY_MAP.get(category)
|
||||
|
||||
if game_item.date is None:
|
||||
game_item.date = datetime.now().strftime("%d.%m.%Y")
|
||||
|
||||
for category_item in self.data:
|
||||
if category_item.name == _category:
|
||||
category_item.games.append(game_item)
|
||||
|
||||
def delete_game(self, game_name: str):
|
||||
for category in self.data:
|
||||
for game in category.games:
|
||||
if game.name.startswith(game_name):
|
||||
category.games.remove(game)
|
||||
|
||||
def __str__(self) -> str:
|
||||
result = ""
|
||||
|
||||
for category in self.data:
|
||||
result += f"{category.name}:\n"
|
||||
|
||||
for game in category.games:
|
||||
result += f"{game}\n"
|
||||
|
||||
result += "\n\n"
|
||||
|
||||
return result
|
||||
|
||||
def get_choices(self, query: str) -> list[app_commands.Choice[str]]:
|
||||
choices = []
|
||||
|
||||
for category in self.data:
|
||||
for game in category.games:
|
||||
if query.lower() in game.name.lower():
|
||||
choices.append(app_commands.Choice(name=game.name, value=game.name))
|
||||
|
||||
return choices[:25]
|
||||
7
src/modules/scheduler_sync/__init__.py
Normal file
7
src/modules/scheduler_sync/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .synchronizer import start_synchronizer
|
||||
|
||||
|
||||
start = start_synchronizer
|
||||
|
||||
|
||||
__all__ = ["start"]
|
||||
62
src/modules/scheduler_sync/comparators.py
Normal file
62
src/modules/scheduler_sync/comparators.py
Normal 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
|
||||
173
src/modules/scheduler_sync/discord_events.py
Normal file
173
src/modules/scheduler_sync/discord_events.py
Normal 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()
|
||||
112
src/modules/scheduler_sync/synchronizer.py
Normal file
112
src/modules/scheduler_sync/synchronizer.py
Normal 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)
|
||||
77
src/modules/scheduler_sync/twitch_events.py
Normal file
77
src/modules/scheduler_sync/twitch_events.py
Normal 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
|
||||
7
src/modules/stream_notifications/__init__.py
Normal file
7
src/modules/stream_notifications/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .twitch.twitch import start_twitch_service
|
||||
|
||||
|
||||
start = start_twitch_service
|
||||
|
||||
|
||||
__all__ = ["start"]
|
||||
91
src/modules/stream_notifications/notification.py
Normal file
91
src/modules/stream_notifications/notification.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from core.config import config, StreamerConfig
|
||||
|
||||
from .twitch.state import State
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def notify_telegram(msg: str, chat_id: str):
|
||||
async with AsyncClient() as client:
|
||||
await client.post(
|
||||
f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
json={
|
||||
"chat_id": chat_id,
|
||||
"text": msg,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def notify_discord(msg: str, channel_id: str):
|
||||
async with AsyncClient() as client:
|
||||
await client.post(
|
||||
f"https://discord.com/api/v10/channels/{channel_id}/messages",
|
||||
headers={
|
||||
"Authorization": f"Bot {config.DISCORD_BOT_TOKEN}"
|
||||
},
|
||||
json={
|
||||
"content": msg,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_role_id(streamer_config: StreamerConfig, category: str) -> int | None:
|
||||
discord_integration = streamer_config.integrations.discord
|
||||
if discord_integration is None:
|
||||
return None
|
||||
|
||||
roles= discord_integration.roles
|
||||
if roles is None:
|
||||
return None
|
||||
|
||||
return roles.get(category)
|
||||
|
||||
|
||||
async def notify(notification_type: Literal["start"] | Literal["change_category"], streamer_config: StreamerConfig, current_state: State):
|
||||
if notification_type == "start":
|
||||
message_template = streamer_config.notifications.start_stream
|
||||
else:
|
||||
message_template = streamer_config.notifications.change_category
|
||||
|
||||
if message_template is None:
|
||||
return
|
||||
|
||||
integrations = streamer_config.integrations
|
||||
|
||||
if (telegram := integrations.telegram) is not None:
|
||||
if telegram.notifications_channel_id is not None:
|
||||
msg = message_template.format(
|
||||
title=current_state.title,
|
||||
category=current_state.category,
|
||||
role=""
|
||||
)
|
||||
|
||||
try:
|
||||
await notify_telegram(msg, str(telegram.notifications_channel_id))
|
||||
except Exception as e:
|
||||
logger.error("Failed to notify telegram", exc_info=e)
|
||||
|
||||
if (discord := integrations.discord) is not None:
|
||||
if discord.notifications_channel_id is not None:
|
||||
role_id = get_role_id(streamer_config, current_state.category)
|
||||
if role_id is not None:
|
||||
role = f"<@&{role_id}>"
|
||||
else:
|
||||
role = ""
|
||||
|
||||
msg = message_template.format(
|
||||
title=current_state.title,
|
||||
category=current_state.category,
|
||||
role=role
|
||||
)
|
||||
|
||||
try:
|
||||
await notify_discord(msg, str(discord.notifications_channel_id))
|
||||
except Exception as e:
|
||||
logger.error("Failed to notify discord", exc_info=e)
|
||||
10
src/modules/stream_notifications/twitch/state.py
Normal file
10
src/modules/stream_notifications/twitch/state.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class State(BaseModel):
|
||||
title: str
|
||||
category: str
|
||||
|
||||
last_live_at: datetime
|
||||
30
src/modules/stream_notifications/twitch/token_storage.py
Normal file
30
src/modules/stream_notifications/twitch/token_storage.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from core.mongo import mongo_manager
|
||||
|
||||
|
||||
class TokenStorage:
|
||||
COLLECTION_NAME = "secrets"
|
||||
OBJECT_ID = "twitch_tokens"
|
||||
|
||||
@staticmethod
|
||||
async def save(acceess_token: str, refresh_token: str):
|
||||
data = {"access_token": acceess_token, "refresh_token": refresh_token}
|
||||
|
||||
async with mongo_manager.connect() as client:
|
||||
db = client.get_default_database()
|
||||
collection = db[TokenStorage.COLLECTION_NAME]
|
||||
|
||||
await collection.update_one(
|
||||
{"_id": TokenStorage.OBJECT_ID},
|
||||
{"$set": data},
|
||||
upsert=True
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get() -> tuple[str, str]:
|
||||
async with mongo_manager.connect() as client:
|
||||
db = client.get_default_database()
|
||||
collection = db[TokenStorage.COLLECTION_NAME]
|
||||
|
||||
data = await collection.find_one({"_id": TokenStorage.OBJECT_ID})
|
||||
|
||||
return data["access_token"], data["refresh_token"]
|
||||
204
src/modules/stream_notifications/twitch/twitch.py
Normal file
204
src/modules/stream_notifications/twitch/twitch.py
Normal file
@@ -0,0 +1,204 @@
|
||||
from asyncio import Lock, sleep
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
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 StreamOnlineEvent, ChannelUpdateEvent
|
||||
|
||||
from core.config import config, StreamerConfig
|
||||
from modules.stream_notifications.notification import notify
|
||||
|
||||
from .state import State
|
||||
from .token_storage import TokenStorage
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TwitchService:
|
||||
lock = Lock()
|
||||
|
||||
SCOPES = [
|
||||
AuthScope.CHAT_READ,
|
||||
AuthScope.CHAT_EDIT,
|
||||
]
|
||||
|
||||
ONLINE_NOTIFICATION_DELAY = 15 * 60
|
||||
UPDATE_DELAY = 5 * 60
|
||||
|
||||
def __init__(self, twitch: Twitch):
|
||||
self.twitch = twitch
|
||||
|
||||
self.state: dict[int, State | None] = {}
|
||||
|
||||
@classmethod
|
||||
async def authorize(cls):
|
||||
twitch = Twitch(
|
||||
config.TWITCH_CLIENT_ID,
|
||||
config.TWITCH_CLIENT_SECRET
|
||||
)
|
||||
|
||||
twitch.user_auth_refresh_callback = TokenStorage.save
|
||||
|
||||
token, refresh_token = await TokenStorage.get()
|
||||
await twitch.set_user_authentication(token, cls.SCOPES, refresh_token)
|
||||
|
||||
await twitch.authenticate_app(cls.SCOPES)
|
||||
|
||||
return twitch
|
||||
|
||||
def get_streamer_config(self, streamer_id: int) -> StreamerConfig:
|
||||
for streamer in config.STREAMERS:
|
||||
if streamer.twitch.id == streamer_id:
|
||||
return streamer
|
||||
|
||||
raise ValueError(f"Streamer with id {streamer_id} not found")
|
||||
|
||||
async def notify_online(self, streamer_id: int):
|
||||
current_state = self.state.get(streamer_id)
|
||||
if current_state is None:
|
||||
raise RuntimeError("State is None")
|
||||
|
||||
streamer = self.get_streamer_config(streamer_id)
|
||||
|
||||
if streamer.notifications.start_stream is None:
|
||||
return
|
||||
|
||||
await notify("start", streamer, current_state)
|
||||
|
||||
async def notify_change_category(self, streamer_id: int):
|
||||
current_state = self.state.get(streamer_id)
|
||||
|
||||
if current_state is None:
|
||||
raise RuntimeError("State is None")
|
||||
|
||||
if (datetime.now() - current_state.last_live_at).seconds >= self.ONLINE_NOTIFICATION_DELAY:
|
||||
return
|
||||
|
||||
streamer = self.get_streamer_config(streamer_id)
|
||||
|
||||
if streamer.notifications.change_category is None:
|
||||
return
|
||||
|
||||
await notify("change_category", streamer, current_state)
|
||||
|
||||
async def get_current_stream(self, streamer_id: int, retry_count: int = 5, delay: int = 5):
|
||||
remain_retry = retry_count
|
||||
|
||||
while remain_retry > 0:
|
||||
stream = await first(self.twitch.get_streams(user_id=[str(streamer_id)]))
|
||||
|
||||
if stream is not None:
|
||||
return stream
|
||||
|
||||
remain_retry -= 1
|
||||
await sleep(delay)
|
||||
|
||||
return None
|
||||
|
||||
async def on_channel_update(self, event: ChannelUpdateEvent):
|
||||
brodcaster_id = int(event.event.broadcaster_user_id)
|
||||
|
||||
stream = await self.get_current_stream(brodcaster_id)
|
||||
if stream is None:
|
||||
return
|
||||
|
||||
async with self.lock:
|
||||
current_state = self.state.get(brodcaster_id)
|
||||
if current_state is None:
|
||||
return
|
||||
|
||||
changed = current_state.category != event.event.category_name
|
||||
|
||||
current_state.title = event.event.title
|
||||
current_state.category = event.event.category_name
|
||||
current_state.last_live_at = datetime.now()
|
||||
|
||||
self.state[brodcaster_id] = current_state
|
||||
|
||||
if changed:
|
||||
await self.notify_change_category(brodcaster_id)
|
||||
|
||||
async def _on_stream_online(self, streamer_id: int):
|
||||
current_stream = await self.get_current_stream(streamer_id)
|
||||
if current_stream is None:
|
||||
return
|
||||
|
||||
state = State(
|
||||
title=current_stream.title,
|
||||
category=current_stream.game_name,
|
||||
last_live_at=datetime.now()
|
||||
)
|
||||
|
||||
async with self.lock:
|
||||
current_state = self.state.get(streamer_id)
|
||||
|
||||
is_need_notify = current_state is None or (datetime.now() - current_state.last_live_at).seconds >= self.ONLINE_NOTIFICATION_DELAY
|
||||
|
||||
self.state[streamer_id] = state
|
||||
|
||||
if is_need_notify:
|
||||
await self.notify_online(streamer_id)
|
||||
|
||||
async def on_stream_online(self, event: StreamOnlineEvent):
|
||||
await self._on_stream_online(int(event.event.broadcaster_user_id))
|
||||
|
||||
async def run(self):
|
||||
eventsub = EventSubWebhook(
|
||||
callback_url=config.TWITCH_CALLBACK_URL,
|
||||
port=config.TWITCH_CALLBACK_PORT,
|
||||
twitch=self.twitch,
|
||||
message_deduplication_history_length=50
|
||||
)
|
||||
|
||||
for streamer in config.STREAMERS:
|
||||
current_stream = await self.get_current_stream(streamer.twitch.id)
|
||||
|
||||
if current_stream:
|
||||
self.state[streamer.twitch.id] = State(
|
||||
title=current_stream.title,
|
||||
category=current_stream.game_name,
|
||||
last_live_at=datetime.now()
|
||||
)
|
||||
else:
|
||||
self.state[streamer.twitch.id] = None
|
||||
|
||||
try:
|
||||
await eventsub.unsubscribe_all()
|
||||
|
||||
eventsub.start()
|
||||
|
||||
logger.info("Subscribe to events...")
|
||||
|
||||
for streamer in config.STREAMERS:
|
||||
logger.info(f"Subscribe to events for {streamer.twitch.name}")
|
||||
await eventsub.listen_channel_update_v2(str(streamer.twitch.id), self.on_channel_update)
|
||||
await eventsub.listen_stream_online(str(streamer.twitch.id), self.on_stream_online)
|
||||
logger.info(f"Subscribe to events for {streamer.twitch.name} done")
|
||||
|
||||
logger.info("Twitch service started")
|
||||
|
||||
while True:
|
||||
await sleep(self.UPDATE_DELAY)
|
||||
|
||||
for streamer in config.STREAMERS:
|
||||
await self._on_stream_online(streamer.twitch.id)
|
||||
finally:
|
||||
await eventsub.stop()
|
||||
await self.twitch.close()
|
||||
|
||||
raise RuntimeError("Twitch service stopped")
|
||||
|
||||
@classmethod
|
||||
async def start(cls):
|
||||
logger.info("Starting Twitch service...")
|
||||
|
||||
twith = await cls.authorize()
|
||||
await cls(twith).run()
|
||||
|
||||
|
||||
async def start_twitch_service():
|
||||
await TwitchService.start()
|
||||
Reference in New Issue
Block a user