Compare commits
11 Commits
feat-simpl
...
feat-check
| Author | SHA1 | Date | |
|---|---|---|---|
| 096831ff38 | |||
| 6f6f1c8711 | |||
| f7156a42ce | |||
| 0eaa73b32a | |||
| d4fa04e696 | |||
| 6988daccf2 | |||
| 75565d2b3c | |||
| e4f8a043a8 | |||
| 7cbb6adcb8 | |||
| 010d52e72e | |||
| c5e9cb1488 |
@@ -33,6 +33,9 @@ active_discord = [
|
||||
'.tagstrings',
|
||||
'.voiceroles',
|
||||
'.hyperfocus',
|
||||
'.twreminders',
|
||||
'.time',
|
||||
'.checkin',
|
||||
]
|
||||
|
||||
async def setup(bot):
|
||||
|
||||
8
src/modules/checkin/__init__.py
Normal file
8
src/modules/checkin/__init__.py
Normal 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
154
src/modules/checkin/cog.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
8
src/modules/time/__init__.py
Normal file
8
src/modules/time/__init__.py
Normal 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
110
src/modules/time/cog.py
Normal 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)
|
||||
8
src/modules/twreminders/__init__.py
Normal file
8
src/modules/twreminders/__init__.py
Normal 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))
|
||||
369
src/modules/twreminders/cog.py
Normal file
369
src/modules/twreminders/cog.py
Normal 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()
|
||||
@@ -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
|
||||
----------
|
||||
|
||||
Reference in New Issue
Block a user