Compare commits

..

9 Commits

Author SHA1 Message Date
32b84625df Add delay to goal alert. 2025-09-24 23:12:24 +10:00
a19bb8a8cb fix: Some typos in config. 2025-09-24 21:14:58 +10:00
7853d0c115 Remove old config command. 2025-09-24 19:28:34 +10:00
c495e2387e Add configuration commands. 2025-09-24 19:27:17 +10:00
7a9045d3dc fix: Typo in check_finished 2025-09-24 09:54:56 +10:00
891e121e99 fix: Cast Decimal to float 2025-09-24 09:44:33 +10:00
0c4b2bfe32 Adjust for rounding errors 2025-09-24 09:40:56 +10:00
a82b58b2c4 Add link command 2025-09-24 09:40:42 +10:00
21b1f9c3ab Fix typo 2025-09-22 23:37:36 +10:00
3 changed files with 305 additions and 48 deletions

View File

@@ -87,32 +87,32 @@ async def prepare_subathon(profiler: ProfilesRegistry, subathon: ActiveSubathon)
last_contrib = ContributionPayload( last_contrib = ContributionPayload(
user_name=name, user_name=name,
user_id=user_id, user_id=user_id,
amount=contrib.score, amount=float(contrib.score),
seconds_added=subathon.get_score_time(contrib.score), seconds_added=subathon.get_score_time(contrib.score),
timestamp=contrib.created_at.isoformat() timestamp=contrib.created_at.isoformat()
) )
score_table = ScoreTablePayload( score_table = ScoreTablePayload(
bit_score=subathon.subathondata.bit_score, bit_score=float(subathon.subathondata.bit_score),
t1_score=subathon.subathondata.sub1_score, t1_score=float(subathon.subathondata.sub1_score),
t2_score=subathon.subathondata.sub2_score, t2_score=float(subathon.subathondata.sub2_score),
t3_score=subathon.subathondata.sub3_score, t3_score=float(subathon.subathondata.sub3_score),
score_time=subathon.subathondata.score_time, score_time=int(subathon.subathondata.score_time),
) )
payload = SubathonPayload( payload = SubathonPayload(
name=subathon.subathondata.name, name=subathon.subathondata.name,
end_at=(await subathon.get_ending()).isoformat(), end_at=(await subathon.get_ending()).isoformat(),
is_running=(subathon.running), is_running=(subathon.running),
score_table=score_table, score_table=score_table,
total_contribution=await subathon.get_score(), total_contribution=float(await subathon.get_score()),
goals_met=goals_met, goals_met=goals_met,
goals_total=total_goals, goals_total=total_goals,
last_goal=GoalPayload( last_goal=GoalPayload(
required=last_goal.required_score, required=float(last_goal.required_score),
name=last_goal.description, name=last_goal.description,
) if last_goal is not None else None, ) if last_goal is not None else None,
next_goal=GoalPayload( next_goal=GoalPayload(
required=next_goal.required_score, required=float(next_goal.required_score),
name=next_goal.description, name=next_goal.description,
) if next_goal is not None else None, ) if next_goal is not None else None,
last_contribution=last_contrib last_contribution=last_contrib

View File

@@ -1,3 +1,5 @@
import asyncio
import re
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
@@ -17,6 +19,36 @@ from .channel import SubathonPayload, prepare_subathon, TimerChannel
class SubathonComponent(cmds.Component): class SubathonComponent(cmds.Component):
"""
!subathon
!subathon setup <initial_hours> <t1score> <t2score> <t3score> <bitscore> <timescore> [cap]
!subathon stop
!subathon link
!subathon pause
!subathon resume
!subathon adjust <amount> [@user]
!subathon config
!subathon config name [new name]
!subathon config cap [new cap]
!subathon config scores [<t1score> <t2score> <t3score> <bitscore>]
!subathon config timescore [new score]
!subathon lb
!goals
!goals remaining
!goals add <points> <description>
!goals remove <points>
Command:
Permissions:
Description:
Examples:
"""
# TODO: Add explicit dependencies and version checks # TODO: Add explicit dependencies and version checks
# for profile and event tracker modules # for profile and event tracker modules
@@ -53,6 +85,9 @@ class SubathonComponent(cmds.Component):
for i, goal in enumerate(goals): for i, goal in enumerate(goals):
if not goal.notified and goal.required_score <= score: if not goal.notified and goal.required_score <= score:
# Goal completed, notify channel # Goal completed, notify channel
# TODO: Quick hack to avoid running into ratelimits
# Should probably wait on a central messaging slow-lock bucketed by channel instead
await asyncio.sleep(1)
await channel.send_message( await channel.send_message(
f"We have reached Goal #{i+1}: {goal.description} !! Thank you everyone for your support <3", f"We have reached Goal #{i+1}: {goal.description} !! Thank you everyone for your support <3",
sender=self.bot.bot_id, sender=self.bot.bot_id,
@@ -60,6 +95,36 @@ class SubathonComponent(cmds.Component):
await goal.update(notified=True) await goal.update(notified=True)
# ----- Event Handlers ----- # ----- 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<name>\w+) used (?P<amount>\d+)", message.text)
if not match:
match = re.match(r"!For (?P<amount>\d+) bits, (?P<name>\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() @cmds.Component.listener()
async def event_safe_bits_use(self, payload): async def event_safe_bits_use(self, payload):
event_row, detail_row, bits_payload = payload event_row, detail_row, bits_payload = payload
@@ -257,7 +322,7 @@ class SubathonComponent(cmds.Component):
duration = strfdelta(timedelta(seconds=dursecs)) duration = strfdelta(timedelta(seconds=dursecs))
secs = await active.get_remaining() secs = await active.get_remaining()
if secs >= 0: if secs > 0:
remaining = strfdelta(timedelta(seconds=secs)) remaining = strfdelta(timedelta(seconds=secs))
text = ( text = (
@@ -273,9 +338,10 @@ class SubathonComponent(cmds.Component):
await ctx.reply("No active subathon running!") await ctx.reply("No active subathon running!")
# subathon start # subathon start
# TODO: Usage line on command error
@group_subathon.command(name='setup', alias='start') @group_subathon.command(name='setup', alias='start')
@cmds.is_broadcaster() @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): 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. Creates a new subathon.
USAGE: {prefix}subathon setup <initial_hours> <sub1_points> <sub2_points> <sub3_points> <bit_points> <timepoints> [timecap] USAGE: {prefix}subathon setup <initial_hours> <sub1_points> <sub2_points> <sub3_points> <bit_points> <timepoints> [timecap]
@@ -297,8 +363,8 @@ class SubathonComponent(cmds.Component):
timecap_seconds = timecap * 60 * 60 if timecap else None timecap_seconds = timecap * 60 * 60 if timecap else None
subdata = await Subathon.create( subdata = await Subathon.create(
name="Subathon",
communityid=cid, communityid=cid,
name=name,
initial_time=initial_time, initial_time=initial_time,
sub1_score=sub1, sub1_score=sub1,
sub2_score=sub2, sub2_score=sub2,
@@ -309,7 +375,12 @@ class SubathonComponent(cmds.Component):
) )
base_timer_url = self.bot.config.subathon['timer_url'] base_timer_url = self.bot.config.subathon['timer_url']
timer_link = f"{base_timer_url}?community={cid}" 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 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) active = ActiveSubathon(subdata, None)
await self.dispatch_update(active) await self.dispatch_update(active)
@@ -334,6 +405,21 @@ class SubathonComponent(cmds.Component):
else: else:
await ctx.reply("No active subathon to stop.") 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 # subathon pause
@group_subathon.command(name='pause') @group_subathon.command(name='pause')
@cmds.is_moderator() @cmds.is_moderator()
@@ -413,41 +499,212 @@ class SubathonComponent(cmds.Component):
else: else:
await ctx.reply("No active subathon to adjust") await ctx.reply("No active subathon to adjust")
# subathon config """
@group_subathon.command(name='config', aliases=('option',)) !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 <option> [value] to see or set individual options
!subathon config name [value]
!subathon config cap [value]
!subathon config scores [value]
!subathon config timescore [value]
!subathon config name
Name of the subathon, used whenever the subathon is mentioned.
Accepts any string. Example: !subathon config name Birthday Subathon
!subathon config cap
Duration cap of the subathon, in hours, including the initial time.
The subathon may still be contributed to after this time, but contributions
will not raise the timer.
Accepts an integer, with 0 to unset. Example: !subathon config cap 10
!subathon config scores
The number of points each type of contribution (t1 sub, t2 sub, t3 sub, 1 bit)
will add to the subathon. Not retroactive. Accepts four floats.
Example: !subathon config scores 5 10 20 0.1
!subathon config timescore
The number of seconds each contributed point adds to the timer.
WARNING: This setting is retroactive and should generally not be changed.
Accepts an integer. Example: !subathon config timecap 10
"""
@group_subathon.group(name='config', aliases=('option',), invoke_fallback=True)
@cmds.is_moderator() @cmds.is_moderator()
async def cmd_subathon_config(self, ctx: Context, option: str, *, value: Optional[str] = None): async def subathon_config_grp(self, ctx: Context, *, args: Optional[str] = None):
community = await self.bot.profiles.fetch_community(ctx.broadcaster) community = await self.bot.profiles.fetch_community(ctx.broadcaster)
cid = community.communityid cid = community.communityid
if (active := await self.get_active_subathon(cid)) is not None: if (active := await self.get_active_subathon(cid)) is None:
if option.lower() == 'cap': await ctx.reply("No active subathon to configure!")
if value: return
# Set the timer cap if args:
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( await ctx.reply(
f"The timer cap has been set to {value} hours." f"USAGE: {ctx.prefix}subathon config [option [value]]\n"
f"Use '{ctx.prefix}subathon config' to see available options and current values."
) )
else: return
# Display the timer cap
cap = active.subathondata.timecap parts = []
if cap:
hours = cap / 3600 sdata = active.subathondata
await ctx.reply(f"The timer cap is currently {hours} hours")
elif option.lower() == 'name': # name
if value: parts.append(
await active.subathondata.update(name=value) f"name=\"{sdata.name}\""
await self.dispatch_update(active) )
await ctx.reply(f"Updated the subathon name to \"{value}\"")
else: # timecamp
name = active.subathondata.name cap = sdata.timecap or 0
await ctx.reply(f"This subathon is called \"{name}\"") caph = int(cap / 3600)
else: parts.append(
f"cap={caph} (hours, 0 means no cap)"
)
# scores
scores = map(float, (sdata.sub1_score, sdata.sub2_score, sdata.sub3_score, sdata.bit_score))
scorestr = ' '.join(map(str, scores))
parts.append(
f"scores={scorestr} (t1, t2, t3, and 1 bit point scores)"
)
# timescore
parts.append(
f"timescore={sdata.score_time} (seconds per point)"
)
# Combine
partstr = ' ; '.join(parts)
await ctx.reply( await ctx.reply(
f"Unknown option {option}! Configurable options: 'name', 'cap'" f"{partstr} ; Use {ctx.prefix}subathon config <option> [value] to see or set each option!"
)
@subathon_config_grp.command(name='name')
@cmds.is_moderator()
async def subathon_config_name_cmd(self, ctx: Context, *, args: Optional[str] = None):
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
cid = community.communityid
if (active := await self.get_active_subathon(cid)) is None:
await ctx.reply("No active subathon to configure!")
return
if args:
# Setting path
await active.subathondata.update(name=args)
await self.dispatch_update(active)
await ctx.reply(f"Updated the subathon name to \"{args}\"")
else:
# Display path
name = active.subathondata.name
await ctx.reply(
"Name of the subathon, used whenever the subathon is mentioned. "
"Accepts any string. "
f"Currently: \"{name}\" "
f"Example: {ctx.prefix}subathon config name Birthday Subathon"
)
@subathon_config_grp.command(name='cap')
@cmds.is_moderator()
async def subathon_config_cap_cmd(self, ctx: Context, *, args: Optional[str] = None):
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
cid = community.communityid
if (active := await self.get_active_subathon(cid)) is None:
await ctx.reply("No active subathon to configure!")
return
if args:
# Setting path
if args.lower() in ('none',):
args = '0'
if not args.isdigit():
await ctx.reply("Provided timer cap must be an integer number of hours!")
return
new_cap = int(args)
if new_cap <= 0:
# Unset the cap
await active.subathondata.update(timecap=None)
await self.dispatch_update(active)
await ctx.reply("The timer cap has been removed! To infinity and beyond!")
else:
# Set the cap
await active.subathondata.update(timecap=int(new_cap * 3600))
await self.dispatch_update(active)
await ctx.reply(f"The subathon timer has been capped to {new_cap} hours.")
else:
# Display path
current_cap = active.subathondata.timecap or 0
caph = int(current_cap / 3600)
await ctx.reply(
"Duration cap for this subathon, in hours, including the initial time. "
"Contributions given after the cap has been reached will be accepted, "
"but will not raise the timer. "
"Accepts an integer, with 0 meaning no cap. "
f"Currently: {caph} hours. "
f"Example: {ctx.prefix}subathon config cap 24"
)
@subathon_config_grp.command(name='scores')
@cmds.is_moderator()
async def subathon_config_scores_cmd(self, ctx: Context, *, args: Optional[str] = None):
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
cid = community.communityid
if (active := await self.get_active_subathon(cid)) is None:
await ctx.reply("No active subathon to configure!")
return
if args:
# Setting path
# Validate
splits = args.split()
if not len(splits) == 4 and all(split.isdecimal() for split in splits):
await ctx.reply(
f"USAGE: {ctx.prefix}subathon config socres [<t1score> <t2score> <t3score> <bitscore>]"
)
return
t1score, t2score, t3score, bitscore = map(float, splits)
await active.subathondata.update(
sub1_score=t1score,
sub2_score=t2score,
sub3_score=t3score,
bit_score=bitscore,
)
await self.dispatch_update(active)
await ctx.reply("Successfully updated subathon score table.")
else:
# Display path
sdata = active.subathondata
scores = map(float, (sdata.sub1_score, sdata.sub2_score, sdata.sub3_score, sdata.bit_score))
scorestr = ' '.join(map(str, scores))
await ctx.reply(
"The number of points each type of contribution (t1 sub, t2 sub, t3 sub, and 1 bit) "
"will add to the subathon. Not retroactive. "
"Accepts four floats. "
f"Currently: {scorestr} "
f"Example: {ctx.prefix}subathon config scores 5 10 20 0.1"
)
@subathon_config_grp.command(name='timescore')
@cmds.is_moderator()
async def subathon_config_timescore_cmd(self, ctx: Context, *, args: Optional[str] = None):
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
cid = community.communityid
if (active := await self.get_active_subathon(cid)) is None:
await ctx.reply("No active subathon to configure!")
return
if args:
# Setting path
if not args.isdigit():
await ctx.reply("Time score (seconds per point) must be an integer.")
return
await active.subathondata.update(score_time=int(args))
await self.dispatch_update(active)
await ctx.reply("Subathon time score updated (NOTE: This is retroactive).")
else:
# Display path
await ctx.reply(
"The number of seconds each contributed point adds to the timer. "
"WARNING: This setting is retroactive and should generally not be used. "
"Accepts an integer. "
f"Currently: {active.subathondata.score_time} (seconds per point) "
f"Example: {ctx.prefix}subathon config timescore 10"
) )
@group_subathon.command(name='leaderboard', aliases=('top', 'lb',)) @group_subathon.command(name='leaderboard', aliases=('top', 'lb',))
@@ -540,11 +797,11 @@ class SubathonComponent(cmds.Component):
line = f"{goal.required_score} points: {goal.description}" line = f"{goal.required_score} points: {goal.description}"
goalstrs.append(line) goalstrs.append(line)
if goalstrs if goalstrs:
text = ', '.join(goalstrs) text = ', '.join(goalstrs)
await ctx.reply(f"{active.name} Goals Remaining -- {text}") await ctx.reply(f"{active.name} Goals Remaining -- {text}")
elif goals: elif goals:
await ctx.reply(f"All goals completed, congratulations!") await ctx.reply("All goals completed, congratulations!")
else: else:
await ctx.reply("No goals have been configured!") await ctx.reply("No goals have been configured!")
else: else:

View File

@@ -32,7 +32,7 @@ class ActiveSubathon:
""" """
Return True if the subathon duration has exceeded its earned time. Return True if the subathon duration has exceeded its earned time.
""" """
return (await self.get_duration() <= 0) return (await self.get_remaining() <= 0)
async def pause(self): async def pause(self):
""" """
@@ -97,10 +97,10 @@ class ActiveSubathon:
async def get_remaining(self) -> int: async def get_remaining(self) -> int:
""" """
Number of seconds remaining on the subathon timer. Number of seconds remaining on the subathon timer.
Will be 0 if finished. Will be 0 or slightly negative if finished.
""" """
total_time = await self.get_earned() total_time = await self.get_earned()
return total_time - await self.get_duration() return total_time - await self.get_duration() - 1
async def get_ending(self): async def get_ending(self):
""" """