Skip to content

Commit

Permalink
Add Twitch auto-status-update
Browse files Browse the repository at this point in the history
  • Loading branch information
4Kaylum committed Oct 9, 2023
1 parent fbb6eea commit 1fce775
Show file tree
Hide file tree
Showing 3 changed files with 224 additions and 7 deletions.
32 changes: 25 additions & 7 deletions novus/ext/client/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class LoopBehavior(Enum):

def loop(
loop_time: float,
start_behavior: LoopBehavior = LoopBehavior.immediate,
end_behavior: LoopBehavior = LoopBehavior.end,
autostart: bool = True,
wait_until_ready: bool = True) -> Callable[..., Loop]:
Expand All @@ -67,11 +68,13 @@ def loop(
The number of seconds between each loop.
This is not guarenteed to be accurate, but is used internally for the
wait function.
end_behavior: LoopBehavior
start_behavior : LoopBehavior
How the loop should behave on its start.
end_behavior : LoopBehavior
How the loop should behave on its end.
autostart: bool
autostart : bool
Whether the loop should start immediately when the plugin is loaded.
wait_until_ready: bool
wait_until_ready : bool
If the plugin should wait until the bot has received the ready payload
before beginning its task.
Expand All @@ -90,6 +93,7 @@ def wrapper(func: Callable[[], Coroutine[None, None, Any]]) -> Loop:
return Loop(
func,
loop_time,
start_behavior,
end_behavior,
autostart,
wait_until_ready,
Expand All @@ -107,6 +111,8 @@ class Loop:
The function that is part of the loop.
loop_time : float
The number of seconds between each loop iteration.
start_behavior : novus.ext.client.LoopBehaviour
The behaviour for how each loop will end.
end_behavior : novus.ext.client.LoopBehaviour
The behaviour for how each loop will end.
autostart : bool
Expand All @@ -122,6 +128,7 @@ class Loop:
__slots__ = (
'func',
'loop_time',
'start_behavior',
'end_behavior',
'autostart',
'wait_until_ready',
Expand All @@ -137,11 +144,13 @@ def __init__(
self,
func: Callable[[], Coroutine[None, None, Any]],
loop_time: float,
start_behavior: LoopBehavior = LoopBehavior.immediate,
end_behavior: LoopBehavior = LoopBehavior.end,
autostart: bool = True,
wait_until_ready: bool = True):
self.func = func
self.loop_time = loop_time
self.start_behavior = start_behavior
self.end_behavior = end_behavior
self.autostart = autostart
self.wait_until_ready = wait_until_ready
Expand Down Expand Up @@ -175,11 +184,20 @@ async def _run(self) -> None:
await self.owner.bot.wait_until_ready()
if self._before:
await self._before()
first = True
while True:
try:
await asyncio.sleep(self.loop_time)
except asyncio.CancelledError:
return
if first:
if self.start_behavior == LoopBehavior.end:
try:
await asyncio.sleep(self.loop_time)
except asyncio.CancelledError:
return
else:
try:
await asyncio.sleep(self.loop_time)
except asyncio.CancelledError:
return
first = False
log.info("Running Loop[%s.%s()]", self.owner.__name__, self.func.__name__)
task = asyncio.create_task(self.func(self.owner, *self._args, **self._kwargs)) # pyright: ignore
if self.end_behavior == LoopBehavior.end:
Expand Down
22 changes: 22 additions & 0 deletions novus/ext/twitch/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""
Copyright (c) Kae Bartlett
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""

from .twitch import *

__all__: tuple[str, ...] = (
'Twitch',
)
177 changes: 177 additions & 0 deletions novus/ext/twitch/twitch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""
Copyright (c) Kae Bartlett
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""

from __future__ import annotations

import asyncio
from typing import Any

import aiohttp

import novus as n
from novus.ext import client

__all__ = (
'Twitch',
)


class LiveChannel:

def __init__(self, data: dict):
self.id = data["id"]
self.user_id = data["user_id"]
self.user_login = data["user_login"]
self.user_name = data["user_name"]
self.game_name = data["game_name"]
self.title = data["title"]

def __eq__(self, other: LiveChannel | Any) -> bool:
if not isinstance(other, self.__class__):
return False
return all((
self.user_id == other.user_id,
self.title == other.title,
self.game_name == other.game_name,
))


class Twitch(client.Plugin):
"""
A plugin saddled with checking if a Twitch account is live and changing
the status accordingly.
"""

CONFIG = {
"twitch_client_id": "",
"twitch_client_secret": "",
"twitch_users": [],
}
USER_AGENT = "Novus Discord bot client live checker."

async def on_load(self) -> None:
self._access_token: str = None # pyright: ignore
self._previous_live_channels: list[LiveChannel] = []
return await super().on_load()

async def get_access_token(self) -> str | None:
"""
Get the access token from Twitch and store it in a local parameter.
"""

# See if there's already a token (and thus a running task to refresh it)
if self._access_token is not None:
return self._access_token

# Format our params
params = {
"client_id": self.bot.config.twitch_client_id,
"client_secret": self.bot.config.twitch_client_secret,
"grant_type": "client_credentials",
}
if None in params.values() or "" in params.values():
self.log.info("Ignoring getting Twitch client info for missing client data.")
self.change_presence_to_live_loop.stop()
return None # Missing client ID or secret

# Get the token
async with aiohttp.ClientSession() as session:
site = await session.post(
"https://id.twitch.tv/oauth2/token",
json=params,
headers={
"User-Agent": self.USER_AGENT,
},
)
data = await site.json()

# Make sure we have a token
if not data.get("access_token"):
self.log.info("Failed to get access token from Twitch - %s", data)
if site.status == 400:
self.change_presence_to_live_loop.stop()
return None

# Store and score
self._access_token = data["access_token"]
expiry = data["expires_in"]
async def get_new_token():
await asyncio.sleep(expiry - 30)
await self.get_access_token()
asyncio.create_task(get_new_token())
return self._access_token

async def get_live_channels(self) -> list[LiveChannel]:
"""
Get a list of live channels from our expected list of channels.
"""

# Get the access token and list of people to check
user_list: list[str] = self.bot.config.twitch_users
if not user_list:
return [] # Technically correct data!
access_token = await self.get_access_token()
if access_token is None:
return [] # No token, let's just skip the whole loop

# Ask the Twitch API for all the live users
user_string = "&".join([f"user_login={i}" for i in user_list])
async with aiohttp.ClientSession() as session:
site = await session.get(
f"https://api.twitch.tv/helix/streams?type=live&{user_string}",
headers={
"User-Agent": self.USER_AGENT,
"Authorization": f"Bearer {access_token}",
"Client-Id": self.bot.config.twitch_client_id,
}
)
data = await site.json()
self.log.debug("Data back from Twitch for online streams: %s", data)

# And return
if not site.ok:
return []
return [
LiveChannel(i)
for i in data["data"]
]

@client.loop(60)
async def change_presence_to_live_loop(self):
"""
Change the bot's presence to whichever channel may be live at that
point in time.
"""

live_channels = await self.get_live_channels()
if live_channels == self._previous_live_channels:
return

self._previous_live_channels = live_channels
if not live_channels:
await self.bot.change_presence()

check = live_channels[0]
await self.bot.change_presence(
activities=[
n.Activity(
f"{check.title} ({check.game_name})",
type=n.ActivityType.streaming,
url=f"https://twitch.tv/{check.user_name}",
)
]
)

0 comments on commit 1fce775

Please sign in to comment.