11 Commits

10 changed files with 717 additions and 7 deletions

View File

@@ -33,6 +33,9 @@ active_discord = [
'.tagstrings',
'.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

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

View File

@@ -0,0 +1,369 @@
import asyncio
import json
import re
import itertools
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
from datetime import timedelta, datetime
from meta import CrocBot, LionCog, LionContext, LionBot
from utils.lib import strfdelta, utc_now, parse_dur
from . import logger
reminder_regex = re.compile(
r"""
(^)?(?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
)
@dataclass
class Reminder:
userid: int
content: str
name: str
channel: str
remind_at: datetime
class ReminderCog(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
self.crocbot: CrocBot = bot.crocbot
self.loaded = asyncio.Event()
self.reminders: dict[int, list[Reminder]] = defaultdict(list)
self.next_reminder_task = None
self._reminder_wait_task = None
self.reminder_lock = asyncio.Lock()
async def cog_load(self):
await self.load_reminders()
self._load_twitch_methods(self.crocbot)
self.loaded.set()
async def ensure_loaded(self):
if not self.loaded.is_set():
await self.cog_load()
async def cog_unload(self):
self._unload_twitch_methods(self.crocbot)
async def cog_check(self, ctx):
await self.ensure_loaded()
return True
def save_reminders(self):
with open('reminders.json', 'w', encoding='utf-8') as f:
mapped = {
int(userid): [
{
'userid': int(state.userid),
'name': state.name,
'channel': state.channel,
'content': state.content,
'remind_at': state.remind_at.isoformat(),
}
for state in states
]
for userid, states in self.reminders.items()
}
json.dump(mapped, f, ensure_ascii=False, indent=4)
async def load_reminders(self):
if self.next_reminder_task and not self.next_reminder_task.cancelled():
self.next_reminder_task.cancel()
self.next_reminder_task = None
with open('reminders.json') as f:
mapped = json.load(f)
self.reminders.clear()
for userid, states in mapped.items():
userid = int(userid)
for map in states:
reminder = Reminder(
userid=int(map['userid']),
content=map['content'],
name=map['name'],
channel=map['channel'],
remind_at=dt.datetime.fromisoformat(map['remind_at'])
)
self.reminders[userid].append(reminder)
self.schedule_next_reminder()
logger.info(f"Loaded reminders: {self.reminders}")
def schedule_next_reminder(self):
"""
Schedule the next reminder in the queue, if it exists, and return it.
Cancels any currently running task.
"""
if not self.reminders:
return None
next_reminder = min(
itertools.chain(*self.reminders.values()), key=lambda r: r.remind_at, default=None
)
if next_reminder:
self.next_reminder_task = asyncio.create_task(self.run_reminder(next_reminder))
else:
# We still need to cancel any ongoing reminders
if self._reminder_wait_task and not self._reminder_wait_task.cancelled():
self._reminder_wait_task.cancel()
async def run_reminder(self, reminder: Reminder):
"""
Wait for and then run the given reminder.
Expects to be cancelled if another reminder is scheduled earlier.
"""
# Cancel the next reminder wait task.
# If the next reminder is currently executing/firing,
# this will do nothing and we will wait until it is finished.
if self._reminder_wait_task and not self._reminder_wait_task.cancelled():
self._reminder_wait_task.cancel()
# This ensures that only one reminder task runs at once
async with self.reminder_lock:
now = utc_now()
to_wait = (reminder.remind_at - now).total_seconds()
try:
self._reminder_wait_task = asyncio.create_task(asyncio.sleep(to_wait))
await self._reminder_wait_task
except asyncio.CancelledError:
# Reminder task was cancelled
raise
# Now fire the reminder
await self.fire_reminder(reminder)
# And schedule the next reminder if needed
self.schedule_next_reminder()
async def fire_reminder(self, reminder: Reminder):
"""
Actually run the given reminder.
"""
# Check that this reminder is still valid
if reminder not in self.reminders[reminder.userid]:
logger.error(f"Reminder {reminder!r} is firing but not scheduled!")
return
# We don't want to reschedule while a reminder is running
# Get the channel to send to
destination = self.crocbot.get_channel(reminder.channel)
if destination is None:
logger.info(f"Reminder couldn't get channel '{reminder.channel}'. Trying again in a minute.")
# In case we aren't actually ready yet
await self.crocbot.wait_for_ready()
try:
await asyncio.sleep(60)
except asyncio.CancelledError:
logger.info("Cancelling channel wait task for reminder.")
raise
destination = self.crocbot.get_channel(reminder.channel)
if destination is None:
# This means we haven't joined the channel
logger.warning(f"Reminder couldn't get channel '{reminder.channel}' for the second time. Cancelling.")
else:
logger.info(f"Channel '{reminder.channel}' found as {destination}. Continuing.")
if destination is not None:
# Send the reminder
msg = f"@{reminder.name}, you asked me to remind you: {reminder.content}"
await destination.send(msg)
# This should really be based on a reminderid but oh well
# It's theoretically possible for a reminder to be scheduled at the same time as it is run
# In which case the wrong reminder will be removed.
self.reminders[reminder.userid].remove(reminder)
self.save_reminders()
def get_reminders_for(self, userid: int):
return self.reminders.get(userid, [])
@commands.command(name='remindme', aliases=['reminders', 'reminder'])
async def remindme_cmd(self, ctx, *, args: str=''):
args = args.strip()
userid = int(ctx.author.id)
existing = self.get_reminders_for(userid)
existing.sort(key=lambda r: r.remind_at, reverse=False)
now = utc_now()
if not args or args.lower() in ('show', 'list'):
# Show user's current reminders or show usage
if not existing:
await ctx.reply(
"USAGE: !remindme <task> in <dur> EG: !remindme Coffee is ready in 10m | !remindme in 10m, Coffee is ready"
)
elif len(existing) == 1:
reminder = existing[0]
dur = reminder.remind_at - now
sec = (dur.total_seconds()) < 60
formatted_dur = strfdelta(dur, short=False, sec=sec)
await ctx.reply(
f"I will remind you about '{reminder.content}' in about {formatted_dur}. Use !remindme cancel to cancel!"
)
else:
parts = []
for i, reminder in enumerate(existing, start=1):
dur = reminder.remind_at - now
sec = (dur.total_seconds()) < 60
formatted_dur = strfdelta(dur, short=True, sec=sec)
parts.append(
f"{i}: '{reminder.content}' in {formatted_dur}"
)
remstr = '; '.join(parts)
if len(remstr) > 290:
remstr = remstr[:290] + '...'
await ctx.reply(
f"Active Reminders: {remstr}. Use '!remindme cancel n' or '!remindme clear' to remove!"
)
elif args.lower() in ('clear', 'clearall', 'remove all'):
# Remove all reminders
if existing:
self.reminders.pop(userid, None)
self.save_reminders()
self.schedule_next_reminder()
else:
await ctx.reply("You don't have any reminders set!")
elif args.lower().split(maxsplit=1)[0] in ('remove', 'cancel'):
splits = args.split(maxsplit=1)
remaining = splits[1].strip() if len(splits) > 1 else ''
# Remove a specified reminder
to_remove = None
if not existing:
await ctx.reply("You don't have any reminders set!")
elif len(existing) == 1:
to_remove = existing[0]
elif remaining.isdigit():
# Try to the remove the reminder with the give number
given = int(remaining)
if given > len(existing):
await ctx.reply(f"You only have {len(existing)} reminders!")
else:
to_remove = existing[given - 1]
else:
# Invalid arguments, show usage
await ctx.reply(
"USAGE: !remindme cancel <number>, e.g. !remindme cancel 1 to cancel your first reminder!"
)
if to_remove is not None:
self.reminders[userid].remove(to_remove)
await ctx.reply(
f"Cancelled your reminder '{to_remove.content}'"
)
self.save_reminders()
self.schedule_next_reminder()
else:
# Parse for reminder
content = None
duration = None
repeating = None
# First parse it
match = re.search(reminder_regex, args)
if match:
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:
# Legacy parsing, without requiring "in" at the front
splits = args.split(maxsplit=1)
if len(splits) == 2 and splits[0].isdigit():
repeating = False
duration = int(splits[0]) * 60
content = splits[1].strip()
# Sanity checking
if not duration or not content:
return await ctx.reply(
"Sorry, I didn't understand your reminder! Please use e.g. !remindme Coffee is ready in 10m"
)
if repeating:
return await ctx.reply(
"Sorry, we don't support repeating reminders right now!"
)
if len(existing) > 10:
return await ctx.reply(
"Sorry, you can only have 10 active reminders! Use !remindme cancel or !remindme clear to cancel some!"
)
reminder = Reminder(
userid=userid,
content=content,
name=ctx.author.name,
channel=ctx.channel.name,
remind_at=now + timedelta(seconds=duration)
)
self.reminders[userid].append(reminder)
dur = reminder.remind_at - now
sec = (dur.total_seconds()) < 60
formatted_dur = strfdelta(dur, short=False, sec=sec)
msg = f"Got it! I will remind you in {formatted_dur}!"
await ctx.reply(msg)
self.save_reminders()
self.schedule_next_reminder()

View File

@@ -342,9 +342,9 @@ def strfdelta(delta: datetime.timedelta, sec=False, minutes=True, short=False) -
return "".join(reply_msg)
def _parse_dur(time_str: str) -> int:
def parse_dur(time_str: str) -> int:
"""
Parses a user provided time duration string into a timedelta object.
Parses a user provided time duration string into an integer number of seconds.
Parameters
----------