Improve subathon modularity.

This commit is contained in:
2025-09-22 23:30:45 +10:00
parent 899f5e7292
commit 033fc5c1ae
3 changed files with 477 additions and 183 deletions

View File

@@ -1,7 +1,6 @@
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Optional
import random
import twitchio
from twitchio import PartialUser, Scopes, eventsub
from twitchio.ext import commands as cmds
@@ -13,147 +12,8 @@ from utils.lib import utc_now, strfdelta
from . import logger
from .data import SubathonData, Subathon, RunningSubathon, SubathonContribution, SubathonGoal
class TimerChannel(Channel):
name = 'SubTimer'
def __init__(self, cog: 'SubathonComponent', **kwargs):
super().__init__(**kwargs)
self.cog = cog
# This is horribly inefficient but will be deprecated by the web update
self.communities = defaultdict(set) # Map of communityid -> listening websockets
async def on_connection(self, websocket, event):
# TODO: Properly this should be communityid
# Which is retrieved via API call to the profiles module for the channel
# This should also be a different error so we can pass it back to the client.
if not event.get('channelid', None):
raise ValueError("Subtimer connection missing channel!")
community = await self.cog.bot.profiles.profiles.get_community_twitch(event['channelid'])
if community is None:
raise ValueError('Requested channel is not registered. Add the bot first.')
await super().on_connection(websocket, event)
self.communities[community.communityid].add(websocket)
await self.send_set(
**await self.get_args_for(community.communityid),
websocket=websocket,
)
async def del_connection(self, websocket):
for wss in self.communities.values():
wss.discard(websocket)
await super().del_connection(websocket)
async def send_updates(self, communityid: int):
args = await self.get_args_for(communityid)
for ws in self.communities[communityid]:
await self.send_set(
**args,
websocket=ws
)
async def get_args_for(self, communityid: int):
active = await self.cog.get_active_subathon(communityid)
if active is not None:
ending = utc_now() + timedelta(seconds=await active.get_remaining())
# TODO: Next goal info and overall goal progress, maybe last contrib
return {
'end_at': ending,
'running': active.running
}
else:
return {
'end_at': utc_now,
'running': False,
}
async def send_set(self, end_at, running, websocket=None):
await self.send_event({
'type': "DO",
'method': 'setTimer',
'args': {
'end_at': end_at.isoformat(),
'running': running,
}
}, websocket=websocket)
class ActiveSubathon:
def __init__(self, subathondata: Subathon, runningdata: RunningSubathon | None):
self.subathondata = subathondata
self.runningdata = runningdata
@property
def running(self):
return self.runningdata is not None
@property
def name(self):
return self.subathondata.name or 'Subathon'
async def check_cap(self):
if not (cap := self.subathondata.timecap):
return False
else:
score = await self.get_score()
time_earned = self.get_score_time(score)
total_time = self.subathondata.initial_time + time_earned
return total_time >= cap
async def pause(self):
if not self.running:
raise ValueError("This subathon is not running!")
assert self.runningdata is not None
new_duration = self.get_duration()
await self.subathondata.update(duration=new_duration)
await self.runningdata.delete()
self.runningdata = None
async def resume(self):
if self.running:
raise ValueError("This subathon is already running!")
self.runningdata = await RunningSubathon.create(subathon_id=self.subathondata.subathon_id)
async def get_score(self) -> float:
rows = await SubathonContribution.fetch_where(subathon_id=self.subathondata.subathon_id)
return sum(row.score for row in rows)
def get_score_time(self, score: float) -> int:
# Get time contributed by this score
return int(score * self.subathondata.score_time)
def get_duration(self) -> int:
# Get the duration of this subathon so far
duration = self.subathondata.duration
if self.runningdata:
now = utc_now()
added = int( (now - self.runningdata.last_started).total_seconds() )
duration += added
return duration
async def get_remaining(self) -> int:
# Get the remaining time
score = await self.get_score()
time_earned = self.get_score_time(score)
total_time = self.subathondata.initial_time + time_earned
if cap := self.subathondata.timecap:
total_time = min(total_time, cap)
return total_time - self.get_duration()
async def add_contribution(self, profileid: int | None, score: float, event_id: int | None) -> SubathonContribution:
return await SubathonContribution.create(
subathon_id=self.subathondata.subathon_id,
profileid=profileid, score=score, event_id=event_id
)
async def get_goals(self) -> list[SubathonGoal]:
goals = await SubathonGoal.fetch_where(subathon_id=self.subathondata.subathon_id).order_by('required_score')
return goals
from .subathon import ActiveSubathon, SubathonRegistry
from .channel import SubathonPayload, prepare_subathon, TimerChannel
class SubathonComponent(cmds.Component):
@@ -163,25 +23,29 @@ class SubathonComponent(cmds.Component):
def __init__(self, bot: Bot):
self.bot = bot
self.data = bot.dbconn.load_registry(SubathonData())
self.channel = TimerChannel(self)
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, communityid: int) -> ActiveSubathon | None:
rows = await Subathon.fetch_where(communityid=communityid, ended_at=None)
if rows:
subathondata = rows[0]
running = await RunningSubathon.fetch(subathondata.subathon_id)
subba = ActiveSubathon(subathondata, running)
return subba
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()
@@ -199,7 +63,7 @@ class SubathonComponent(cmds.Component):
@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:
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']
@@ -226,7 +90,7 @@ class SubathonComponent(cmds.Component):
contrib_str,
sender=self.bot.bot_id
)
await self.channel.send_updates(event_row['communityid'])
await self.dispatch_update(active)
await self.goalcheck(active, bits_payload.broadcaster)
# Check goals
@@ -237,7 +101,7 @@ class SubathonComponent(cmds.Component):
# Ignore gifted here
return
if (active := await self.get_active_subathon(event_row['communityid'])) is not None:
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']
@@ -272,7 +136,7 @@ class SubathonComponent(cmds.Component):
contrib_str,
sender=self.bot.bot_id
)
await self.channel.send_updates(event_row['communityid'])
await self.dispatch_update(active)
# Check goals
await self.goalcheck(active, sub_payload.broadcaster)
@@ -280,7 +144,7 @@ class SubathonComponent(cmds.Component):
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:
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']
@@ -314,7 +178,7 @@ class SubathonComponent(cmds.Component):
contrib_str,
sender=self.bot.bot_id
)
await self.channel.send_updates(event_row['communityid'])
await self.dispatch_update(active)
# Check goals
await self.goalcheck(active, gift_payload.broadcaster)
@@ -322,7 +186,7 @@ class SubathonComponent(cmds.Component):
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:
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']
@@ -357,7 +221,7 @@ class SubathonComponent(cmds.Component):
contrib_str,
sender=self.bot.bot_id
)
await self.channel.send_updates(event_row['communityid'])
await self.dispatch_update(active)
# Check goals
await self.goalcheck(active, sub_payload.broadcaster)
@@ -369,11 +233,12 @@ class SubathonComponent(cmds.Component):
if (active := await self.get_active_subathon(cid)) is not None:
if active.running:
await active.pause()
await payload.broadcaster.send_message(
"Paused the subathon timer because the stream went offline!",
sender=self.bot.bot_id
)
await self.channel.send_updates(cid)
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 -----
@@ -388,15 +253,21 @@ class SubathonComponent(cmds.Component):
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()
remaining = strfdelta(timedelta(seconds=secs))
if secs >= 0:
remaining = strfdelta(timedelta(seconds=secs))
secs = active.get_duration()
duration = 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}!"
)
text = (
f"{active.name} running for {duration}! {score} points recieved, {goalstr}, and {remaining} left on the timer"
)
await ctx.reply(text)
else:
await ctx.reply("No active subathon running!")
@@ -415,7 +286,7 @@ class SubathonComponent(cmds.Component):
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 seconds to cap the timer at.
timecap (optional) = number of hours to cap the timer at
"""
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
cid = community.communityid
@@ -423,8 +294,9 @@ class SubathonComponent(cmds.Component):
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
active = await Subathon.create(
subdata = await Subathon.create(
communityid=cid,
name=name,
initial_time=initial_time,
@@ -433,12 +305,13 @@ class SubathonComponent(cmds.Component):
sub3_score=sub3,
bit_score=bit,
score_time=timescore,
timecap=timecap
timecap=timecap_seconds
)
base_timer_url = self.bot.config.subathon['timer_url']
timer_link = f"{base_timer_url}?channelid={ctx.channel.id}"
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}")
await self.channel.send_updates(cid)
active = ActiveSubathon(subdata, None)
await self.dispatch_update(active)
# subathon stop
@group_subathon.command(name='stop')
@@ -449,14 +322,14 @@ class SubathonComponent(cmds.Component):
if (active := await self.get_active_subathon(cid)) is not None:
if active.running:
await active.pause()
await self.channel.send_updates(cid)
await self.dispatch_update(active)
await active.subathondata.update(ended_at=utc_now())
total = await active.get_score()
dursecs = active.get_duration()
dursecs = await active.get_duration()
dur = strfdelta(timedelta(seconds=dursecs))
await ctx.reply(
f"{active.name} complete after {dur} with a total of {total} points, congratulations!"
f"{active.name} ended after {dur} with a total of {total} points, congratulations!"
)
else:
await ctx.reply("No active subathon to stop.")
@@ -471,7 +344,7 @@ class SubathonComponent(cmds.Component):
if active.running:
await active.pause()
await ctx.reply(f"{active.name} timer paused!")
await self.channel.send_updates(cid)
await self.dispatch_update(active)
else:
await ctx.reply(f"{active.name} timer already paused!")
else:
@@ -484,12 +357,14 @@ class SubathonComponent(cmds.Component):
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
cid = community.communityid
if (active := await self.get_active_subathon(cid)) is not None:
if not active.running:
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.channel.send_updates(cid)
else:
await ctx.reply(f"{active.name} timer already running!")
await self.dispatch_update(active)
else:
await ctx.reply("No active subathon to resume")
@@ -522,7 +397,7 @@ class SubathonComponent(cmds.Component):
name = None
pid = None
await active.add_contribution(pid, amount, None)
await self.channel.send_updates(cid)
await self.dispatch_update(active)
# Build message
if amount > 0:
@@ -538,6 +413,43 @@ class SubathonComponent(cmds.Component):
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):
"""
@@ -615,6 +527,29 @@ class SubathonComponent(cmds.Component):
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):
@@ -630,3 +565,23 @@ class SubathonComponent(cmds.Component):
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.")