Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions redbot/cogs/streams/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ class InvalidYoutubeCredentials(StreamsError):
pass


class InvalidKickCredentials(StreamsError):
pass


class YoutubeQuotaExceeded(StreamsError):
pass

Expand Down
169 changes: 160 additions & 9 deletions redbot/cogs/streams/streams.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from operator import is_
import discord
from redbot.core.utils.chat_formatting import humanize_list
from redbot.core.bot import Red
Expand All @@ -7,13 +8,15 @@
from redbot.core.utils.chat_formatting import escape, inline, pagify

from .streamtypes import (
KickStream,
PicartoStream,
Stream,
TwitchStream,
YoutubeStream,
)
from .errors import (
APIError,
InvalidKickCredentials,
InvalidTwitchCredentials,
InvalidYoutubeCredentials,
OfflineStream,
Expand Down Expand Up @@ -51,6 +54,7 @@ class Streams(commands.Cog):
"tokens": {},
"streams": [],
"notified_owner_missing_twitch_secret": False,
"notified_owner_missing_kick_secret": False,
}

guild_defaults = {
Expand All @@ -70,6 +74,7 @@ def __init__(self, bot: Red):
super().__init__()
self.config: Config = Config.get_conf(self, 26262626)
self.ttv_bearer_cache: dict = {}
self.kick_bearer_cache: dict = {}
self.config.register_global(**self.global_defaults)
self.config.register_guild(**self.guild_defaults)
self.config.register_role(**self.role_defaults)
Expand Down Expand Up @@ -105,6 +110,8 @@ async def cog_load(self) -> None:
async def on_red_api_tokens_update(self, service_name, api_tokens):
if service_name == "twitch":
await self.get_twitch_bearer_token(api_tokens)
elif service_name == "kick":
await self.get_kick_bearer_token(api_tokens)

async def move_api_keys(self) -> None:
"""Move the API keys from cog stored config to core bot config if they exist."""
Expand All @@ -126,7 +133,7 @@ async def _notify_owner_about_missing_twitch_secret(self) -> None:
"1. Go to this page: {link}.\n"
'2. Click "Manage" on your application.\n'
'3. Click on "New secret".\n'
"5. Copy your client ID and your client secret into:\n"
"4. Copy your client ID and your client secret into:\n"
"{command}"
"\n\n"
"Note: These tokens are sensitive and should only be used in a private channel "
Expand All @@ -142,6 +149,28 @@ async def _notify_owner_about_missing_twitch_secret(self) -> None:
await send_to_owners_with_prefix_replaced(self.bot, message)
await self.config.notified_owner_missing_twitch_secret.set(True)

async def _notify_owner_about_missing_kick_secret(self) -> None:
message = _(
"You need a client secret key if you want to use the Kick API on this cog.\n"
"Follow these steps:\n"
"1. Go to this page: {link}.\n"
'2. Click "Manage" on your application.\n'
"3. Copy your client ID and your client secret into:\n"
"{command}"
"\n\n"
"Note: These tokens are sensitive and should only be used in a private channel "
"or in DM with the bot."
).format(
link="https://kick.com/settings/developer",
command=inline(
"[p]set api twitch client_id {} client_secret {}".format(
_("<your_client_id_here>"), _("<your_client_secret_here>")
)
),
)
await send_to_owners_with_prefix_replaced(self.bot, message)
await self.config.notified_owner_missing_kick_secret.set(True)

async def get_twitch_bearer_token(self, api_tokens: Optional[Dict] = None) -> None:
tokens = (
await self.bot.get_shared_api_tokens("twitch") if api_tokens is None else api_tokens
Expand Down Expand Up @@ -198,9 +227,64 @@ async def get_twitch_bearer_token(self, api_tokens: Optional[Dict] = None) -> No
self.ttv_bearer_cache["expires_at"] = datetime.now().timestamp() + data.get("expires_in")

async def maybe_renew_twitch_bearer_token(self) -> None:
if self.ttv_bearer_cache:
if self.ttv_bearer_cache["expires_at"] - datetime.now().timestamp() <= 60:
await self.get_twitch_bearer_token()
if (
self.ttv_bearer_cache
and self.ttv_bearer_cache["expires_at"] - datetime.now().timestamp() <= 60
):
await self.get_twitch_bearer_token()

async def get_kick_bearer_token(self, api_tokens: Optional[Dict] = None) -> None:
tokens = await self.bot.get_shared_api_tokens("kick") if api_tokens is None else api_tokens
if tokens.get("client_id"):
notified_owner_missing_kick_secret = (
await self.config.notified_owner_missing_kick_secret()
)
try:
tokens["client_secret"]
if notified_owner_missing_kick_secret is True:
await self.config.notified_owner_missing_kick_secret.set(False)
except KeyError:
if notified_owner_missing_kick_secret is False:
asyncio.create_task(self._notify_owner_about_missing_kick_secret())
async with aiohttp.ClientSession() as session:
async with session.post(
"https://id.kick.com/oauth/token",
params={
"client_id": tokens.get("client_id", ""),
"client_secret": tokens.get("client_secret", ""),
"grant_type": "client_credentials",
},
) as req:
try:
data = await req.json()
except aiohttp.ContentTypeError:
data = {}

if req.status == 200:
pass
elif req.status == 401 and data.get("error") == "invalid_client":
log.error("Kick API request failed authentication: set Client ID is invalid.")
elif "error" in data:
log.error(
"Kick OAuth2 API request failed with status code %s and error message: %s",
req.status,
data["error"],
)
else:
log.error("Kick OAuth2 API request failed with status code %s", req.status)

if req.status != 200:
return

self.kick_bearer_cache = data
self.kick_bearer_cache["expires_at"] = datetime.now().timestamp() + data.get("expires_in")

async def maybe_renew_kick_token(self) -> None:
if (
self.kick_bearer_cache
and self.kick_bearer_cache["expires_at"] - datetime.now().timestamp() <= 60
):
await self.get_kick_bearer_token()

@commands.guild_only()
@commands.command()
Expand Down Expand Up @@ -242,10 +326,19 @@ async def picarto(self, ctx: commands.Context, channel_name: str):
stream = PicartoStream(_bot=self.bot, name=channel_name)
await self.check_online(ctx, stream)

@commands.guild_only()
@commands.command()
async def kickstream(self, ctx: commands.Context, channel_name: str):
"""Check if a Kick channel is live."""
await self.maybe_renew_kick_token()
token = self.kick_bearer_cache.get("access_token")
stream = _streamtypes.KickStream(_bot=self.bot, name=channel_name, token=token)
await self.check_online(ctx, stream)

async def check_online(
self,
ctx: commands.Context,
stream: Union[PicartoStream, YoutubeStream, TwitchStream],
stream: Union[PicartoStream, YoutubeStream, TwitchStream, KickStream],
):
try:
info = await stream.is_online()
Expand All @@ -265,6 +358,12 @@ async def check_online(
"The YouTube API key is either invalid or has not been set. See {command}."
).format(command=inline(f"{ctx.clean_prefix}streamset youtubekey"))
)
except InvalidKickCredentials:
await ctx.send(
_("The Kick API key is either invalid or has not been set. See {command}.").format(
command=inline(f"{ctx.clean_prefix}streamset kicktoken")
)
)
except YoutubeQuotaExceeded:
await ctx.send(
_(
Expand Down Expand Up @@ -363,6 +462,18 @@ async def picarto_alert(
"""Toggle alerts in this channel for a Picarto stream."""
await self.stream_alert(ctx, PicartoStream, channel_name, discord_channel)

@streamalert.command(name="kick")
async def kick_alert(
self,
ctx: commands.Context,
channel_name: str,
discord_channel: Union[
discord.TextChannel, discord.VoiceChannel, discord.StageChannel
] = commands.CurrentChannel,
):
"""Toggle alerts in this channel for a Kick stream."""
await self.stream_alert(ctx, KickStream, channel_name, discord_channel)

@streamalert.command(name="stop", usage="[disable_all=No]")
async def streamalert_stop(self, ctx: commands.Context, _all: bool = False):
"""Disable all stream alerts in this channel or server.
Expand Down Expand Up @@ -435,6 +546,7 @@ async def stream_alert(self, ctx: commands.Context, _class, channel_name, discor
token = await self.bot.get_shared_api_tokens(_class.token_name)
is_yt = _class.__name__ == "YoutubeStream"
is_twitch = _class.__name__ == "TwitchStream"
is_kick = _class.__name__ == "KickStream"
if is_yt and not self.check_name_or_id(channel_name):
stream = _class(_bot=self.bot, id=channel_name, token=token, config=self.config)
elif is_twitch:
Expand All @@ -445,6 +557,10 @@ async def stream_alert(self, ctx: commands.Context, _class, channel_name, discor
token=token.get("client_id"),
bearer=self.ttv_bearer_cache.get("access_token", None),
)
elif is_kick:
await self.maybe_renew_kick_token()
token = self.kick_bearer_cache.get("access_token")
stream = _class(_bot=self.bot, name=channel_name, token=token)
else:
if is_yt:
stream = _class(
Expand All @@ -464,8 +580,7 @@ async def stream_alert(self, ctx: commands.Context, _class, channel_name, discor
except InvalidYoutubeCredentials:
await ctx.send(
_(
"The YouTube API key is either invalid or has not been set. See "
"{command}."
"The YouTube API key is either invalid or has not been set. See {command}."
).format(command=inline(f"{ctx.clean_prefix}streamset youtubekey"))
)
return
Expand All @@ -476,6 +591,13 @@ async def stream_alert(self, ctx: commands.Context, _class, channel_name, discor
" Try again later or contact the owner if this continues."
)
)
except InvalidKickCredentials:
await ctx.send(
_(
"The Kick API key is either invalid or has not been set. See {command}."
).format(command=inline(f"{ctx.clean_prefix}streamset kicktoken"))
)
return
except APIError as e:
log.error(
"Something went wrong whilst trying to contact the stream service's API.\n"
Expand Down Expand Up @@ -537,6 +659,30 @@ async def twitchtoken(self, ctx: commands.Context):

await ctx.maybe_send_embed(message)

@streamset.command()
@commands.is_owner()
async def kicktoken(self, ctx: commands.Context):
"""Explain how to set the Kick token."""
message = _(
"To get one, do the following:\n"
"1. Go to this page: {link}.\n"
"2. Click on *Create new*.\n"
"3. Fill the name and description, for *Redirection URL* add *http://localhost*.\n"
"4. Click on *Create Application*.\n"
"5. Copy your client ID and your client secret into:\n"
"{command}"
"\n\n"
"Note: These tokens are sensitive and should only be used in a private channel\n"
"or in DM with the bot.\n"
).format(
link="https://kick.com/settings/developer",
command="`{}set api kick client_id {} client_secret {}`".format(
ctx.clean_prefix, _("<your_client_id_here>"), _("<your_client_secret_here>")
),
)

await ctx.maybe_send_embed(message)

@streamset.command()
@commands.is_owner()
async def youtubekey(self, ctx: commands.Context):
Expand Down Expand Up @@ -826,15 +972,18 @@ async def check_streams(self):
for stream in self.streams:
try:
try:
is_rerun = False
is_schedule = False
is_rerun, is_schedule = False, False
if stream.__class__.__name__ == "TwitchStream":
await self.maybe_renew_twitch_bearer_token()
embed, is_rerun = await stream.is_online()

elif stream.__class__.__name__ == "YoutubeStream":
embed, is_schedule = await stream.is_online()

elif stream.__class__.__name__ == "KickStream":
await self.maybe_renew_kick_token()
embed = await stream.is_online()

else:
embed = await stream.is_online()
except StreamNotFound:
Expand Down Expand Up @@ -1008,6 +1157,8 @@ async def load_streams(self):
if _class.__name__ == "TwitchStream":
raw_stream["token"] = token.get("client_id")
raw_stream["bearer"] = self.ttv_bearer_cache.get("access_token", None)
elif _class.__name__ == "KickStream":
raw_stream["token"] = self.kick_bearer_cache.get("access_token", None)
else:
if _class.__name__ == "YoutubeStream":
raw_stream["config"] = self.config
Expand Down
Loading
Loading