Compare commits

..

16 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
3 changed files with 673 additions and 193 deletions

View File

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

View File

@@ -1,3 +1,5 @@
import asyncio
import re
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
@@ -11,12 +13,49 @@ from meta.sockets import Channel, register_channel
from utils.lib import utc_now, strfdelta from utils.lib import utc_now, strfdelta
from . import logger from . import logger
from .data import SubathonData, Subathon, RunningSubathon, SubathonContribution, SubathonGoal from .data import (
SubathonData,
Subathon,
RunningSubathon,
SubathonContribution,
SubathonGoal,
)
from .subathon import ActiveSubathon, SubathonRegistry from .subathon import ActiveSubathon, SubathonRegistry
from .channel import SubathonPayload, prepare_subathon, TimerChannel from .channel import SubathonPayload, prepare_subathon, TimerChannel
class SubathonComponent(cmds.Component): class SubathonComponent(cmds.Component):
"""
!subathon
!subathon setup <initial_hours> <t1score> <t2score> <t3score> <bitscore> <timescore> [cap]
!subathon stop
!subathon link
!subathon pause
!subathon resume
!subathon adjust <amount> [@user]
!subathon config
!subathon config name [new name]
!subathon config cap [new cap]
!subathon config scores [<t1score> <t2score> <t3score> <bitscore>]
!subathon config timescore [new score]
!subathon lb
!goals
!goals remaining
!goals add <points> <description>
!goals remove <points>
Command:
Permissions:
Description:
Examples:
"""
# TODO: Add explicit dependencies and version checks # TODO: Add explicit dependencies and version checks
# for profile and event tracker modules # for profile and event tracker modules
@@ -26,7 +65,7 @@ class SubathonComponent(cmds.Component):
self.subathons = SubathonRegistry(self.data, bot.profiles.profiles) self.subathons = SubathonRegistry(self.data, bot.profiles.profiles)
self.channel = TimerChannel(self.bot.profiles.profiles, self.subathons) self.channel = TimerChannel(self.bot.profiles.profiles, self.subathons)
register_channel('SubTimer', self.channel) register_channel("SubTimer", self.channel)
# ----- API ----- # ----- API -----
async def component_load(self): async def component_load(self):
@@ -53,22 +92,62 @@ class SubathonComponent(cmds.Component):
for i, goal in enumerate(goals): for i, goal in enumerate(goals):
if not goal.notified and goal.required_score <= score: if not goal.notified and goal.required_score <= score:
# Goal completed, notify channel # Goal completed, notify channel
# TODO: Quick hack to avoid running into ratelimits
# Should probably wait on a central messaging slow-lock bucketed by channel instead
await asyncio.sleep(1)
await channel.send_message( await channel.send_message(
f"We have reached Goal #{i+1}: {goal.description} !! Thank you everyone for your support <3", f"We have reached Goal #{i + 1}: {goal.description} !! Thank you everyone for your support <3",
sender=self.bot.bot_id, sender=self.bot.bot_id,
) )
await goal.update(notified=True) await goal.update(notified=True)
# ----- Event Handlers ----- # ----- Event Handlers -----
# Blerp handler
@cmds.Component.listener()
async def event_message(self, message: twitchio.ChatMessage):
if message.chatter.id not in ("253326823", "1361913054"):
return
if "bits" not in message.text:
return
community = await self.bot.profiles.fetch_community(message.broadcaster)
cid = community.communityid
if (
active := await self.get_active_subathon(cid)
) is not None and not await active.check_finished():
# Extract count and user
match = re.match(r"(?P<name>\w+) used (?P<amount>\d+)", message.text)
if not match:
match = re.match(
r"!For (?P<amount>\d+) bits, (?P<name>\w+)", message.text
)
if match:
amount = int(match["amount"])
name = match["name"]
# This is for giving the contribution to the calling user
# user = await self.bot.fetch_user(login=name.lower())
user = await message.chatter.user()
profile = await self.bot.profiles.fetch_profile(user)
pid = profile.profileid
score = amount * active.subathondata.bit_score
contrib = await active.add_contribution(pid, score, None)
await self.dispatch_update(active)
logger.info(f"Blerp contribution: {contrib!r} from {message!r}")
else:
logger.warning(f"Unknown blerp bit message: {message!r}")
@cmds.Component.listener() @cmds.Component.listener()
async def event_safe_bits_use(self, payload): async def event_safe_bits_use(self, payload):
event_row, detail_row, bits_payload = payload event_row, detail_row, bits_payload = payload
if (active := await self.get_active_subathon(event_row['communityid'])) is not None and not await active.check_finished(): if (
active := await self.get_active_subathon(event_row["communityid"])
) is not None and not await active.check_finished():
# In an active subathon # In an active subathon
pid = event_row['profileid'] pid = event_row["profileid"]
uid = event_row['user_id'] uid = event_row["user_id"]
score = detail_row['bits'] * active.subathondata.bit_score score = detail_row["bits"] * active.subathondata.bit_score
await active.add_contribution(pid, score, event_row['event_id']) logger.info(f"Adding subathon contribution from bits payload {payload}")
await active.add_contribution(pid, score, event_row["event_id"])
# Send message to channel # Send message to channel
sec = active.get_score_time(score) sec = active.get_score_time(score)
@@ -78,7 +157,7 @@ class SubathonComponent(cmds.Component):
else: else:
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} point{pl}" contrib_str = f"{name} contributed {score} point{pl}"
if not await active.check_cap(): if not await active.check_cap():
@@ -87,8 +166,7 @@ class SubathonComponent(cmds.Component):
contrib_str += " towards our subathon! Thank you <3" contrib_str += " towards our subathon! Thank you <3"
await bits_payload.broadcaster.send_message( await bits_payload.broadcaster.send_message(
contrib_str, contrib_str, sender=self.bot.bot_id
sender=self.bot.bot_id
) )
await self.dispatch_update(active) await self.dispatch_update(active)
await self.goalcheck(active, bits_payload.broadcaster) await self.goalcheck(active, bits_payload.broadcaster)
@@ -101,16 +179,18 @@ class SubathonComponent(cmds.Component):
# Ignore gifted here # Ignore gifted here
return return
if (active := await self.get_active_subathon(event_row['communityid'])) is not None and not await active.check_finished(): if (
active := await self.get_active_subathon(event_row["communityid"])
) is not None and not await active.check_finished():
data = active.subathondata data = active.subathondata
# In an active subathon # In an active subathon
pid = event_row['profileid'] pid = event_row["profileid"]
tier = int(sub_payload.tier) tier = int(sub_payload.tier)
if tier == 1000: if tier == 1000:
mult = data.sub1_score mult = data.sub1_score
elif tier == 2000: elif tier == 2000:
mult = data.sub2_score mult = data.sub2_score
elif tier == 3000: elif tier == 3000:
mult = data.sub3_score mult = data.sub3_score
else: else:
@@ -118,13 +198,16 @@ class SubathonComponent(cmds.Component):
score = mult * 1 score = mult * 1
await active.add_contribution(pid, score, event_row['event_id']) logger.info(
f"Adding subathon contribution from subscription payload {payload}"
)
await active.add_contribution(pid, score, event_row["event_id"])
# Send message to channel # Send message to channel
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 = 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} point{pl}" contrib_str = f"{name} contributed {score} point{pl}"
if not await active.check_cap(): if not await active.check_cap():
@@ -133,8 +216,7 @@ class SubathonComponent(cmds.Component):
contrib_str += " towards our subathon! Thank you <3" contrib_str += " towards our subathon! Thank you <3"
await sub_payload.broadcaster.send_message( await sub_payload.broadcaster.send_message(
contrib_str, contrib_str, sender=self.bot.bot_id
sender=self.bot.bot_id
) )
await self.dispatch_update(active) await self.dispatch_update(active)
# Check goals # Check goals
@@ -144,16 +226,18 @@ class SubathonComponent(cmds.Component):
async def event_safe_subscription_gift(self, payload): async def event_safe_subscription_gift(self, payload):
event_row, detail_row, gift_payload = 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(): if (
active := await self.get_active_subathon(event_row["communityid"])
) is not None and not await active.check_finished():
data = active.subathondata data = active.subathondata
# In an active subathon # In an active subathon
pid = event_row['profileid'] pid = event_row["profileid"]
tier = int(gift_payload.tier) tier = int(gift_payload.tier)
if tier == 1000: if tier == 1000:
mult = data.sub1_score mult = data.sub1_score
elif tier == 2000: elif tier == 2000:
mult = data.sub2_score mult = data.sub2_score
elif tier == 3000: elif tier == 3000:
mult = data.sub3_score mult = data.sub3_score
else: else:
@@ -161,12 +245,15 @@ class SubathonComponent(cmds.Component):
score = mult * gift_payload.total score = mult * gift_payload.total
await active.add_contribution(pid, score, event_row['event_id']) 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 # Send message to channel
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} points" contrib_str = f"{name} contributed {score} points"
if not await active.check_cap(): if not await active.check_cap():
@@ -175,8 +262,7 @@ class SubathonComponent(cmds.Component):
contrib_str += " towards our subathon! Thank you <3" contrib_str += " towards our subathon! Thank you <3"
await gift_payload.broadcaster.send_message( await gift_payload.broadcaster.send_message(
contrib_str, contrib_str, sender=self.bot.bot_id
sender=self.bot.bot_id
) )
await self.dispatch_update(active) await self.dispatch_update(active)
# Check goals # Check goals
@@ -186,16 +272,18 @@ class SubathonComponent(cmds.Component):
async def event_safe_subscription_message(self, payload): async def event_safe_subscription_message(self, payload):
event_row, detail_row, sub_payload = 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(): if (
active := await self.get_active_subathon(event_row["communityid"])
) is not None and not await active.check_finished():
data = active.subathondata data = active.subathondata
# In an active subathon # In an active subathon
pid = event_row['profileid'] pid = event_row["profileid"]
tier = int(sub_payload.tier) tier = int(sub_payload.tier)
if tier == 1000: if tier == 1000:
mult = data.sub1_score mult = data.sub1_score
elif tier == 2000: elif tier == 2000:
mult = data.sub2_score mult = data.sub2_score
elif tier == 3000: elif tier == 3000:
mult = data.sub3_score mult = data.sub3_score
else: else:
@@ -203,13 +291,16 @@ class SubathonComponent(cmds.Component):
score = mult * 1 score = mult * 1
await active.add_contribution(pid, score, event_row['event_id']) 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 # Send message to channel
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 = 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} points{pl}" contrib_str = f"{name} contributed {score} points{pl}"
if not await active.check_cap(): if not await active.check_cap():
@@ -218,8 +309,7 @@ class SubathonComponent(cmds.Component):
contrib_str += " towards our subathon! Thank you <3" contrib_str += " towards our subathon! Thank you <3"
await sub_payload.broadcaster.send_message( await sub_payload.broadcaster.send_message(
contrib_str, contrib_str, sender=self.bot.bot_id
sender=self.bot.bot_id
) )
await self.dispatch_update(active) await self.dispatch_update(active)
# Check goals # Check goals
@@ -232,17 +322,20 @@ class SubathonComponent(cmds.Component):
cid = community.communityid cid = community.communityid
if (active := await self.get_active_subathon(cid)) is not None: if (active := await self.get_active_subathon(cid)) is not None:
if active.running: if active.running:
logger.info(
f"Automatically paused suabathon {active.subathondata!r} from stream offline."
)
await active.pause() await active.pause()
if not await active.check_finished(): if not await active.check_finished():
await payload.broadcaster.send_message( await payload.broadcaster.send_message(
"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,
) )
await self.dispatch_update(active) await self.dispatch_update(active)
# ----- Commands ----- # ----- Commands -----
@cmds.group(name='subathon', aliases=['studython'], 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):
community = await self.bot.profiles.fetch_community(ctx.broadcaster) community = await self.bot.profiles.fetch_community(ctx.broadcaster)
cid = community.communityid cid = community.communityid
@@ -257,33 +350,40 @@ class SubathonComponent(cmds.Component):
duration = strfdelta(timedelta(seconds=dursecs)) duration = strfdelta(timedelta(seconds=dursecs))
secs = await active.get_remaining() secs = await active.get_remaining()
if secs >= 0: if secs > 0:
remaining = strfdelta(timedelta(seconds=secs)) remaining = strfdelta(timedelta(seconds=secs))
text = ( text = f"{active.name} running for {duration}! {score} points recieved, {goalstr}, and {remaining} left on the timer"
f"{active.name} running for {duration}! {score} points recieved, {goalstr}, and {remaining} left on the timer"
)
else: else:
text = ( text = f"{active.name} completed after {duration} with a total of {score} points, and {goalstr}!"
f"{active.name} completed after {duration} with a total of {score} points, and {goalstr}!"
)
await ctx.reply(text) await ctx.reply(text)
else: else:
await ctx.reply("No active subathon running!") await ctx.reply("No active subathon running!")
# subathon start # subathon start
@group_subathon.command(name='setup', alias='start') # TODO: Usage line on command error
@group_subathon.command(name="setup", alias="start")
@cmds.is_broadcaster() @cmds.is_broadcaster()
async def cmd_setup(self, ctx: cmds.Context, name: str, initial_hours: float, sub1: float, sub2: float, sub3: float, bit: float, timescore: int, timecap: Optional[int]=None): async def cmd_setup(
self,
ctx: cmds.Context,
initial_hours: float,
sub1: float,
sub2: float,
sub3: float,
bit: float,
timescore: int,
timecap: Optional[int] = None,
):
""" """
Creates a new subathon. Creates a new subathon.
USAGE: {prefix}subathon setup <initial_hours> <sub1_points> <sub2_points> <sub3_points> <bit_points> <timepoints> [timecap] USAGE: {prefix}subathon setup <initial_hours> <sub1_points> <sub2_points> <sub3_points> <bit_points> <timepoints> [timecap]
Arguments: Arguments:
initial_hours = number of hours to start the timer with initial_hours = number of hours to start the timer with
sub1_points = points per T1 sub sub1_points = points per T1 sub
sub2_points = points per T2 sub sub2_points = points per T2 sub
sub3_points = points per T3 sub sub3_points = points per T3 sub
bit_points = points per bit bit_points = points per bit
timepoints = seconds to be added to the timer per point timepoints = seconds to be added to the timer per point
timecap (optional) = number of hours to cap the timer at timecap (optional) = number of hours to cap the timer at
@@ -291,30 +391,37 @@ class SubathonComponent(cmds.Component):
community = await self.bot.profiles.fetch_community(ctx.broadcaster) community = await self.bot.profiles.fetch_community(ctx.broadcaster)
cid = community.communityid cid = community.communityid
if (active := await self.get_active_subathon(cid)) is not None: if (active := await self.get_active_subathon(cid)) is not None:
await ctx.reply("There is already an active subathon running! Use !subathon stop to stop it!") await ctx.reply(
"There is already an active subathon running! Use !subathon stop to stop it!"
)
return return
initial_time = initial_hours * 60 * 60 initial_time = initial_hours * 60 * 60
timecap_seconds = timecap * 60 * 60 if timecap else None timecap_seconds = timecap * 60 * 60 if timecap else None
subdata = await Subathon.create( subdata = await Subathon.create(
name="Subathon",
communityid=cid, communityid=cid,
name=name,
initial_time=initial_time, initial_time=initial_time,
sub1_score=sub1, sub1_score=sub1,
sub2_score=sub2, sub2_score=sub2,
sub3_score=sub3, sub3_score=sub3,
bit_score=bit, bit_score=bit,
score_time=timescore, score_time=timescore,
timecap=timecap_seconds timecap=timecap_seconds,
) )
base_timer_url = self.bot.config.subathon['timer_url'] base_timer_url = self.bot.config.subathon["timer_url"]
timer_link = f"{base_timer_url}?community={cid}" timer_link = f"{base_timer_url}?community={cid}"
await ctx.reply(f"Setup your {name}! Use !subathon resume to get the timer running. Your timer link: {timer_link}") await ctx.reply(
f"Setup your subathon! "
f"Use {ctx.prefix}subathon resume to get the timer running. "
f"Use {ctx.prefix}subathon config to see and set options, including the name. "
f"Your timer link for OBS: {timer_link}"
)
active = ActiveSubathon(subdata, None) active = ActiveSubathon(subdata, None)
await self.dispatch_update(active) await self.dispatch_update(active)
# subathon stop # subathon stop
@group_subathon.command(name='stop') @group_subathon.command(name="stop")
@cmds.is_broadcaster() @cmds.is_broadcaster()
async def cmd_stop(self, ctx: cmds.Context): async def cmd_stop(self, ctx: cmds.Context):
community = await self.bot.profiles.fetch_community(ctx.broadcaster) community = await self.bot.profiles.fetch_community(ctx.broadcaster)
@@ -334,8 +441,21 @@ class SubathonComponent(cmds.Component):
else: else:
await ctx.reply("No active subathon to stop.") await ctx.reply("No active subathon to stop.")
# subathon pause # subathon link
@group_subathon.command(name='pause') @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() @cmds.is_moderator()
async def cmd_pause(self, ctx: cmds.Context): async def cmd_pause(self, ctx: cmds.Context):
community = await self.bot.profiles.fetch_community(ctx.broadcaster) community = await self.bot.profiles.fetch_community(ctx.broadcaster)
@@ -350,8 +470,8 @@ class SubathonComponent(cmds.Component):
else: else:
await ctx.reply("No active subathon to pause") await ctx.reply("No active subathon to pause")
# subathon resume # subathon resume
@group_subathon.command(name='resume') @group_subathon.command(name="resume")
@cmds.is_moderator() @cmds.is_moderator()
async def cmd_resume(self, ctx: cmds.Context): async def cmd_resume(self, ctx: cmds.Context):
community = await self.bot.profiles.fetch_community(ctx.broadcaster) community = await self.bot.profiles.fetch_community(ctx.broadcaster)
@@ -369,9 +489,11 @@ class SubathonComponent(cmds.Component):
await ctx.reply("No active subathon to resume") await ctx.reply("No active subathon to resume")
# subathon adjust # subathon adjust
@group_subathon.command(name='adjust') @group_subathon.command(name="adjust")
@cmds.is_moderator() @cmds.is_moderator()
async def cmd_subathon_adjust(self, ctx: Context, amount: int, *, user: Optional[twitchio.User] = None): async def cmd_subathon_adjust(
self, ctx: Context, amount: int, *, user: Optional[twitchio.User] = None
):
""" """
Directly add or remove points from the subathon. Directly add or remove points from the subathon.
If a user is provided, will adjust their contributed amount. If a user is provided, will adjust their contributed amount.
@@ -390,8 +512,8 @@ 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 user is not None: if user is not None:
profile = await self.bot.profiles.fetch_profile(user) profile = await self.bot.profiles.fetch_profile(user)
name = user.display_name or profile.nickname or 'Unknown' name = user.display_name or profile.nickname or "Unknown"
pid = profile.profileid pid = profile.profileid
else: else:
profile = None profile = None
name = None name = None
@@ -413,44 +535,234 @@ class SubathonComponent(cmds.Component):
else: else:
await ctx.reply("No active subathon to adjust") await ctx.reply("No active subathon to adjust")
# subathon config """
@group_subathon.command(name='config', aliases=('option',)) !subathon config
name="Birthday Subathon" ; cap=10 (hours) ; scores=5 10 20 0.1 (t1, t2, t3, and bit points) ; timescore=10 (seconds per point); Use !subathon config <option> [value] to see or set individual options
!subathon config name [value]
!subathon config cap [value]
!subathon config scores [value]
!subathon config timescore [value]
!subathon config name
Name of the subathon, used whenever the subathon is mentioned.
Accepts any string. Example: !subathon config name Birthday Subathon
!subathon config cap
Duration cap of the subathon, in hours, including the initial time.
The subathon may still be contributed to after this time, but contributions
will not raise the timer.
Accepts an integer, with 0 to unset. Example: !subathon config cap 10
!subathon config scores
The number of points each type of contribution (t1 sub, t2 sub, t3 sub, 1 bit)
will add to the subathon. Not retroactive. Accepts four floats.
Example: !subathon config scores 5 10 20 0.1
!subathon config timescore
The number of seconds each contributed point adds to the timer.
WARNING: This setting is retroactive and should generally not be changed.
Accepts an integer. Example: !subathon config timecap 10
"""
@group_subathon.group(name="config", aliases=("option",), invoke_fallback=True)
@cmds.is_moderator() @cmds.is_moderator()
async def cmd_subathon_config(self, ctx: Context, option: str, *, value: Optional[str] = None): async def subathon_config_grp(self, ctx: Context, *, args: Optional[str] = None):
community = await self.bot.profiles.fetch_community(ctx.broadcaster) community = await self.bot.profiles.fetch_community(ctx.broadcaster)
cid = community.communityid cid = community.communityid
if (active := await self.get_active_subathon(cid)) is not None: if (active := await self.get_active_subathon(cid)) is None:
if option.lower() == 'cap': await ctx.reply("No active subathon to configure!")
if value: return
# Set the timer cap if args:
if not value.isdigit(): await ctx.reply(
await ctx.reply("Timer cap must be an integer number of hours!") f"USAGE: {ctx.prefix}subathon config [option [value]]\n"
else: f"Use '{ctx.prefix}subathon config' to see available options and current values."
await active.subathondata.update(timecap=int(value * 60 * 60)) )
await self.dispatch_update(active) return
await ctx.reply(
f"The timer cap has been set to {value} hours."
)
else:
# Display the timer cap
cap = active.subathondata.timecap
if cap:
hours = cap / 3600
await ctx.reply(f"The timer cap is currently {hours} hours")
elif option.lower() == 'name':
if value:
await active.subathondata.update(name=value)
await self.dispatch_update(active)
await ctx.reply(f"Updated the subathon name to \"{value}\"")
else:
name = active.subathondata.name
await ctx.reply(f"This subathon is called \"{name}\"")
else:
await ctx.reply(
f"Unknown option {option}! Configurable options: 'name', 'cap'"
)
@group_subathon.command(name='leaderboard', aliases=('top', 'lb',)) 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): async def cmd_subathon_lb(self, ctx: Context):
""" """
Display top contributors by points contributed, up to 10. Display top contributors by points contributed, up to 10.
@@ -465,34 +777,36 @@ 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:
# Get totals for all contributors # Get totals for all contributors
query = self.data.subathon_contributions.select_where(subathon_id=active.subathondata.subathon_id) query = self.data.subathon_contributions.select_where(
query.join('user_profiles', using=('profileid',), join_type=JOINTYPE.LEFT) subathon_id=active.subathondata.subathon_id
query.select('subathon_id', 'profileid', total="SUM(score)") )
query.order_by('total', direction=ORDER.DESC) query.join("user_profiles", using=("profileid",), join_type=JOINTYPE.LEFT)
query.group_by('subathon_id', 'profileid') 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() query.with_no_adapter()
results = await query results = await query
parts = [] parts = []
caller_idx = None caller_idx = None
for i, row in enumerate(results): for i, row in enumerate(results):
if pid := row['profileid']: if pid := row["profileid"]:
profile = await self.bot.profiles.profiles.get_profile(pid) profile = await self.bot.profiles.profiles.get_profile(pid)
name = (profile.nickname if profile else None) or 'Unknown' name = (profile.nickname if profile else None) or "Unknown"
if pid == caller_pid: if pid == caller_pid:
caller_idx = i caller_idx = i
else: else:
name = 'Anonymous' name = "Anonymous"
score = row['total'] score = row["total"]
part = f"{name}: {score} points" part = f"{name}: {score} points"
parts.append(part) parts.append(part)
header = "" header = ""
leaderboard = ', '.join(parts[:10]) leaderboard = ", ".join(parts[:10])
footer = "" footer = ""
if len(parts) > 10: if len(parts) > 10:
header = f"{active.name} top 10 leaderboard: " header = f"{active.name} top 10 leaderboard: "
leaderboard = ', '.join(parts[:10]) leaderboard = ", ".join(parts[:10])
if caller_idx is not None and caller_idx >= 10: if caller_idx is not None and caller_idx >= 10:
caller_part = parts[caller_idx] caller_part = parts[caller_idx]
footer = f" ... {caller_part}" footer = f" ... {caller_part}"
@@ -507,7 +821,7 @@ class SubathonComponent(cmds.Component):
await ctx.reply("No active subathon to show leaderboard of!") await ctx.reply("No active subathon to show leaderboard of!")
# Subathon goals # Subathon goals
@cmds.group(name='goals', invoke_fallback=True) @cmds.group(name="goals", invoke_fallback=True)
async def group_goals(self, ctx: cmds.Context): async def group_goals(self, ctx: cmds.Context):
# List the goals # List the goals
community = await self.bot.profiles.fetch_community(ctx.broadcaster) community = await self.bot.profiles.fetch_community(ctx.broadcaster)
@@ -520,14 +834,14 @@ class SubathonComponent(cmds.Component):
goalstrs.append(line) goalstrs.append(line)
if goals: if goals:
text = ', '.join(goalstrs) text = ", ".join(goalstrs)
await ctx.reply(f"{active.name} Goals! -- {text}") await ctx.reply(f"{active.name} Goals! -- {text}")
else: else:
await ctx.reply("No goals have been configured!") await ctx.reply("No goals have been configured!")
else: else:
await ctx.reply("No active subathon running!") await ctx.reply("No active subathon running!")
@group_goals.command(name='remaining', aliases=('left',)) @group_goals.command(name="remaining", aliases=("left",))
async def cmd_goals_remaining(self, ctx: cmds.Context): async def cmd_goals_remaining(self, ctx: cmds.Context):
community = await self.bot.profiles.fetch_community(ctx.broadcaster) community = await self.bot.profiles.fetch_community(ctx.broadcaster)
cid = community.communityid cid = community.communityid
@@ -540,17 +854,17 @@ class SubathonComponent(cmds.Component):
line = f"{goal.required_score} points: {goal.description}" line = f"{goal.required_score} points: {goal.description}"
goalstrs.append(line) goalstrs.append(line)
if goalstrs if goalstrs:
text = ', '.join(goalstrs) text = ", ".join(goalstrs)
await ctx.reply(f"{active.name} Goals Remaining -- {text}") await ctx.reply(f"{active.name} Goals Remaining -- {text}")
elif goals: elif goals:
await ctx.reply(f"All goals completed, congratulations!") await ctx.reply("All goals completed, congratulations!")
else: else:
await ctx.reply("No goals have been configured!") await ctx.reply("No goals have been configured!")
else: else:
await ctx.reply("No active subathon running!") await ctx.reply("No active subathon running!")
@group_goals.command(name='add') @group_goals.command(name="add")
@cmds.is_moderator() @cmds.is_moderator()
async def cmd_add(self, ctx: cmds.Context, required: int, *, description: str): async def cmd_add(self, ctx: cmds.Context, required: int, *, description: str):
community = await self.bot.profiles.fetch_community(ctx.broadcaster) community = await self.bot.profiles.fetch_community(ctx.broadcaster)
@@ -565,8 +879,8 @@ class SubathonComponent(cmds.Component):
else: else:
await ctx.reply("No active subathon to add goal to!") await ctx.reply("No active subathon to add goal to!")
# remove # remove
@group_goals.command(name='remove', aliases=['del', 'delete', 'rm']) @group_goals.command(name="remove", aliases=["del", "delete", "rm"])
@cmds.is_moderator() @cmds.is_moderator()
async def cmd_goals_remove(self, ctx: cmds.Context, required: int): async def cmd_goals_remove(self, ctx: cmds.Context, required: int):
""" """
@@ -574,7 +888,7 @@ class SubathonComponent(cmds.Component):
Remove any goal(s) set at the given score. Remove any goal(s) set at the given score.
""" """
community = await self.bot.profiles.fetch_community(ctx.broadcaster) community = await self.bot.profiles.fetch_community(ctx.broadcaster)
cid = community.communityid cid = community.communityid
if (active := await self.get_active_subathon(cid)) is not None: if (active := await self.get_active_subathon(cid)) is not None:
results = await SubathonGoal.table.delete_where( results = await SubathonGoal.table.delete_where(
subathon_id=active.subathondata.subathon_id, subathon_id=active.subathondata.subathon_id,
@@ -584,4 +898,3 @@ class SubathonComponent(cmds.Component):
await ctx.reply(f"Score {required} goal(s) removed!") await ctx.reply(f"Score {required} goal(s) removed!")
else: else:
await ctx.reply(f"No goal set at {required} score to remove.") await ctx.reply(f"No goal set at {required} score to remove.")

View File

@@ -1,10 +1,18 @@
from asyncio import Event from asyncio import Event
from datetime import datetime, timedelta from datetime import datetime, timedelta
from data.queries import JOINTYPE
from utils.lib import utc_now, strfdelta from utils.lib import utc_now, strfdelta
from modules.profiles.profiles.profiles import ProfilesRegistry from modules.profiles.profiles.profiles import ProfilesRegistry
from . import logger from . import logger
from .data import SubathonData, Subathon, RunningSubathon, SubathonContribution, SubathonGoal from .data import (
SubathonData,
Subathon,
RunningSubathon,
SubathonContribution,
SubathonGoal,
)
class ActiveSubathon: class ActiveSubathon:
def __init__(self, subathondata: Subathon, runningdata: RunningSubathon | None): def __init__(self, subathondata: Subathon, runningdata: RunningSubathon | None):
@@ -17,7 +25,7 @@ class ActiveSubathon:
@property @property
def name(self): def name(self):
return self.subathondata.name or 'Subathon' return self.subathondata.name or "Subathon"
async def check_cap(self): async def check_cap(self):
if not (cap := self.subathondata.timecap): if not (cap := self.subathondata.timecap):
@@ -32,7 +40,7 @@ class ActiveSubathon:
""" """
Return True if the subathon duration has exceeded its earned time. Return True if the subathon duration has exceeded its earned time.
""" """
return (await self.get_duration() <= 0) return await self.get_remaining() <= 0
async def pause(self): async def pause(self):
""" """
@@ -53,10 +61,14 @@ class ActiveSubathon:
async def resume(self): async def resume(self):
if self.running: if self.running:
raise ValueError("This subathon is already running!") raise ValueError("This subathon is already running!")
self.runningdata = await RunningSubathon.create(subathon_id=self.subathondata.subathon_id) self.runningdata = await RunningSubathon.create(
subathon_id=self.subathondata.subathon_id
)
async def get_score(self) -> float: async def get_score(self) -> float:
rows = await SubathonContribution.fetch_where(subathon_id=self.subathondata.subathon_id) rows = await SubathonContribution.fetch_where(
subathon_id=self.subathondata.subathon_id
)
return sum(row.score for row in rows) return sum(row.score for row in rows)
def get_score_time(self, score: float) -> int: def get_score_time(self, score: float) -> int:
@@ -75,7 +87,7 @@ class ActiveSubathon:
# Add running duration if required # Add running duration if required
if self.runningdata: if self.runningdata:
now = utc_now() now = utc_now()
added = int( (now - self.runningdata.last_started).total_seconds() ) added = int((now - self.runningdata.last_started).total_seconds())
duration += added duration += added
earned = await self.get_earned() earned = await self.get_earned()
@@ -97,10 +109,10 @@ class ActiveSubathon:
async def get_remaining(self) -> int: async def get_remaining(self) -> int:
""" """
Number of seconds remaining on the subathon timer. Number of seconds remaining on the subathon timer.
Will be 0 if finished. Will be 0 or slightly negative if finished.
""" """
total_time = await self.get_earned() total_time = await self.get_earned()
return total_time - await self.get_duration() return total_time - await self.get_duration() - 1
async def get_ending(self): async def get_ending(self):
""" """
@@ -111,23 +123,110 @@ class ActiveSubathon:
remaining = await self.get_remaining() remaining = await self.get_remaining()
return now + timedelta(seconds=remaining) return now + timedelta(seconds=remaining)
async def add_contribution(self, profileid: int | None, score: float, event_id: int | None) -> SubathonContribution: async def add_contribution(
return await SubathonContribution.create( self, profileid: int | None, score: float, event_id: int | None
) -> SubathonContribution:
row = await SubathonContribution.create(
subathon_id=self.subathondata.subathon_id, subathon_id=self.subathondata.subathon_id,
profileid=profileid, score=score, event_id=event_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): def fetch_contributions(self, **kwargs):
query = SubathonContribution.fetch_where( query = SubathonContribution.fetch_where(
subathon_id=self.subathondata.subathon_id, subathon_id=self.subathondata.subathon_id, **kwargs
**kwargs ).order_by("created_at")
).order_by('created_at')
return query return query
async def get_goals(self) -> list[SubathonGoal]: async def get_goals(self) -> list[SubathonGoal]:
goals = await SubathonGoal.fetch_where(subathon_id=self.subathondata.subathon_id).order_by('required_score') goals = await SubathonGoal.fetch_where(
subathon_id=self.subathondata.subathon_id
).order_by("required_score")
return goals 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: class SubathonRegistry:
def __init__(self, data: SubathonData, profiler: ProfilesRegistry): def __init__(self, data: SubathonData, profiler: ProfilesRegistry):
@@ -144,5 +243,3 @@ class SubathonRegistry:
running = await RunningSubathon.fetch(subathondata.subathon_id) running = await RunningSubathon.fetch(subathondata.subathon_id)
subba = ActiveSubathon(subathondata, running) subba = ActiveSubathon(subathondata, running)
return subba return subba