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 .discord import start_discord_sevice
start = start_discord_sevice
__all__ = ["start"]

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

View 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]

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

View File

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

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

View File

@@ -0,0 +1,10 @@
from datetime import datetime
from pydantic import BaseModel
class State(BaseModel):
title: str
category: str
last_live_at: datetime

View 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"]

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