Compare commits

..

7 Commits

Author SHA1 Message Date
df94877068 fix: Handle no timecap. 2025-07-31 06:59:39 +10:00
a52dcade56 Add timecap. 2025-07-31 06:55:21 +10:00
2c57d135c9 Add subscription messages to sub tracker. 2025-07-28 22:16:44 +10:00
295ab69fa3 Add fallback for load_tokens. 2025-07-28 14:53:11 +10:00
bf835e2529 Add subathon timer websocket. 2025-07-28 14:49:17 +10:00
03d50fbe92 Fix subathon string. 2025-07-28 14:11:02 +10:00
3cd243f3eb Fix goalcheck. 2025-07-28 14:10:14 +10:00
4 changed files with 153 additions and 21 deletions

View File

@@ -175,4 +175,7 @@ class CrocBot(commands.Bot):
async def load_tokens(self, path: str | None = None): async def load_tokens(self, path: str | None = None):
for row in await UserAuth.fetch_where(): for row in await UserAuth.fetch_where():
await self.add_token(row.token, row.refresh_token) try:
await self.add_token(row.token, row.refresh_token)
except Exception:
logger.exception(f"Failed to add token for {row}")

View File

@@ -8,10 +8,58 @@ from twitchio.ext import commands as cmds
from datamodels import BotChannel, Communities, UserProfile from datamodels import BotChannel, Communities, UserProfile
from meta import CrocBot from meta import CrocBot
from utils.lib import utc_now, strfdelta from utils.lib import utc_now, strfdelta
from sockets import Channel, register_channel
from . import logger from . import logger
from .data import SubathonData, Subathon, RunningSubathon, SubathonContribution, SubathonGoal from .data import SubathonData, Subathon, RunningSubathon, SubathonContribution, SubathonGoal
class TimerChannel(Channel):
name = 'Timer'
def __init__(self, cog: 'SubathonComponent', **kwargs):
super().__init__(**kwargs)
self.cog = cog
self.communityid = 1
async def on_connection(self, websocket, event):
await super().on_connection(websocket, event)
await self.send_set(
**await self.get_args_for(self.communityid),
websocket=websocket,
)
async def send_updates(self):
await self.send_set(
**await self.get_args_for(self.communityid),
)
async def get_args_for(self, channelid):
active = await self.cog.get_active_subathon(channelid)
if active is not None:
ending = utc_now() + timedelta(seconds=await active.get_remaining())
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: class ActiveSubathon:
def __init__(self, subathondata: Subathon, runningdata: RunningSubathon | None): def __init__(self, subathondata: Subathon, runningdata: RunningSubathon | None):
self.subathondata = subathondata self.subathondata = subathondata
@@ -21,6 +69,15 @@ class ActiveSubathon:
def running(self): def running(self):
return self.runningdata is not None return self.runningdata is not None
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): async def pause(self):
if not self.running: if not self.running:
raise ValueError("This subathon is not running!") raise ValueError("This subathon is not running!")
@@ -56,6 +113,8 @@ class ActiveSubathon:
score = await self.get_score() score = await self.get_score()
time_earned = self.get_score_time(score) time_earned = self.get_score_time(score)
total_time = self.subathondata.initial_time + time_earned 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() return total_time - self.get_duration()
@@ -74,6 +133,8 @@ class SubathonComponent(cmds.Component):
def __init__(self, bot: CrocBot): def __init__(self, bot: CrocBot):
self.bot = bot self.bot = bot
self.data = bot.dbconn.load_registry(SubathonData()) self.data = bot.dbconn.load_registry(SubathonData())
self.channel = TimerChannel(self)
register_channel('SubTimer', self.channel)
# ----- API ----- # ----- API -----
@@ -131,12 +192,19 @@ class SubathonComponent(cmds.Component):
added = f"{sec} seconds" added = f"{sec} seconds"
name = bits_payload.user.name name = bits_payload.user.name
pl = 's' if bits_payload.bits != 1 else '' pl = 's' if bits_payload.bits != 1 else ''
contrib_str = f"{name} contributed {score} bit{pl}"
if not await active.check_cap():
contrib_str += f" and added {added} to the timer! Thank you holono1Heart"
else:
contrib_str += " towards our studython! Thank you holono1Heart"
await bits_payload.broadcaster.send_message( await bits_payload.broadcaster.send_message(
f"{name} contributed {bits_payload.bits} bit{pl} and added {added} to the timer! Thank you <3", contrib_str,
sender=self.bot.bot_id sender=self.bot.bot_id
) )
# TODO: Websocket update await self.channel.send_updates()
await self.goalcheck() await self.goalcheck(active, bits_payload.broadcaster)
# Check goals # Check goals
@cmds.Component.listener() @cmds.Component.listener()
@@ -170,13 +238,20 @@ class SubathonComponent(cmds.Component):
added = f"{added_min} minutes" added = f"{added_min} minutes"
name = sub_payload.user.name name = sub_payload.user.name
pl = 's' if score > 1 else '' pl = 's' if score > 1 else ''
contrib_str = f"{name} contributed {score} sub{pl}"
if not await active.check_cap():
contrib_str += f" and added {added} to the timer! Thank you holono1Heart"
else:
contrib_str += " towards our studython! Thank you holono1Heart"
await sub_payload.broadcaster.send_message( await sub_payload.broadcaster.send_message(
f"{name} contributed {score} sub{pl} and added {added} to the timer! Thank you <3", contrib_str,
sender=self.bot.bot_id sender=self.bot.bot_id
) )
# TODO: Websocket update await self.channel.send_updates()
# Check goals # Check goals
await self.goalcheck() await self.goalcheck(active, sub_payload.broadcaster)
@cmds.Component.listener() @cmds.Component.listener()
async def event_safe_subscription_gift(self, payload): async def event_safe_subscription_gift(self, payload):
@@ -205,13 +280,63 @@ class SubathonComponent(cmds.Component):
added_min = int(active.get_score_time(score) // 60) added_min = int(active.get_score_time(score) // 60)
added = f"{added_min} minutes" added = f"{added_min} minutes"
name = gift_payload.user.name if gift_payload.user else 'Anonymous' name = gift_payload.user.name if gift_payload.user else 'Anonymous'
contrib_str = f"{name} contributed {score} subs"
if not await active.check_cap():
contrib_str += f" and added {added} to the timer! Thank you holono1Heart"
else:
contrib_str += " towards our studython! Thank you holono1Heart"
await gift_payload.broadcaster.send_message( await gift_payload.broadcaster.send_message(
f"{name} contributed {score} subs and added {added} to the timer! Thank you <3", contrib_str,
sender=self.bot.bot_id sender=self.bot.bot_id
) )
# TODO: Websocket update await self.channel.send_updates()
# Check goals # Check goals
await self.goalcheck() 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:
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} sub{pl}"
if not await active.check_cap():
contrib_str += f" and added {added} to the timer! Thank you holono1Heart"
else:
contrib_str += " towards our studython! Thank you holono1Heart"
await sub_payload.broadcaster.send_message(
contrib_str,
sender=self.bot.bot_id
)
await self.channel.send_updates()
# Check goals
await self.goalcheck(active, sub_payload.broadcaster)
# end stream => Automatically pause the timer # end stream => Automatically pause the timer
@cmds.Component.listener() @cmds.Component.listener()
@@ -225,11 +350,11 @@ class SubathonComponent(cmds.Component):
"Paused the subathon timer because the stream went offline!", "Paused the subathon timer because the stream went offline!",
sender=self.bot.bot_id sender=self.bot.bot_id
) )
# TODO: Websocket update await self.channel.send_updates()
# ----- Commands ----- # ----- Commands -----
@cmds.group(name='subathon', invoke_fallback=True) @cmds.group(name='subathon', aliases=['studython'], invoke_fallback=True)
async def group_subathon(self, ctx: cmds.Context): async def group_subathon(self, ctx: cmds.Context):
# TODO: Status # TODO: Status
community = await self.bot.community_fetch(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name) community = await self.bot.community_fetch(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name)
@@ -239,7 +364,7 @@ class SubathonComponent(cmds.Component):
goals = await active.get_goals() goals = await active.get_goals()
total_goals = len(goals) total_goals = len(goals)
donegoals = len([goal for goal in goals if score >= goal.required_score]) donegoals = len([goal for goal in goals if score >= goal.required_score])
goalstr = f"{donegoals}/{total_goals} achieved" goalstr = f"{donegoals}/{total_goals} goals achieved"
secs = await active.get_remaining() secs = await active.get_remaining()
remaining = strfdelta(timedelta(seconds=secs)) remaining = strfdelta(timedelta(seconds=secs))
@@ -248,7 +373,7 @@ class SubathonComponent(cmds.Component):
duration = strfdelta(timedelta(seconds=secs)) duration = strfdelta(timedelta(seconds=secs))
text = ( text = (
f"Subathon running for {duration}! {score} (equivalent) subscriptions recieved, {goals}, and {remaining} left on the timer" f"Subathon running for {duration}! {score} (equivalent) subscriptions recieved, {goalstr}, and {remaining} left on the timer"
) )
await ctx.reply(text) await ctx.reply(text)
else: else:
@@ -256,7 +381,7 @@ class SubathonComponent(cmds.Component):
# subathon start # subathon start
@group_subathon.command(name='setup') @group_subathon.command(name='setup')
async def cmd_setup(self, ctx: cmds.Context, initial_hours: float, sub1: float, sub2: float, sub3: float, bit: float, timescore: int): 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):
if ctx.broadcaster: if ctx.broadcaster:
community = await self.bot.community_fetch(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name) community = await self.bot.community_fetch(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name)
cid = community.communityid cid = community.communityid
@@ -272,10 +397,11 @@ class SubathonComponent(cmds.Component):
sub2_score=sub2, sub2_score=sub2,
sub3_score=sub3, sub3_score=sub3,
bit_score=bit, bit_score=bit,
score_time=timescore score_time=timescore,
timecap=timecap
) )
await ctx.reply("Setup a new subathon! Use !subathon resume to get the timer running.") await ctx.reply("Setup a new subathon! Use !subathon resume to get the timer running.")
# TODO: Websocket broadcast await self.channel.send_updates()
# subathon stop # subathon stop
@group_subathon.command(name='stop') @group_subathon.command(name='stop')
@@ -286,7 +412,7 @@ class SubathonComponent(cmds.Component):
if (active := await self.get_active_subathon(cid)) is not None: if (active := await self.get_active_subathon(cid)) is not None:
if active.running: if active.running:
await active.pause() await active.pause()
# TODO: Websocket update await self.channel.send_updates()
await active.subathondata.update(ended_at=utc_now()) await active.subathondata.update(ended_at=utc_now())
total = await active.get_score() total = await active.get_score()
dursecs = active.get_duration() dursecs = active.get_duration()
@@ -308,7 +434,7 @@ class SubathonComponent(cmds.Component):
if active.running: if active.running:
await active.pause() await active.pause()
await ctx.reply("Subathon timer paused!") await ctx.reply("Subathon timer paused!")
# TODO: Websocket update await self.channel.send_updates()
else: else:
await ctx.reply("Subathon timer already paused!") await ctx.reply("Subathon timer already paused!")
else: else:
@@ -324,7 +450,7 @@ class SubathonComponent(cmds.Component):
if not active.running: if not active.running:
await active.resume() await active.resume()
await ctx.reply("Subathon timer resumed!") await ctx.reply("Subathon timer resumed!")
# TODO: Websocket update await self.channel.send_updates()
else: else:
await ctx.reply("Subathon timer already running!") await ctx.reply("Subathon timer already running!")
else: else:
@@ -351,7 +477,7 @@ class SubathonComponent(cmds.Component):
await ctx.reply("No active subathon running!") await ctx.reply("No active subathon running!")
@group_goals.command(name='add') @group_goals.command(name='add')
async def cmd_add(self, ctx: cmds.Context, required: int, description: str): async def cmd_add(self, ctx: cmds.Context, required: int, *, description: str):
if ctx.broadcaster or ctx.author.moderator: if ctx.broadcaster or ctx.author.moderator:
community = await self.bot.community_fetch(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name) community = await self.bot.community_fetch(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name)
cid = community.communityid cid = community.communityid

View File

@@ -19,6 +19,7 @@ class Subathon(RowModel):
score_time = Integer() # Conversion factor score to seconds score_time = Integer() # Conversion factor score to seconds
duration = Integer() duration = Integer()
ended_at = Timestamp() ended_at = Timestamp()
timecap = Integer() # Maximum subathon duration in seconds
class RunningSubathon(RowModel): class RunningSubathon(RowModel):
_tablename_ = 'running_subathons' _tablename_ = 'running_subathons'
@@ -36,6 +37,7 @@ class SubathonContribution(RowModel):
profileid = Integer() profileid = Integer()
score = Integer() score = Integer()
event_id = Integer() event_id = Integer()
# TODO: Should add a created timestamp here, since not all contributions have event ids
class SubathonGoal(RowModel): class SubathonGoal(RowModel):
_tablename_ = 'subathon_goals' _tablename_ = 'subathon_goals'

View File

@@ -316,6 +316,7 @@ class TrackerComponent(cmds.Component):
streak_months=payload.streak_months, streak_months=payload.streak_months,
message=payload.text, message=payload.text,
) )
self.bot.safe_dispatch('subscription_message', payload=(event_row, detail_row, payload))
@cmds.Component.listener() @cmds.Component.listener()
async def event_stream_online(self, payload: twitchio.StreamOnline): async def event_stream_online(self, payload: twitchio.StreamOnline):