Add basic subathon impl.

This commit is contained in:
2025-07-28 13:29:21 +10:00
parent 6a50f13e87
commit 6879e53fbf
7 changed files with 481 additions and 10 deletions

View File

@@ -271,4 +271,46 @@ CREATE TABLE raid_in_events(
); );
-- }}} -- }}}
-- Subathon timer data {{{
CREATE TABLE subathons(
subathon_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
communityid INTEGER NOT NULL REFERENCES communities(communityid),
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
initial_time INTEGER NOT NULL,
sub1_score NUMERIC NOT NULL DEFAULT 1,
sub2_score NUMERIC NOT NULL DEFAULT 2,
sub3_score NUMERIC NOT NULL DEFAULT 6,
bit_score NUMERIC NOT NULL,
score_time NUMERIC NOT NULL,
duration INTEGER NOT NULL DEFAULT 0,
ended_at TIMESTAMPTZ
);
CREATE TABLE running_subathons(
subathon_id INTEGER PRIMARY KEY REFERENCES subathons(subathon_id),
last_started TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE subathon_contributions(
contribution_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
subathon_id INTEGER NOT NULL REFERENCES subathons(subathon_id),
profileid INTEGER REFERENCES user_profiles(profileid),
score NUMERIC NOT NULL,
event_id INTEGER REFERENCES events(event_id)
);
CREATE TABLE subathon_goals(
goal_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
subathon_id INTEGER NOT NULL REFERENCES subathons(subathon_id),
required_score NUMERIC NOT NULL,
description TEXT NOT NULL,
notified BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- }}}
-- vim: set fdm=marker: -- vim: set fdm=marker:

View File

@@ -1,11 +1,13 @@
import asyncio import asyncio
import logging import logging
import websockets
from twitchio.web import AiohttpAdapter from twitchio.web import AiohttpAdapter
from meta import CrocBot, conf, setup_main_logger, args from meta import CrocBot, conf, setup_main_logger, args
from data import Database from data import Database
from constants import DATA_VERSION from constants import DATA_VERSION
import sockets
from modules import setup from modules import setup
@@ -28,17 +30,18 @@ async def main():
domain=conf.bot.get('wsdomain', None), domain=conf.bot.get('wsdomain', None),
) )
bot = CrocBot( async with websockets.serve(sockets.root_handler, '', conf.wserver.getint('port')):
config=conf, bot = CrocBot(
dbconn=db, config=conf,
adapter=adapter, dbconn=db,
setup=setup, adapter=adapter,
) setup=setup,
)
try: try:
await bot.start() await bot.start()
finally: finally:
await bot.close() await bot.close()
def _main(): def _main():

View File

@@ -1,3 +1,5 @@
async def setup(bot): async def setup(bot):
from . import tracker from . import tracker
from . import subathons
await tracker.setup(bot) await tracker.setup(bot)
await subathons.setup(bot)

View File

@@ -0,0 +1,7 @@
import logging
logger = logging.getLogger()
async def setup(bot):
from .component import SubathonComponent
await bot.add_component(SubathonComponent(bot))

View File

@@ -0,0 +1,342 @@
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
from datamodels import BotChannel, Communities, UserProfile
from meta import CrocBot
from utils.lib import utc_now, strfdelta
from . import logger
from .data import SubathonData, Subathon, RunningSubathon, SubathonContribution, SubathonGoal
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
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()
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
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
class SubathonComponent(cmds.Component):
def __init__(self, bot: CrocBot):
self.bot = bot
self.data = bot.dbconn.load_registry(SubathonData())
# ----- API -----
async def component_load(self):
# TODO: Setup the websocket
await self.data.init()
async def component_teardown(self):
pass
# ----- Methods -----
async def get_community(self, twitchid: str, name: str | None) -> Communities:
return await Communities.fetch_or_create(twitchid=twitchid, name=name)
async def get_profile(self, twitchid: str, name: str | None) -> UserProfile:
return await UserProfile.fetch_or_create(twitchid=twitchid, name=name)
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 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:
# 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 ''
await bits_payload.broadcaster.send_message(
f"{name} contributed {bits_payload.bits} bit{pl} and added {added} to the timer! Thank you <3",
sender=self.bot.bot_id
)
# TODO: Websocket update
# 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:
data = active.subathondata
# In an active subathon
pid = event_row['profileid']
if sub_payload.tier == 1000:
mult = data.sub1_score
elif sub_payload.tier == 2000:
mult = data.sub2_score
elif sub_payload.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 ''
await sub_payload.broadcaster.send_message(
f"{name} contributed {score} sub{pl} and added {added} to the timer! Thank you <3",
sender=self.bot.bot_id
)
# TODO: Websocket update
# Check goals
@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:
data = active.subathondata
# In an active subathon
pid = event_row['profileid']
if gift_payload.tier == 1000:
mult = data.sub1_score
elif gift_payload.tier == 2000:
mult = data.sub2_score
elif gift_payload.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'
await gift_payload.broadcaster.send_message(
f"{name} contributed {score} subs and added {added} to the timer! Thank you <3",
sender=self.bot.bot_id
)
# TODO: Websocket update
# Check goals
# end stream => Automatically pause the timer
@cmds.Component.listener()
async def event_stream_offline(self, payload: twitchio.StreamOffline):
community = await Communities.fetch_or_create(twitchid=payload.broadcaster.id, name=payload.broadcaster.name)
cid = community.communityid
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
)
# TODO: Websocket update
# ----- Commands -----
@cmds.group(name='subathon', invoke_fallback=True)
async def group_subathon(self, ctx: cmds.Context):
# TODO: Status
...
# subathon start
@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):
if ctx.broadcaster:
community = await Communities.fetch_or_create(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name)
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
active = await Subathon.create(
communityid=cid,
initial_time=initial_time,
sub1_score=sub1,
sub2_score=sub2,
sub3_score=sub3,
bit_score=bit,
score_time=timescore
)
await ctx.reply("Setup a new subathon! Use !subathon resume to get the timer running.")
# TODO: Websocket broadcast
# subathon stop
@group_subathon.command(name='stop')
async def cmd_stop(self, ctx: cmds.Context):
if ctx.broadcaster:
community = await Communities.fetch_or_create(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name)
cid = community.communityid
if (active := await self.get_active_subathon(cid)) is not None:
if active.running:
await active.pause()
# TODO: Websocket update
await active.subathondata.update(ended_at=utc_now())
total = await active.get_score()
dursecs = active.get_duration()
dur = strfdelta(timedelta(seconds=dursecs))
await ctx.reply(
f"Subathon complete after {dur} with a total of {total} subs, congratulations!"
)
else:
await ctx.reply("No active subathon to stop.")
# subathon pause
@group_subathon.command(name='pause')
async def cmd_pause(self, ctx: cmds.Context):
if ctx.broadcaster or ctx.author.moderator:
community = await Communities.fetch_or_create(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name)
cid = community.communityid
if (active := await self.get_active_subathon(cid)) is not None:
if active.running:
await active.pause()
await ctx.reply("Subathon timer paused!")
# TODO: Websocket update
else:
await ctx.reply("Subathon timer already paused!")
else:
await ctx.reply("No active subathon to pause")
# subathon resume
@group_subathon.command(name='resume')
async def cmd_resume(self, ctx: cmds.Context):
if ctx.broadcaster or ctx.author.moderator:
community = await Communities.fetch_or_create(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name)
cid = community.communityid
if (active := await self.get_active_subathon(cid)) is not None:
if not active.running:
await active.resume()
await ctx.reply("Subathon timer resumed!")
# TODO: Websocket update
else:
await ctx.reply("Subathon timer already running!")
else:
await ctx.reply("No active subathon to resume")
@cmds.group(name='goals', invoke_fallback=True)
async def group_goals(self, ctx: cmds.Context):
# List the goals
community = await Communities.fetch_or_create(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name)
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} subs: {goal.description}"
goalstrs.append(line)
if goals:
text = ', '.join(goalstrs)
await ctx.reply(f"Subathon Goals! -- {text}")
else:
await ctx.reply("No goals have been configured!")
else:
await ctx.reply("No active subathon running!")
@group_goals.command(name='add')
async def cmd_add(self, ctx: cmds.Context, required: int, description: str):
if ctx.broadcaster or ctx.author.moderator:
community = await Communities.fetch_or_create(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name)
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 goal!")

View File

@@ -0,0 +1,56 @@
from data import Registry, RowModel, Table
from data.columns import String, Timestamp, Integer, Bool
# User contributed {} subs and added {}:{} to the timer! Thank you :love:
# We have reached goal #5: Karaoke !! Thank you everyone for your support <3
class Subathon(RowModel):
_tablename_ = 'subathons'
_cache_ = {}
subathon_id = Integer(primary=True)
communityid = Integer()
started_at = Timestamp()
initial_time = Integer() # Initial number of seconds
sub1_score = Integer()
sub2_score = Integer()
sub3_score = Integer()
bit_score = Integer()
score_time = Integer() # Conversion factor score to seconds
duration = Integer()
ended_at = Timestamp()
class RunningSubathon(RowModel):
_tablename_ = 'running_subathons'
_cache_ = {}
subathon_id = Integer(primary=True)
last_started = Timestamp()
class SubathonContribution(RowModel):
_tablename_ = 'subathon_contributions'
_cache_ = {}
contribution_id = Integer(primary=True)
subathon_id = Integer()
profileid = Integer()
score = Integer()
event_id = Integer()
class SubathonGoal(RowModel):
_tablename_ = 'subathon_goals'
_cache_ = {}
goal_id = Integer(primary=True)
subathon_id = Integer()
required_score = Integer()
description = String()
notified = Bool()
created_at = Timestamp()
class SubathonData(Registry):
subathons = Subathon.table
running_subathons = RunningSubathon.table
subathon_contributions = SubathonContribution.table
subathon_goals = SubathonGoal.table

View File

@@ -235,6 +235,7 @@ class TrackerComponent(cmds.Component):
message=payload.text, message=payload.text,
powerup_type=payload.power_up.type if payload.power_up else None powerup_type=payload.power_up.type if payload.power_up else None
) )
self.bot.safe_dispatch('bits_use', payload=(event_row, detail_row, payload))
@cmds.Component.listener() @cmds.Component.listener()
async def event_subscription(self, payload: twitchio.ChannelSubscribe): async def event_subscription(self, payload: twitchio.ChannelSubscribe):
@@ -259,6 +260,7 @@ class TrackerComponent(cmds.Component):
tier=payload.tier, tier=payload.tier,
gifted=payload.gift, gifted=payload.gift,
) )
self.bot.safe_dispatch('subscription', payload=(event_row, detail_row, payload))
@cmds.Component.listener() @cmds.Component.listener()
async def event_subscription_gift(self, payload: twitchio.ChannelSubscriptionGift): async def event_subscription_gift(self, payload: twitchio.ChannelSubscriptionGift):
@@ -286,6 +288,7 @@ class TrackerComponent(cmds.Component):
tier=payload.tier, tier=payload.tier,
gifted_count=payload.total, gifted_count=payload.total,
) )
self.bot.safe_dispatch('gift_subscription', payload=(event_row, detail_row, payload))
@cmds.Component.listener() @cmds.Component.listener()
async def event_subscription_message(self, payload: twitchio.ChannelSubscriptionMessage): async def event_subscription_message(self, payload: twitchio.ChannelSubscriptionMessage):
@@ -333,6 +336,22 @@ class TrackerComponent(cmds.Component):
stream_type=payload.type, stream_type=payload.type,
) )
@cmds.Component.listener()
async def event_stream_offline(self, payload: twitchio.StreamOffline):
tracked = await TrackingChannel.fetch(payload.broadcaster.id)
if tracked and tracked.joined:
community = await Communities.fetch_or_create(twitchid=payload.broadcaster.id, name=payload.broadcaster.name)
cid = community.communityid
event_row = await self.data.events.insert(
event_type='stream_offline',
communityid=cid,
channel_id=payload.broadcaster.id,
)
detail_row = await self.data.stream_offline_events.insert(
event_id=event_row['event_id'],
)
@cmds.Component.listener() @cmds.Component.listener()
async def event_raid(self, payload: twitchio.ChannelRaid): async def event_raid(self, payload: twitchio.ChannelRaid):
await self._event_raid_out( await self._event_raid_out(