import re from collections import defaultdict from datetime import datetime, timedelta from typing import Optional import twitchio from twitchio import PartialUser, Scopes, eventsub from twitchio.ext import commands as cmds from data.queries import JOINTYPE, ORDER from meta import Bot, Context from meta.sockets import Channel, register_channel from utils.lib import utc_now, strfdelta from . import logger from .data import SubathonData, Subathon, RunningSubathon, SubathonContribution, SubathonGoal from .subathon import ActiveSubathon, SubathonRegistry from .channel import SubathonPayload, prepare_subathon, TimerChannel class SubathonComponent(cmds.Component): """ !subathon !subathon setup [cap] !subathon stop !subathon link !subathon pause !subathon resume !subathon adjust [@user] !subathon config !subathon config name [new name] !subathon config cap [new cap] !subathon config scores [ ] !subathon config timescore [new score] !subathon lb !goals !goals remaining !goals add !goals remove Command: Permissions: Description: Examples: """ # TODO: Add explicit dependencies and version checks # for profile and event tracker modules def __init__(self, bot: Bot): self.bot = bot self.data = bot.dbconn.load_registry(SubathonData()) self.subathons = SubathonRegistry(self.data, bot.profiles.profiles) self.channel = TimerChannel(self.bot.profiles.profiles, self.subathons) register_channel('SubTimer', self.channel) # ----- API ----- async def component_load(self): await self.data.init() await self.subathons.init() await self.bot.version_check(*self.data.VERSION) async def component_teardown(self): pass async def dispatch_update(self, subathon: ActiveSubathon): # TODO: Fix confusion of responsibility for preparation cid = subathon.subathondata.communityid payload = await prepare_subathon(self.bot.profiles.profiles, subathon) await self.channel.send_subathon_update(cid, payload) # ----- Methods ----- async def get_active_subathon(self, *args, **kwargs): return await self.subathons.get_active_subathon(*args, **kwargs) async def goalcheck(self, active: ActiveSubathon, channel: PartialUser): goals = await active.get_goals() score = await active.get_score() for i, goal in enumerate(goals): if not goal.notified and goal.required_score <= score: # Goal completed, notify channel await channel.send_message( f"We have reached Goal #{i+1}: {goal.description} !! Thank you everyone for your support <3", sender=self.bot.bot_id, ) await goal.update(notified=True) # ----- Event Handlers ----- # Blerp handler @cmds.Component.listener() async def event_message(self, message: twitchio.ChatMessage): if message.chatter.id not in ('253326823', '1361913054'): return if 'bits' not in message.text: return community = await self.bot.profiles.fetch_community(message.broadcaster) cid = community.communityid if (active := await self.get_active_subathon(cid)) is not None and not await active.check_finished(): # Extract count and user match = re.match(r"(?P\w+) used (?P\d+)", message.text) if not match: match = re.match(r"!For (?P\d+) bits, (?P\w+)", message.text) if match: amount = int(match['amount']) name = match['name'] # This is for giving the contribution to the calling user # user = await self.bot.fetch_user(login=name.lower()) user = await message.chatter.user() profile = await self.bot.profiles.fetch_profile(user) pid = profile.profileid score = amount * active.subathondata.bit_score contrib = await active.add_contribution(pid, score, None) await self.dispatch_update(active) logger.info(f"Blerp contribution: {contrib!r} from {message!r}") else: logger.warning(f"Unknown blerp bit message: {message!r}") @cmds.Component.listener() async def event_safe_bits_use(self, payload): event_row, detail_row, bits_payload = payload if (active := await self.get_active_subathon(event_row['communityid'])) is not None and not await active.check_finished(): # In an active subathon pid = event_row['profileid'] uid = event_row['user_id'] score = detail_row['bits'] * active.subathondata.bit_score await active.add_contribution(pid, score, event_row['event_id']) # Send message to channel sec = active.get_score_time(score) added_min = int(sec // 60) if added_min > 0: added = f"{added_min} minutes" else: added = f"{sec} seconds" name = bits_payload.user.name pl = 's' if bits_payload.bits != 1 else '' contrib_str = f"{name} contributed {score} point{pl}" if not await active.check_cap(): contrib_str += f" and added {added} to the timer! Thank you <3" else: contrib_str += " towards our subathon! Thank you <3" await bits_payload.broadcaster.send_message( contrib_str, sender=self.bot.bot_id ) await self.dispatch_update(active) await self.goalcheck(active, bits_payload.broadcaster) # Check goals @cmds.Component.listener() async def event_safe_subscription(self, payload): event_row, detail_row, sub_payload = payload if sub_payload.gift: # Ignore gifted here return if (active := await self.get_active_subathon(event_row['communityid'])) is not None and not await active.check_finished(): data = active.subathondata # In an active subathon pid = event_row['profileid'] tier = int(sub_payload.tier) if tier == 1000: mult = data.sub1_score elif tier == 2000: mult = data.sub2_score elif tier == 3000: mult = data.sub3_score else: raise ValueError(f"Unknown sub tier {sub_payload.tier}") score = mult * 1 await active.add_contribution(pid, score, event_row['event_id']) # Send message to channel added_min = int(active.get_score_time(score) // 60) added = f"{added_min} minutes" name = sub_payload.user.name pl = 's' if score > 1 else '' contrib_str = f"{name} contributed {score} point{pl}" if not await active.check_cap(): contrib_str += f" and added {added} to the timer! Thank you <3" else: contrib_str += " towards our subathon! Thank you <3" await sub_payload.broadcaster.send_message( contrib_str, sender=self.bot.bot_id ) await self.dispatch_update(active) # Check goals await self.goalcheck(active, sub_payload.broadcaster) @cmds.Component.listener() async def event_safe_subscription_gift(self, payload): event_row, detail_row, gift_payload = payload if (active := await self.get_active_subathon(event_row['communityid'])) is not None and not await active.check_finished(): data = active.subathondata # In an active subathon pid = event_row['profileid'] tier = int(gift_payload.tier) if tier == 1000: mult = data.sub1_score elif tier == 2000: mult = data.sub2_score elif tier == 3000: mult = data.sub3_score else: raise ValueError(f"Unknown sub tier {gift_payload.tier}") score = mult * gift_payload.total await active.add_contribution(pid, score, event_row['event_id']) # Send message to channel added_min = int(active.get_score_time(score) // 60) added = f"{added_min} minutes" name = gift_payload.user.name if gift_payload.user else 'Anonymous' contrib_str = f"{name} contributed {score} points" if not await active.check_cap(): contrib_str += f" and added {added} to the timer! Thank you <3" else: contrib_str += " towards our subathon! Thank you <3" await gift_payload.broadcaster.send_message( contrib_str, sender=self.bot.bot_id ) await self.dispatch_update(active) # Check goals await self.goalcheck(active, gift_payload.broadcaster) @cmds.Component.listener() async def event_safe_subscription_message(self, payload): event_row, detail_row, sub_payload = payload if (active := await self.get_active_subathon(event_row['communityid'])) is not None and not await active.check_finished(): data = active.subathondata # In an active subathon pid = event_row['profileid'] tier = int(sub_payload.tier) if tier == 1000: mult = data.sub1_score elif tier == 2000: mult = data.sub2_score elif tier == 3000: mult = data.sub3_score else: raise ValueError(f"Unknown sub tier {sub_payload.tier}") score = mult * 1 await active.add_contribution(pid, score, event_row['event_id']) # Send message to channel added_min = int(active.get_score_time(score) // 60) added = f"{added_min} minutes" name = sub_payload.user.name pl = 's' if score > 1 else '' contrib_str = f"{name} contributed {score} points{pl}" if not await active.check_cap(): contrib_str += f" and added {added} to the timer! Thank you <3" else: contrib_str += " towards our subathon! Thank you <3" await sub_payload.broadcaster.send_message( contrib_str, sender=self.bot.bot_id ) await self.dispatch_update(active) # Check goals await self.goalcheck(active, sub_payload.broadcaster) # end stream => Automatically pause the timer @cmds.Component.listener() async def event_stream_offline(self, payload: twitchio.StreamOffline): community = await self.bot.profiles.fetch_community(payload.broadcaster) cid = community.communityid if (active := await self.get_active_subathon(cid)) is not None: if active.running: await active.pause() if not await active.check_finished(): await payload.broadcaster.send_message( "Paused the subathon timer because the stream went offline!", sender=self.bot.bot_id ) await self.dispatch_update(active) # ----- Commands ----- @cmds.group(name='subathon', aliases=['studython'], invoke_fallback=True) async def group_subathon(self, ctx: cmds.Context): community = await self.bot.profiles.fetch_community(ctx.broadcaster) cid = community.communityid if (active := await self.get_active_subathon(cid)) is not None: score = await active.get_score() goals = await active.get_goals() total_goals = len(goals) donegoals = len([goal for goal in goals if score >= goal.required_score]) goalstr = f"{donegoals}/{total_goals} goals achieved" dursecs = await active.get_duration() duration = strfdelta(timedelta(seconds=dursecs)) secs = await active.get_remaining() if secs > 0: remaining = strfdelta(timedelta(seconds=secs)) text = ( f"{active.name} running for {duration}! {score} points recieved, {goalstr}, and {remaining} left on the timer" ) else: text = ( f"{active.name} completed after {duration} with a total of {score} points, and {goalstr}!" ) await ctx.reply(text) else: await ctx.reply("No active subathon running!") # subathon start # TODO: Usage line on command error @group_subathon.command(name='setup', alias='start') @cmds.is_broadcaster() async def cmd_setup(self, ctx: cmds.Context, initial_hours: float, sub1: float, sub2: float, sub3: float, bit: float, timescore: int, timecap: Optional[int]=None): """ Creates a new subathon. USAGE: {prefix}subathon setup [timecap] Arguments: initial_hours = number of hours to start the timer with sub1_points = points per T1 sub sub2_points = points per T2 sub sub3_points = points per T3 sub bit_points = points per bit timepoints = seconds to be added to the timer per point timecap (optional) = number of hours to cap the timer at """ community = await self.bot.profiles.fetch_community(ctx.broadcaster) cid = community.communityid if (active := await self.get_active_subathon(cid)) is not None: await ctx.reply("There is already an active subathon running! Use !subathon stop to stop it!") return initial_time = initial_hours * 60 * 60 timecap_seconds = timecap * 60 * 60 if timecap else None subdata = await Subathon.create( communityid=cid, initial_time=initial_time, sub1_score=sub1, sub2_score=sub2, sub3_score=sub3, bit_score=bit, score_time=timescore, timecap=timecap_seconds ) base_timer_url = self.bot.config.subathon['timer_url'] timer_link = f"{base_timer_url}?community={cid}" await ctx.reply( f"Setup your subathon! " f"Use {ctx.prefix}subathon resume to get the timer running. " f"Use {ctx.prefix}subathon config to see and set options, including the name. " f"Your timer link for OBS: {timer_link}" ) active = ActiveSubathon(subdata, None) await self.dispatch_update(active) # subathon stop @group_subathon.command(name='stop') @cmds.is_broadcaster() async def cmd_stop(self, ctx: cmds.Context): community = await self.bot.profiles.fetch_community(ctx.broadcaster) cid = community.communityid if (active := await self.get_active_subathon(cid)) is not None: if active.running: await active.pause() await self.dispatch_update(active) await active.subathondata.update(ended_at=utc_now()) total = await active.get_score() dursecs = await active.get_duration() dur = strfdelta(timedelta(seconds=dursecs)) await ctx.reply( f"{active.name} ended after {dur} with a total of {total} points, congratulations!" ) else: await ctx.reply("No active subathon to stop.") # subathon link @group_subathon.command(name='link') @cmds.is_moderator() async def cmd_link(self, ctx: cmds.Context): community = await self.bot.profiles.fetch_community(ctx.broadcaster) cid = community.communityid if (active := await self.get_active_subathon(cid)) is not None: base_timer_url = self.bot.config.subathon['timer_url'] timer_link = f"{base_timer_url}?community={cid}" await ctx.reply( timer_link ) else: await ctx.reply("No active subathon to stop.") # subathon pause @group_subathon.command(name='pause') @cmds.is_moderator() async def cmd_pause(self, ctx: cmds.Context): community = await self.bot.profiles.fetch_community(ctx.broadcaster) cid = community.communityid if (active := await self.get_active_subathon(cid)) is not None: if active.running: await active.pause() await ctx.reply(f"{active.name} timer paused!") await self.dispatch_update(active) else: await ctx.reply(f"{active.name} timer already paused!") else: await ctx.reply("No active subathon to pause") # subathon resume @group_subathon.command(name='resume') @cmds.is_moderator() async def cmd_resume(self, ctx: cmds.Context): community = await self.bot.profiles.fetch_community(ctx.broadcaster) cid = community.communityid if (active := await self.get_active_subathon(cid)) is not None: if active.running: await ctx.reply(f"{active.name} timer already running!") elif await active.check_finished(): await ctx.reply(f"{active.name} has already finished!") else: await active.resume() await ctx.reply(f"{active.name} timer resumed!") await self.dispatch_update(active) else: await ctx.reply("No active subathon to resume") # subathon adjust @group_subathon.command(name='adjust') @cmds.is_moderator() async def cmd_subathon_adjust(self, ctx: Context, amount: int, *, user: Optional[twitchio.User] = None): """ Directly add or remove points from the subathon. If a user is provided, will adjust their contributed amount. USAGE: {prefix}subathon adjust [@user] Arguments: 'amount' is an integer number of points to adjust by. It may be negative to remove points. '@user' is an optional user name or mention to adjust for. Examples: '{prefix}subathon adjust 10' '{prefix}subathon adjust -10 @machinestalkerwolfie' """ community = await self.bot.profiles.fetch_community(ctx.broadcaster) cid = community.communityid if (active := await self.get_active_subathon(cid)) is not None: if user is not None: profile = await self.bot.profiles.fetch_profile(user) name = user.display_name or profile.nickname or 'Unknown' pid = profile.profileid else: profile = None name = None pid = None await active.add_contribution(pid, amount, None) await self.dispatch_update(active) # Build message if amount > 0: amountstr = f"Added {amount} point(s) to the timer" elif amount < 0: amountstr = f"Removed {-amount} point(s) from the timer" else: amountstr = "Did nothing to the timer" namestr = f"on behalf of {name}" if profile else "" message = f"{amountstr} {namestr}" await ctx.reply(message) else: await ctx.reply("No active subathon to adjust") """ !subathon config name="Birthday Subathon" ; cap=10 (hours) ; scores=5 10 20 0.1 (t1, t2, t3, and bit points) ; timescore=10 (seconds per point); Use !subathon config