8 Commits

8 changed files with 389 additions and 21 deletions

View File

@@ -34,6 +34,8 @@ active_discord = [
'.voiceroles',
'.hyperfocus',
'.twreminders',
'.time',
'.checkin',
]
async def setup(bot):

View File

@@ -0,0 +1,8 @@
import logging
logger = logging.getLogger(__name__)
from .cog import CheckinCog
async def setup(bot):
await bot.add_cog(CheckinCog(bot))

154
src/modules/checkin/cog.py Normal file
View File

@@ -0,0 +1,154 @@
import asyncio
from typing import Optional
import datetime as dt
from datetime import timedelta, datetime
import discord
import twitchAPI
from twitchAPI.object.eventsub import ChannelPointsCustomRewardRedemptionData
from twitchAPI.eventsub.websocket import EventSubWebsocket
from twitchAPI.type import AuthScope
import twitchio
from twitchio.ext import commands
from meta import CrocBot, LionCog, LionContext, LionBot
from utils.lib import utc_now
from . import logger
class CheckinCog(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
self.crocbot: CrocBot = bot.crocbot
self.listeners = []
self.eswebsockets = {}
async def cog_load(self):
self._loop = asyncio.get_running_loop()
self._load_twitch_methods(self.crocbot)
check_in_channel_id = self.bot.config.croccy['check_in_channel'].strip()
await self.attach_checkin_channel(check_in_channel_id)
async def cog_unload(self):
self._unload_twitch_methods(self.crocbot)
async def fetch_eventsub_for(self, channelid):
if (eventsub := self.eswebsockets.get(channelid)) is None:
authcog = self.bot.get_cog('TwitchAuthCog')
if not await authcog.check_auth(channelid, scopes=[AuthScope.CHANNEL_READ_REDEMPTIONS]):
logger.error(
f"Insufficient auth to login to registered check-in channelid {channelid}"
)
else:
twitch = await authcog.fetch_client_for(channelid)
eventsub = EventSubWebsocket(twitch)
eventsub.start()
self.eswebsockets[channelid] = eventsub
return eventsub
async def attach_checkin_channel(self, channel):
# Register a listener for the given channel (given as a string id)
eventsub = await self.fetch_eventsub_for(channel)
if eventsub:
await eventsub.listen_channel_points_custom_reward_redemption_add(channel, self.handle_redeem)
logger.info(f"Attached check-in listener to registered channel {channel}")
else:
logger.error(f"Could not attach checkin listener to registered channel {channel}")
async def handle_redeem(self, data: ChannelPointsCustomRewardRedemptionData):
# Check if the redeem is one of the 'checkin' or 'quiet checkin' redeems.
title = data.event.reward.title.lower()
# TODO: Redeem ID based registration (configured)
seeking = ('check in', 'quiet hello')
if title in seeking:
quiet = seeking.index(title)
await self.do_checkin(
data.event.broadcaster_user_id,
data.event.broadcaster_user_login,
data.event.user_id,
data.event.user_name,
quiet,
data.event.redeemed_at
)
async def do_checkin(self, channel, channel_name, user, user_name, quiet, redeemed_at):
logger.info(
f"Starting checkin process for {channel_name=}, {user_name=}, {quiet=}, {redeemed_at=}"
)
checkin_counter_name = '_checkin'
first_counter_name = '_first'
second_counter_name = '_second'
third_counter_name = '_third'
counters = self.bot.get_cog('CounterCog')
if not counters:
raise ValueError("Check-in running without counters cog loaded!")
profiles = self.bot.get_cog('ProfileCog')
if not profiles:
raise ValueError("Check-in running without profile cog loaded!")
# TODO: Relies on profile implementation detail
profile = await profiles.fetch_profile_twitch(discord.Object(id=user))
stream_start = await self.get_stream_start(channel)
# Stream has to be running for this to do anything
if stream_start is not None:
# Get all check-in redeems since the start of stream.
check_in_counter = await counters.fetch_counter(checkin_counter_name)
entries = await counters.data.CounterEntry.table.select_where(
counters.data.CounterEntry.created_at >= stream_start,
counterid=check_in_counter.counterid,
)
position = len(entries) + 1
if profile.profileid not in (e['userid'] for e in entries):
# User has not already checked in!
# Check them in
# TODO: May be worth setting custom counter time
await counters.add_to_counter(
counter=check_in_counter.name,
userid=profile.profileid,
value=1,
)
checkin_total = await counters.personal_total(checkin_counter_name, profile.profileid)
# If they deserve a first, give them that
position_total = None
if position <= 3:
counter_name = (first_counter_name, second_counter_name, third_counter_name)[position-1]
await counters.add_to_counter(
counter=counter_name,
userid=profile.profileid,
value=1,
)
position_total = await counters.personal_total(counter_name, profile.profileid)
if not quiet:
name = user_name
if position == 1:
message = f"Welcome in and congrats on first check-in {name}! You have been first {position_total}/{checkin_total} times!"
else:
# TODO: Randomised replies
# TODO: Maybe different messages for lower positions or earlier times but not explicitly giving numbers?
# Need to update this for stream calcs anyway.
message = f"Welcome in {name}! You have checked in {checkin_total} times! Let's have a productive time together~"
# Now get the channel and post
channel = self.crocbot.get_channel(channel_name)
if not channel:
logger.error(
f"Channel {channel_name} is not in cache. Cannot send checkin reply."
)
else:
await channel.send(message)
async def get_stream_start(self, channelid: str | int) -> Optional[datetime]:
future = asyncio.run_coroutine_threadsafe(self._get_stream_start(channelid), self._loop)
return future.result()
async def _get_stream_start(self, channelid: str | int) -> Optional[datetime]:
streams = await self.crocbot.fetch_streams(user_ids=[int(channelid)])
if streams:
return streams[0].started_at

View File

@@ -3,6 +3,8 @@ from enum import Enum
from typing import Optional
from datetime import timedelta
from data.base import RawExpr
from data.columns import Column
import discord
from discord.ext import commands as cmds
from discord import app_commands as appcmds
@@ -165,7 +167,8 @@ class CounterCog(LionCog):
)
disc_cmds.append(
cmds.hybrid_command(
name=row.name
name=row.name,
with_app_command=False,
)(self.discord_callback(counter_cb))
)
@@ -177,18 +180,20 @@ class CounterCog(LionCog):
)
disc_cmds.append(
cmds.hybrid_command(
name=row.lbname
name=row.lbname,
with_app_command=False,
)(self.discord_callback(lb_cb))
)
if row.undoname:
twitch_cmds.append(
commands.command(
name=row.undoname
name=row.undoname,
)(self.twitch_callback(undo_cb))
)
disc_cmds.append(
cmds.hybrid_command(
name=row.undoname
name=row.undoname,
with_app_command=False,
)(self.discord_callback(undo_cb))
)
@@ -370,6 +375,51 @@ class CounterCog(LionCog):
await ctx.reply("Counter userid->profileid migration done.")
# Counters commands
@commands.command()
async def counterslb(self, ctx: commands.Context, *, periodstr: Optional[str] = None):
"""
Build a leaderboard of counter totals in the given period.
"""
profiles = self.bot.get_cog('ProfileCog')
author = await profiles.fetch_profile_twitch(ctx.author)
userid = author.profileid
community = await profiles.fetch_community_twitch(await ctx.channel.user())
period, start_time = await self.parse_period(community, periodstr or '')
query = self.data.CounterEntry.table.select_where()
query.group_by('counterid')
query.select('counterid', counter_total='SUM(value)')
query.order_by('counter_total', ORDER.DESC)
# query.where(Column('counter_total') > 0)
if start_time is not None:
query.where(self.data.CounterEntry.created_at >= start_time)
query.with_no_adapter()
results = await query
query.where(self.data.CounterEntry.userid == userid)
user_results = await query
lb = {result['counterid']: result['counter_total'] for result in results}
userlb = {result['counterid']: result['counter_total'] for result in user_results}
counters = await self.data.Counter.fetch_where(counterid=list(lb.keys()))
cmap = {c.counterid: c for c in counters}
parts = []
for cid, ctotal in lb.items():
if not ctotal:
continue
counter = cmap[cid]
user_total = userlb.get(cid) or 0
parts.append(f"{counter.name}: {ctotal}")
prefix = 'top 10 ' if len(parts) > 10 else ''
parts = parts[:10]
lbstr = '; '.join(parts)
await ctx.reply(f"Counters {period.value[-1]} {prefix}leaderboard -- {lbstr}")
@commands.command()
async def counter(self, ctx: commands.Context, name: str, subcmd: Optional[str], *, args: Optional[str]=None):
if not (ctx.author.is_mod or ctx.author.is_broadcaster):

View File

@@ -83,7 +83,7 @@ class FocusChannel(Channel):
class HyperFocusCog(LionCog):
def __init__(self, bot: CrocBot):
def __init__(self, bot: LionBot):
self.bot = bot
self.crocbot: CrocBot = bot.crocbot

View File

@@ -0,0 +1,8 @@
import logging
logger = logging.getLogger(__name__)
from .cog import TimeCog
async def setup(bot):
await bot.add_cog(TimeCog(bot))

110
src/modules/time/cog.py Normal file
View File

@@ -0,0 +1,110 @@
import datetime as dt
import twitchio
from twitchio.ext import commands
from meta import CrocBot, LionCog, LionContext, LionBot
from utils.lib import strfdelta, utc_now, parse_dur
from . import logger
class TimeCog(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
self.crocbot: CrocBot = bot.crocbot
async def cog_load(self):
self._load_twitch_methods(self.crocbot)
async def cog_unload(self):
self._unload_twitch_methods(self.crocbot)
async def get_timezone_for(self, profile):
timezone = None
discords = await profile.discord_accounts()
if discords:
userid = discords[0].userid
luser = await self.bot.core.lions.fetch_user(userid)
if luser:
timezone = luser.config.timezone.value
return timezone
def get_timestr(self, tz, brief=False):
"""
Get the current time in the given timezone, using a fixed format string.
"""
format_str = "%H:%M, %d/%m/%Y" if brief else "%I:%M %p (%Z) on %a, %d/%m/%Y"
now = dt.datetime.now(tz=tz)
return now.strftime(format_str)
async def time_diff(self, tz, auth_tz, name, brief=False):
"""
Get a string representing the time difference between the user's timezone and the given one.
"""
if auth_tz is None or tz is None:
return None
author_time = dt.datetime.now(tz=auth_tz)
other_time = dt.datetime.now(tz=tz)
timediff = other_time.replace(tzinfo=None) - author_time.replace(tzinfo=None)
diffsecs = round(timediff.total_seconds())
if diffsecs == 0:
return ", the same as {}!".format(name)
modifier = "behind" if diffsecs > 0 else "ahead"
diffsecs = abs(diffsecs)
hours, remainder = divmod(diffsecs, 3600)
mins, _ = divmod(remainder, 60)
hourstr = "{} hour{} ".format(hours, "s" if hours > 1 else "") if hours else ""
minstr = "{} minutes ".format(mins) if mins else ""
joiner = "and " if (hourstr and minstr) else ""
return ". {} is {}{}{}{}, at {}.".format(
name, hourstr, joiner, minstr, modifier, self.get_timestr(auth_tz, brief=brief)
)
@commands.command(name='time', aliases=['ti'])
async def time_cmd(self, ctx, *, args: str=''):
"""
Current usage is
!time
!time <target user>
Planned:
!time set ...
!time at ...
"""
authprofile = await self.bot.get_cog('ProfileCog').fetch_profile_twitch(ctx.author)
authtz = await self.get_timezone_for(authprofile)
if args:
target_tw = await self.crocbot.seek_user(args)
if target_tw is None:
return await ctx.reply(f"Couldn't find user '{args}'!")
target = await self.bot.get_cog('ProfileCog').fetch_profile_twitch(target_tw)
targettz = await self.get_timezone_for(target)
name = await target.get_name()
if targettz is None:
return await ctx.reply(
f"{name} hasn't set their timezone! Ask them to set it with '/my timezone' on discord."
)
else:
target = None
targettz = None
name = None
if authtz is None:
return await ctx.reply(
"You haven't set your timezone! Set it on discord by linking your Twitch account with `/profiles link twitch`, and then using `/my timezone`"
)
timestr = self.get_timestr(targettz if target else authtz)
name = name or await authprofile.get_name()
if target:
tdiffstr = await self.time_diff(targettz, authtz, await authprofile.get_name())
msg = f"The current time for {name} is {timestr}{tdiffstr}"
else:
msg = f"The current time for {name} is {timestr}"
await ctx.reply(msg)

View File

@@ -6,6 +6,8 @@ from typing import Optional
from dataclasses import dataclass
from collections import defaultdict
from dateutil.parser import ParserError, parse
import twitchio
from twitchio.ext import commands
import datetime as dt
@@ -18,9 +20,10 @@ from . import logger
reminder_regex = re.compile(
r"""
(^)?(?P<type> (?: \b in) | (?: every))
\s*(?P<duration> (?: day| hour| (?:\d+\s*(?:(?:d|h|m|s)[a-zA-Z]*)?(?:\s|and)*)+))
(?:(?(1) (?:, | ; | : | \. | to)? | $))
(^)?(?P<type> (?: \b in) | (?: every) | (?P<at> at))
\s*
(?(at) (?P<time> \d?\d (?: :\d\d)?\s*(?: am | pm)?) | (?P<duration> (?: day| hour| (?:\d+\s*(?:(?:d|h|m|s)[a-zA-Z]*)?(?:\s|and)*)+)))
(?:(?(1) (?:, | ; | : | \. | to)?\s+ | $ ))
""",
re.IGNORECASE | re.VERBOSE | re.DOTALL
)
@@ -274,20 +277,53 @@ class ReminderCog(LionCog):
# First parse it
match = re.search(reminder_regex, args)
if match:
repeating = match.group('type').lower() == 'every'
duration_str = match.group('duration').lower()
if duration_str.isdigit():
# Default to minutes if no unit given
duration = int(duration_str) * 60
elif duration_str in ('day', 'a day'):
duration = 24 * 60 * 60
elif duration_str in ('hour', 'an hour'):
duration = 60 * 60
else:
duration = parse_dur(duration_str)
typ = match.group('type').lower().strip()
content = (args[:match.start()] + args[match.end():]).strip()
if typ in ('every', 'in'):
repeating = typ == 'every'
duration_str = match.group('duration').lower()
if duration_str.isdigit():
# Default to minutes if no unit given
duration = int(duration_str) * 60
elif duration_str in ('day', 'a day'):
duration = 24 * 60 * 60
elif duration_str in ('hour', 'an hour'):
duration = 60 * 60
else:
duration = parse_dur(duration_str)
elif typ == 'at':
# Get timezone for this member.
profile = await self.bot.get_cog('ProfileCog').fetch_profile_twitch(ctx.author)
timezone = None
discords = await profile.discord_accounts()
if discords:
luserid = discords[0].userid
luser = await self.bot.core.lions.fetch_user(luserid)
if luser:
timezone = luser.config.timezone.value
if not timezone:
return await ctx.reply(
"Sorry, to use this you have to link your account with `/profiles link twitch` and set your timezone with '/my timezone' on the Discord!"
)
time_str = match.group('time').lower()
if time_str.isdigit():
# Assume it's an hour
time_str = time_str + ':00'
default = dt.datetime.now(tz=timezone).replace(hour=0, minute=0, second=0, microsecond=0)
try:
ts = parse(time_str, fuzzy=True, default=default)
except ParserError:
return await ctx.reply(
"Sorry, I didn't understand your target time! Please use e.g. !remindme Remember to hydrate at 10pm"
)
while ts < dt.datetime.now(tz=timezone):
ts += dt.timedelta(days=1)
duration = (ts - dt.datetime.now(tz=timezone)).total_seconds()
duration = int(duration)
if content.startswith('to '):
content = content[3:].strip()
else: