Compare commits

..

31 Commits

Author SHA1 Message Date
11c77bcc89 tweak: Add fallback user name 2025-10-30 23:42:56 +10:00
89f9ce3ffa fix: Next and last goal logic 2025-10-30 23:21:11 +10:00
21cf65398c tweak: Add contribution logging. 2025-10-30 22:48:34 +10:00
84791e6b91 (channel): Add basic logging to event 2025-10-30 22:24:12 +10:00
7a9a47f11f fix: More typos in raw data query 2025-10-30 22:23:49 +10:00
0c5250f09e fix: Typo in join column name 2025-10-30 22:07:14 +10:00
aa87fd6f9f feat: Add data to channel payload.
Add entire goal list to payload.
Add contributions leaderboard to payload.
Add raw sub point total to payload.
Add raw bits total to payload.

Add subpoint and bits total computations to ActiveSubathon.
2025-10-30 21:13:17 +10:00
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
033fc5c1ae Improve subathon modularity. 2025-09-22 23:30:45 +10:00
899f5e7292 Move timer url to config 2025-09-02 22:12:43 +10:00
a5dd5ce6ad fix: Join type in lb query. 2025-09-02 22:02:29 +10:00
27103366aa fix: leaderboard query 2025-09-02 21:34:54 +10:00
b10098d28f feat: Add lb and adjust commands. 2025-09-02 21:31:19 +10:00
a3a3b36230 Add subathon name. 2025-09-02 10:40:01 +10:00
0934ee3af4 Tweak timer channel. 2025-09-02 09:17:00 +10:00
2c8bc4ae24 fix: Add timecap to data. 2025-09-02 08:56:41 +10:00
37ecb2d7ee tweak: Update wording. 2025-09-02 08:47:59 +10:00
18f57d1800 Migrate to new plugin architecture. 2025-09-01 23:24:38 +10:00
1544e9dbf9 Add data version. 2025-09-01 23:24:27 +10:00
0ea90b5cd2 Annotate component. 2025-09-01 19:41:19 +10:00
a7b38c20b2 Add created_by field for contribution. 2025-09-01 19:41:03 +10:00
4e9dbeee79 Add schema. 2025-09-01 19:40:49 +10:00
d1abe6782e Update plugin name. 2025-09-01 19:21:07 +10:00
9 changed files with 1460 additions and 496 deletions

View File

@@ -0,0 +1 @@
from .subathon import setup as twitch_setup

46
data/subathon.sql Normal file
View File

@@ -0,0 +1,46 @@
BEGIN;
INSERT INTO version_history (component, from_version, to_version, author)
VALUES ('SUBATHON', 0, 1, 'Initial Creation');
CREATE TABLE subathons(
subathon_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
communityid INTEGER NOT NULL REFERENCES communities(communityid) ON DELETE CASCADE ON UPDATE CASCADE,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
initial_time INTEGER NOT NULL,
name TEXT,
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,
timecap INTEGER,
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) ON DELETE CASCADE,
profileid INTEGER REFERENCES user_profiles(profileid) ON DELETE SET NULL,
score NUMERIC NOT NULL,
event_id INTEGER REFERENCES events(event_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE subathon_goals(
goal_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
subathon_id INTEGER NOT NULL REFERENCES subathons(subathon_id) ON DELETE CASCADE,
required_score NUMERIC NOT NULL,
description TEXT NOT NULL,
notified BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
COMMIT;

View File

@@ -0,0 +1,3 @@
[SUBATHON]
timer_url = https://izashi.thewisewolf.dev/tracker/timer

261
subathon/channel.py Normal file
View File

@@ -0,0 +1,261 @@
from typing import Optional, TypeAlias, TypedDict
import json
from collections import defaultdict
from datetime import datetime, timedelta
from dataclasses import dataclass
from data.queries import JOINTYPE, ORDER
from meta.sockets import Channel
from utils.lib import utc_now
from modules.profiles.profiles.profiles import ProfilesRegistry
from . import logger
from .data import (
SubathonData,
Subathon,
RunningSubathon,
SubathonContribution,
SubathonGoal,
)
from .subathon import ActiveSubathon, SubathonRegistry
# ISO formatted timestamp
ISOTimestamp: TypeAlias = str
class GoalPayload(TypedDict):
required: float
name: str
class ContributionPayload(TypedDict):
user_name: str
user_id: str | None
amount: float
seconds_added: int
timestamp: ISOTimestamp
class LeaderboardItemPayload(TypedDict):
user_name: str
user_id: str | None
amount: float
class ScoreTablePayload(TypedDict):
bit_score: float # points per bit
t1_score: float # points per T1 sub
t2_score: float # points per T2 sub
t3_score: float # points per T3 sub
score_time: float # seconds added per point
class SubathonPayload(TypedDict):
end_at: ISOTimestamp
is_running: bool
score_table: ScoreTablePayload
name: str
total_contribution: float
total_subpoints: int
total_bits: int
goals_met: int
goals_total: int
last_goal: GoalPayload | None
next_goal: GoalPayload | None
goals: list[GoalPayload]
last_contribution: ContributionPayload | None
leaderboard: list[LeaderboardItemPayload]
async def prepare_subathon(
profiler: ProfilesRegistry, subathon: ActiveSubathon
) -> SubathonPayload:
total_score = await subathon.get_score()
# Build the goal data
goals = await subathon.get_goals()
goals_met = 0
last_goalp = None
next_goalp = None
goalps = []
total_goals = len(goals)
for goal in goals:
goalp = GoalPayload(
required=float(goal.required_score),
name=goal.description,
)
goalps.append(goalp)
if goal.required_score <= total_score:
last_goalp = goalp
goals_met += 1
elif next_goalp is None:
next_goalp = goalp
# Build the last contribution information
contribs = await subathon.fetch_contributions().limit(1)
last_contrib: ContributionPayload | None = None
if contribs:
contrib = contribs[0]
if contrib.profileid:
profile = await profiler.get_profile(contrib.profileid)
assert profile is not None
name = profile.nickname or "Unknown"
user_id = str(profile.profileid)
else:
name = "Anonymous"
user_id = None
last_contrib = ContributionPayload(
user_name=name,
user_id=user_id,
amount=float(contrib.score),
seconds_added=subathon.get_score_time(contrib.score),
timestamp=contrib.created_at.isoformat(),
)
# Build the score table
score_table = ScoreTablePayload(
bit_score=float(subathon.subathondata.bit_score),
t1_score=float(subathon.subathondata.sub1_score),
t2_score=float(subathon.subathondata.sub2_score),
t3_score=float(subathon.subathondata.sub3_score),
score_time=int(subathon.subathondata.score_time),
)
# Build the contribution leaderboard
query = SubathonContribution.table.select_where(
subathon_id=subathon.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
leaderboard = []
for row in results:
if pid := row["profileid"]:
profile = await profiler.get_profile(pid)
name = (profile.nickname if profile else None) or "Unknown"
else:
name = "Anonymous"
score = row["total"]
item = LeaderboardItemPayload(
user_name=name,
user_id=pid,
amount=float(score),
)
leaderboard.append(item)
# Build the raw totals
subpoints = await subathon.get_total_subs()
bits = await subathon.get_total_bits()
# Finally, put together the payload
payload = SubathonPayload(
name=subathon.subathondata.name,
end_at=(await subathon.get_ending()).isoformat(),
is_running=(subathon.running),
score_table=score_table,
total_contribution=float(await subathon.get_score()),
total_subpoints=subpoints,
total_bits=bits,
goals_met=goals_met,
goals_total=total_goals,
last_goal=last_goalp,
next_goal=next_goalp,
goals=goalps,
last_contribution=last_contrib,
leaderboard=leaderboard,
)
return payload
class TimerChannel(Channel):
name = "SubTimer"
def __init__(
self, profiler: ProfilesRegistry, subathons: SubathonRegistry, **kwargs
):
super().__init__(**kwargs)
self.profiler: ProfilesRegistry = profiler
self.subathons: SubathonRegistry = subathons
# Map of communities to webhooks listening for this community
self.communities = defaultdict(
set
) # Map of communityid -> listening websockets
async def on_connection(self, websocket, event):
if not (cidstr := event.get("community")):
raise ValueError("Subathon timer connection missing communityid")
elif not cidstr.isdigit():
raise ValueError("Community id provided is not an integer")
cid = int(cidstr)
community = await self.profiler.get_community(cid)
if community is None:
raise ValueError("Unknown community provided.")
await super().on_connection(websocket, event)
self.communities[cid].add(websocket)
subathon = await self.subathons.get_active_subathon(cid)
if subathon:
payload = await prepare_subathon(self.profiler, subathon)
await self.send_subathon_update(cid, payload, websocket)
else:
await self.send_no_subathon(cid, websocket)
async def del_connection(self, websocket):
for wss in self.communities.values():
wss.discard(websocket)
await super().del_connection(websocket)
async def send_subathon_update(
self, communityid: int, payload: SubathonPayload, websocket=None
):
for ws in (websocket,) if websocket else self.communities[communityid]:
await self.send_event(
{
"type": "DO",
"method": "setTimer",
"args": payload,
},
websocket=ws,
)
async def send_subathon_ended(
self, communityid: int, payload: SubathonPayload, websocket=None
):
for ws in (websocket,) if websocket else self.communities[communityid]:
await self.send_event(
{
"type": "DO",
"method": "endTimer",
"args": payload,
},
websocket=ws,
)
async def send_no_subathon(self, communityid: int, websocket=None):
for ws in (websocket,) if websocket else self.communities[communityid]:
await self.send_event(
{
"type": "DO",
"method": "noTimer",
"args": {},
},
websocket=ws,
)
async def send_event(self, event, **kwargs):
logger.info(f"Sending websocket event: {json.dumps(event, indent=1)}")
await super().send_event(event, **kwargs)

900
subathon/component.py Normal file
View File

@@ -0,0 +1,900 @@
import asyncio
import re
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):
"""
!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
# 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
# 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(
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 -----
# 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()
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
logger.info(f"Adding subathon contribution from bits payload {payload}")
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
logger.info(
f"Adding subathon contribution from subscription payload {payload}"
)
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
logger.info(
f"Adding subathon contribution from subscription gift payload {payload}"
)
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
logger.info(
f"Adding subathon contribution from subscription message payload {payload}"
)
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:
logger.info(
f"Automatically paused suabathon {active.subathondata!r} from stream offline."
)
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
# TODO: Usage line on command error
@group_subathon.command(name="setup", alias="start")
@cmds.is_broadcaster()
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.
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(
name="Subathon",
communityid=cid,
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 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)
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 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
@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
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()
async def subathon_config_grp(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:
await ctx.reply(
f"USAGE: {ctx.prefix}subathon config [option [value]]\n"
f"Use '{ctx.prefix}subathon config' to see available options and current values."
)
return
parts = []
sdata = active.subathondata
# name
parts.append(f'name="{sdata.name}"')
# timecamp
cap = sdata.timecap or 0
caph = int(cap / 3600)
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(
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",
),
)
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("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.")

View File

@@ -1,8 +1,6 @@
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'
@@ -11,6 +9,7 @@ class Subathon(RowModel):
subathon_id = Integer(primary=True)
communityid = Integer()
started_at = Timestamp()
name = String()
initial_time = Integer() # Initial number of seconds
sub1_score = Integer()
sub2_score = Integer()
@@ -37,7 +36,7 @@ class SubathonContribution(RowModel):
profileid = Integer()
score = Integer()
event_id = Integer()
# TODO: Should add a created timestamp here, since not all contributions have event ids
created_at = Timestamp()
class SubathonGoal(RowModel):
_tablename_ = 'subathon_goals'
@@ -52,6 +51,8 @@ class SubathonGoal(RowModel):
class SubathonData(Registry):
VERSION = ('SUBATHON', 1)
subathons = Subathon.table
running_subathons = RunningSubathon.table
subathon_contributions = SubathonContribution.table

245
subathon/subathon.py Normal file
View File

@@ -0,0 +1,245 @@
from asyncio import Event
from datetime import datetime, timedelta
from data.queries import JOINTYPE
from utils.lib import utc_now, strfdelta
from modules.profiles.profiles.profiles import ProfilesRegistry
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
@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 check_finished(self):
"""
Return True if the subathon duration has exceeded its earned time.
"""
return await self.get_remaining() <= 0
async def pause(self):
"""
Pause the active subathon.
If the subathon is in 'overtime', i.e. it has already finished,
then the total time is saved as the earned time not including the overtime.
"""
if not self.running:
raise ValueError("This subathon is not running!")
assert self.runningdata is not None
new_duration = await 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)
async def get_duration(self) -> int:
"""
Number of seconds that this subathon has run for.
Includes current running session if it exists.
If the subathon is already finished (in overtime)
returns the earned time instead of the true duration.
"""
duration = self.subathondata.duration
# Add running duration if required
if self.runningdata:
now = utc_now()
added = int((now - self.runningdata.last_started).total_seconds())
duration += added
earned = await self.get_earned()
return min(duration, earned)
async def get_earned(self) -> int:
"""
Number of seconds earned in the subathon so far.
Includes initial time.
Takes into account the cap.
"""
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
async def get_remaining(self) -> int:
"""
Number of seconds remaining on the subathon timer.
Will be 0 or slightly negative if finished.
"""
total_time = await self.get_earned()
return total_time - await self.get_duration() - 1
async def get_ending(self):
"""
Ending time of the subathon.
May be in the past if the subathon has already ended.
"""
now = utc_now()
remaining = await self.get_remaining()
return now + timedelta(seconds=remaining)
async def add_contribution(
self, profileid: int | None, score: float, event_id: int | None
) -> SubathonContribution:
row = await SubathonContribution.create(
subathon_id=self.subathondata.subathon_id,
profileid=profileid,
score=score,
event_id=event_id,
)
logger.info(f"Added contribution {row!r} to subathon {self.subathondata!r}")
return row
def fetch_contributions(self, **kwargs):
query = SubathonContribution.fetch_where(
subathon_id=self.subathondata.subathon_id, **kwargs
).order_by("created_at")
return query
async def get_goals(self) -> list[SubathonGoal]:
goals = await SubathonGoal.fetch_where(
subathon_id=self.subathondata.subathon_id
).order_by("required_score")
return goals
async def get_total_subs(self) -> int:
"""
Get the total number of sub points
contributed to this subathon.
"""
pointcol = (
"CASE "
"WHEN tier = 1000 THEN 1 "
"WHEN tier = 2000 THEN 2 "
"WHEN tier = 3000 THEN 6 "
"END"
)
total_points = 0
result = await (
SubathonContribution.table.select_where(
subathon_id=self.subathondata.subathon_id
)
.join(
"subscribe_events",
using=("event_id",),
join_type=JOINTYPE.INNER,
)
.select(total=f"SUM({pointcol})")
.with_no_adapter()
)
total_points += result[0]["total"] or 0
result = await (
SubathonContribution.table.select_where(
subathon_id=self.subathondata.subathon_id
)
.join(
"subscribe_message_events",
using=("event_id",),
join_type=JOINTYPE.INNER,
)
.select(total=f"SUM({pointcol})")
.with_no_adapter()
)
total_points += result[0]["total"] or 0
result = await (
SubathonContribution.table.select_where(
subathon_id=self.subathondata.subathon_id
)
.join(
"gift_events",
using=("event_id",),
join_type=JOINTYPE.INNER,
)
.select(total=f"SUM(gifted_count * ({pointcol}))")
.with_no_adapter()
)
total_points += result[0]["total"] or 0
return total_points
async def get_total_bits(self) -> int:
"""
Get the total number of bits
contributed to this subathon.
"""
result = await (
SubathonContribution.table.select_where(
subathon_id=self.subathondata.subathon_id
)
.join(
"bits_events",
using=("event_id",),
join_type=JOINTYPE.INNER,
)
.select(total="SUM(bits)")
.with_no_adapter()
)
return result[0]["total"] or 0
class SubathonRegistry:
def __init__(self, data: SubathonData, profiler: ProfilesRegistry):
self.data = data
self.profiler = profiler
async def init(self):
await self.data.init()
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

View File

@@ -1,493 +0,0 @@
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 sockets import Channel, register_channel
from . import logger
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:
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 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()
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
class SubathonComponent(cmds.Component):
def __init__(self, bot: CrocBot):
self.bot = bot
self.data = bot.dbconn.load_registry(SubathonData())
self.channel = TimerChannel(self)
register_channel('SubTimer', self.channel)
# ----- 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 self.bot.community_fetch(twitchid=twitchid, name=name)
async def get_profile(self, twitchid: str, name: str | None) -> UserProfile:
return await self.bot.profile_fetch(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 ''
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(
contrib_str,
sender=self.bot.bot_id
)
await self.channel.send_updates()
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:
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)
@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']
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} 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(
contrib_str,
sender=self.bot.bot_id
)
await self.channel.send_updates()
# 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:
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
@cmds.Component.listener()
async def event_stream_offline(self, payload: twitchio.StreamOffline):
community = await self.bot.community_fetch(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
)
await self.channel.send_updates()
# ----- Commands -----
@cmds.group(name='subathon', aliases=['studython'], invoke_fallback=True)
async def group_subathon(self, ctx: cmds.Context):
# TODO: Status
community = await self.bot.community_fetch(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name)
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"
secs = await active.get_remaining()
remaining = strfdelta(timedelta(seconds=secs))
secs = active.get_duration()
duration = strfdelta(timedelta(seconds=secs))
text = (
f"Subathon running for {duration}! {score} (equivalent) subscriptions recieved, {goalstr}, and {remaining} left on the timer"
)
await ctx.reply(text)
else:
await ctx.reply("No active subathon running!")
# 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, timecap: Optional[int]=None):
if ctx.broadcaster:
community = await self.bot.community_fetch(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,
timecap=timecap
)
await ctx.reply("Setup a new subathon! Use !subathon resume to get the timer running.")
await self.channel.send_updates()
# subathon stop
@group_subathon.command(name='stop')
async def cmd_stop(self, ctx: cmds.Context):
if ctx.broadcaster:
community = await self.bot.community_fetch(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 self.channel.send_updates()
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 self.bot.community_fetch(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!")
await self.channel.send_updates()
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 self.bot.community_fetch(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!")
await self.channel.send_updates()
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 self.bot.community_fetch(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 self.bot.community_fetch(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!")