588 lines
24 KiB
Python
588 lines
24 KiB
Python
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):
|
|
# 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 -----
|
|
@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
|
|
@group_subathon.command(name='setup', alias='start')
|
|
@cmds.is_broadcaster()
|
|
async def cmd_setup(self, ctx: cmds.Context, name: str, 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 <initial_hours> <sub1_points> <sub2_points> <sub3_points> <bit_points> <timepoints> [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,
|
|
name=name,
|
|
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 {name}! Use !subathon resume to get the timer running. Your timer link: {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 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 <amount> [@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
|
|
@group_subathon.command(name='config', aliases=('option',))
|
|
@cmds.is_moderator()
|
|
async def cmd_subathon_config(self, ctx: Context, option: str, *, value: Optional[str] = None):
|
|
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
|
cid = community.communityid
|
|
if (active := await self.get_active_subathon(cid)) is not None:
|
|
if option.lower() == 'cap':
|
|
if value:
|
|
# Set the timer cap
|
|
if not value.isdigit():
|
|
await ctx.reply("Timer cap must be an integer number of hours!")
|
|
else:
|
|
await active.subathondata.update(timecap=int(value * 60 * 60))
|
|
await self.dispatch_update(active)
|
|
await ctx.reply(
|
|
f"The timer cap has been set to {value} hours."
|
|
)
|
|
else:
|
|
# Display the timer cap
|
|
cap = active.subathondata.timecap
|
|
if cap:
|
|
hours = cap / 3600
|
|
await ctx.reply(f"The timer cap is currently {hours} hours")
|
|
elif option.lower() == 'name':
|
|
if value:
|
|
await active.subathondata.update(name=value)
|
|
await self.dispatch_update(active)
|
|
await ctx.reply(f"Updated the subathon name to \"{value}\"")
|
|
else:
|
|
name = active.subathondata.name
|
|
await ctx.reply(f"This subathon is called \"{name}\"")
|
|
else:
|
|
await ctx.reply(
|
|
f"Unknown option {option}! Configurable options: 'name', 'cap'"
|
|
)
|
|
|
|
@group_subathon.command(name='leaderboard', aliases=('top', 'lb',))
|
|
async def cmd_subathon_lb(self, ctx: Context):
|
|
"""
|
|
Display top contributors by points contributed, up to 10.
|
|
Anonymous contributions are gathered as one user.
|
|
"""
|
|
# TODO: Might want to extend stats to select/show last subathon as well
|
|
# IDEA: We could also offer full export as csv via web
|
|
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
|
cid = community.communityid
|
|
caller_profile = await self.bot.profiles.fetch_profile(ctx.chatter)
|
|
caller_pid = caller_profile.profileid
|
|
|
|
if (active := await self.get_active_subathon(cid)) is not None:
|
|
# Get totals for all contributors
|
|
query = self.data.subathon_contributions.select_where(subathon_id=active.subathondata.subathon_id)
|
|
query.join('user_profiles', using=('profileid',), join_type=JOINTYPE.LEFT)
|
|
query.select('subathon_id', 'profileid', total="SUM(score)")
|
|
query.order_by('total', direction=ORDER.DESC)
|
|
query.group_by('subathon_id', 'profileid')
|
|
query.with_no_adapter()
|
|
results = await query
|
|
|
|
parts = []
|
|
caller_idx = None
|
|
for i, row in enumerate(results):
|
|
if pid := row['profileid']:
|
|
profile = await self.bot.profiles.profiles.get_profile(pid)
|
|
name = (profile.nickname if profile else None) or 'Unknown'
|
|
if pid == caller_pid:
|
|
caller_idx = i
|
|
else:
|
|
name = 'Anonymous'
|
|
score = row['total']
|
|
part = f"{name}: {score} points"
|
|
parts.append(part)
|
|
|
|
header = ""
|
|
leaderboard = ', '.join(parts[:10])
|
|
footer = ""
|
|
if len(parts) > 10:
|
|
header = f"{active.name} top 10 leaderboard: "
|
|
leaderboard = ', '.join(parts[:10])
|
|
if caller_idx is not None and caller_idx >= 10:
|
|
caller_part = parts[caller_idx]
|
|
footer = f" ... {caller_part}"
|
|
elif parts:
|
|
header = f"{active.name} contribution leaderboard: "
|
|
else:
|
|
header = "No contributions to show yet!"
|
|
|
|
message = f"{header} {leaderboard} {footer}"
|
|
await ctx.reply(message)
|
|
else:
|
|
await ctx.reply("No active subathon to show leaderboard of!")
|
|
|
|
# Subathon goals
|
|
@cmds.group(name='goals', invoke_fallback=True)
|
|
async def group_goals(self, ctx: cmds.Context):
|
|
# List the goals
|
|
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
|
cid = community.communityid
|
|
if (active := await self.get_active_subathon(cid)) is not None:
|
|
goals = await active.get_goals()
|
|
goalstrs = []
|
|
for i, goal in enumerate(goals, start=1):
|
|
line = f"{goal.required_score} points: {goal.description}"
|
|
goalstrs.append(line)
|
|
|
|
if goals:
|
|
text = ', '.join(goalstrs)
|
|
await ctx.reply(f"{active.name} Goals! -- {text}")
|
|
else:
|
|
await ctx.reply("No goals have been configured!")
|
|
else:
|
|
await ctx.reply("No active subathon running!")
|
|
|
|
@group_goals.command(name='remaining', aliases=('left',))
|
|
async def cmd_goals_remaining(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()
|
|
goalstrs = []
|
|
for i, goal in enumerate(goals, start=1):
|
|
if goal.required_score > score:
|
|
line = f"{goal.required_score} points: {goal.description}"
|
|
goalstrs.append(line)
|
|
|
|
if goalstrs
|
|
text = ', '.join(goalstrs)
|
|
await ctx.reply(f"{active.name} Goals Remaining -- {text}")
|
|
elif goals:
|
|
await ctx.reply(f"All goals completed, congratulations!")
|
|
else:
|
|
await ctx.reply("No goals have been configured!")
|
|
else:
|
|
await ctx.reply("No active subathon running!")
|
|
|
|
@group_goals.command(name='add')
|
|
@cmds.is_moderator()
|
|
async def cmd_add(self, ctx: cmds.Context, required: int, *, description: str):
|
|
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
|
cid = community.communityid
|
|
if (active := await self.get_active_subathon(cid)) is not None:
|
|
await SubathonGoal.create(
|
|
subathon_id=active.subathondata.subathon_id,
|
|
required_score=required,
|
|
description=description,
|
|
)
|
|
await ctx.reply("Goal added!")
|
|
else:
|
|
await ctx.reply("No active subathon to add goal to!")
|
|
|
|
# remove
|
|
@group_goals.command(name='remove', aliases=['del', 'delete', 'rm'])
|
|
@cmds.is_moderator()
|
|
async def cmd_goals_remove(self, ctx: cmds.Context, required: int):
|
|
"""
|
|
Description:
|
|
Remove any goal(s) set at the given score.
|
|
"""
|
|
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
|
cid = community.communityid
|
|
if (active := await self.get_active_subathon(cid)) is not None:
|
|
results = await SubathonGoal.table.delete_where(
|
|
subathon_id=active.subathondata.subathon_id,
|
|
required_score=required,
|
|
)
|
|
if results:
|
|
await ctx.reply(f"Score {required} goal(s) removed!")
|
|
else:
|
|
await ctx.reply(f"No goal set at {required} score to remove.")
|
|
|