Compare commits

...

7 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
3 changed files with 424 additions and 201 deletions

View File

@@ -1,15 +1,23 @@
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 .data import (
SubathonData,
Subathon,
RunningSubathon,
SubathonContribution,
SubathonGoal,
)
from .subathon import ActiveSubathon, SubathonRegistry
@@ -31,6 +39,12 @@ class ContributionPayload(TypedDict):
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
@@ -47,31 +61,44 @@ class SubathonPayload(TypedDict):
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:
now = utc_now()
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_goal = None
next_goal = None
last_goalp = None
next_goalp = None
goalps = []
total_goals = len(goals)
for goal in goals:
if goal.required_score >= total_score:
last_goal = goal
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
else:
next_goal = goal
break
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:
@@ -79,19 +106,20 @@ async def prepare_subathon(profiler: ProfilesRegistry, subathon: ActiveSubathon)
if contrib.profileid:
profile = await profiler.get_profile(contrib.profileid)
assert profile is not None
name = profile.nickname
user_id = None
name = profile.nickname or "Unknown"
user_id = str(profile.profileid)
else:
name = 'Anonymous'
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()
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),
@@ -99,41 +127,75 @@ async def prepare_subathon(profiler: ProfilesRegistry, subathon: ActiveSubathon)
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=GoalPayload(
required=float(last_goal.required_score),
name=last_goal.description,
) if last_goal is not None else None,
next_goal=GoalPayload(
required=float(next_goal.required_score),
name=next_goal.description,
) if next_goal is not None else None,
last_contribution=last_contrib
last_goal=last_goalp,
next_goal=next_goalp,
goals=goalps,
last_contribution=last_contrib,
leaderboard=leaderboard,
)
return payload
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)
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
self.communities = defaultdict(
set
) # Map of communityid -> listening websockets
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")
elif not cidstr.isdigit():
raise ValueError("Community id provided is not an integer")
@@ -157,35 +219,43 @@ class TimerChannel(Channel):
wss.discard(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]:
await self.send_event(
{
'type': "DO",
'method': "setTimer",
'args': payload,
"type": "DO",
"method": "setTimer",
"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]:
await self.send_event(
{
'type': "DO",
'method': "endTimer",
'args': payload,
"type": "DO",
"method": "endTimer",
"args": payload,
},
websocket=ws
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': {},
"type": "DO",
"method": "noTimer",
"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

@@ -13,7 +13,13 @@ 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 .data import (
SubathonData,
Subathon,
RunningSubathon,
SubathonContribution,
SubathonGoal,
)
from .subathon import ActiveSubathon, SubathonRegistry
from .channel import SubathonPayload, prepare_subathon, TimerChannel
@@ -49,6 +55,7 @@ class SubathonComponent(cmds.Component):
Description:
Examples:
"""
# TODO: Add explicit dependencies and version checks
# for profile and event tracker modules
@@ -58,7 +65,7 @@ class SubathonComponent(cmds.Component):
self.subathons = SubathonRegistry(self.data, bot.profiles.profiles)
self.channel = TimerChannel(self.bot.profiles.profiles, self.subathons)
register_channel('SubTimer', self.channel)
register_channel("SubTimer", self.channel)
# ----- API -----
async def component_load(self):
@@ -89,8 +96,8 @@ class SubathonComponent(cmds.Component):
# 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,
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)
@@ -98,20 +105,24 @@ class SubathonComponent(cmds.Component):
# Blerp handler
@cmds.Component.listener()
async def event_message(self, message: twitchio.ChatMessage):
if message.chatter.id not in ('253326823', '1361913054'):
if message.chatter.id not in ("253326823", "1361913054"):
return
if 'bits' not in message.text:
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():
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)
match = re.match(
r"!For (?P<amount>\d+) bits, (?P<name>\w+)", message.text
)
if match:
amount = int(match['amount'])
name = match['name']
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()
@@ -128,12 +139,15 @@ class SubathonComponent(cmds.Component):
@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():
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
await active.add_contribution(pid, score, event_row['event_id'])
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)
@@ -143,7 +157,7 @@ class SubathonComponent(cmds.Component):
else:
added = f"{sec} seconds"
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}"
if not await active.check_cap():
@@ -152,8 +166,7 @@ class SubathonComponent(cmds.Component):
contrib_str += " towards our subathon! Thank you <3"
await bits_payload.broadcaster.send_message(
contrib_str,
sender=self.bot.bot_id
contrib_str, sender=self.bot.bot_id
)
await self.dispatch_update(active)
await self.goalcheck(active, bits_payload.broadcaster)
@@ -166,10 +179,12 @@ class SubathonComponent(cmds.Component):
# Ignore gifted here
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
# In an active subathon
pid = event_row['profileid']
pid = event_row["profileid"]
tier = int(sub_payload.tier)
if tier == 1000:
@@ -183,13 +198,16 @@ class SubathonComponent(cmds.Component):
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
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 ''
pl = "s" if score > 1 else ""
contrib_str = f"{name} contributed {score} point{pl}"
if not await active.check_cap():
@@ -198,8 +216,7 @@ class SubathonComponent(cmds.Component):
contrib_str += " towards our subathon! Thank you <3"
await sub_payload.broadcaster.send_message(
contrib_str,
sender=self.bot.bot_id
contrib_str, sender=self.bot.bot_id
)
await self.dispatch_update(active)
# Check goals
@@ -209,10 +226,12 @@ class SubathonComponent(cmds.Component):
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():
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']
pid = event_row["profileid"]
tier = int(gift_payload.tier)
if tier == 1000:
@@ -226,12 +245,15 @@ class SubathonComponent(cmds.Component):
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
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'
name = gift_payload.user.name if gift_payload.user else "Anonymous"
contrib_str = f"{name} contributed {score} points"
if not await active.check_cap():
@@ -240,8 +262,7 @@ class SubathonComponent(cmds.Component):
contrib_str += " towards our subathon! Thank you <3"
await gift_payload.broadcaster.send_message(
contrib_str,
sender=self.bot.bot_id
contrib_str, sender=self.bot.bot_id
)
await self.dispatch_update(active)
# Check goals
@@ -251,10 +272,12 @@ class SubathonComponent(cmds.Component):
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():
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']
pid = event_row["profileid"]
tier = int(sub_payload.tier)
if tier == 1000:
@@ -268,13 +291,16 @@ class SubathonComponent(cmds.Component):
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
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 ''
pl = "s" if score > 1 else ""
contrib_str = f"{name} contributed {score} points{pl}"
if not await active.check_cap():
@@ -283,8 +309,7 @@ class SubathonComponent(cmds.Component):
contrib_str += " towards our subathon! Thank you <3"
await sub_payload.broadcaster.send_message(
contrib_str,
sender=self.bot.bot_id
contrib_str, sender=self.bot.bot_id
)
await self.dispatch_update(active)
# Check goals
@@ -297,17 +322,20 @@ class SubathonComponent(cmds.Component):
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
)
"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)
@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
@@ -325,13 +353,9 @@ class SubathonComponent(cmds.Component):
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"
)
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}!"
)
text = f"{active.name} completed after {duration} with a total of {score} points, and {goalstr}!"
await ctx.reply(text)
else:
@@ -339,9 +363,19 @@ class SubathonComponent(cmds.Component):
# subathon start
# TODO: Usage line on command error
@group_subathon.command(name='setup', alias='start')
@group_subathon.command(name="setup", alias="start")
@cmds.is_broadcaster()
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):
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]
@@ -357,7 +391,9 @@ class SubathonComponent(cmds.Component):
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!")
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
@@ -371,9 +407,9 @@ class SubathonComponent(cmds.Component):
sub3_score=sub3,
bit_score=bit,
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}"
await ctx.reply(
f"Setup your subathon! "
@@ -385,7 +421,7 @@ class SubathonComponent(cmds.Component):
await self.dispatch_update(active)
# subathon stop
@group_subathon.command(name='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)
@@ -406,22 +442,20 @@ class SubathonComponent(cmds.Component):
await ctx.reply("No active subathon to stop.")
# subathon link
@group_subathon.command(name='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']
base_timer_url = self.bot.config.subathon["timer_url"]
timer_link = f"{base_timer_url}?community={cid}"
await ctx.reply(
timer_link
)
await ctx.reply(timer_link)
else:
await ctx.reply("No active subathon to stop.")
# subathon pause
@group_subathon.command(name='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)
@@ -437,7 +471,7 @@ class SubathonComponent(cmds.Component):
await ctx.reply("No active subathon to pause")
# subathon resume
@group_subathon.command(name='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)
@@ -455,9 +489,11 @@ class SubathonComponent(cmds.Component):
await ctx.reply("No active subathon to resume")
# subathon adjust
@group_subathon.command(name='adjust')
@group_subathon.command(name="adjust")
@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.
If a user is provided, will adjust their contributed amount.
@@ -476,7 +512,7 @@ class SubathonComponent(cmds.Component):
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'
name = user.display_name or profile.nickname or "Unknown"
pid = profile.profileid
else:
profile = None
@@ -524,7 +560,8 @@ class SubathonComponent(cmds.Component):
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)
@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)
@@ -544,38 +581,35 @@ class SubathonComponent(cmds.Component):
sdata = active.subathondata
# name
parts.append(
f"name=\"{sdata.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)"
)
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)"
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)"
)
parts.append(f"timescore={sdata.score_time} (seconds per point)")
# Combine
partstr = ' ; '.join(parts)
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')
@subathon_config_grp.command(name="name")
@cmds.is_moderator()
async def subathon_config_name_cmd(self, ctx: Context, *, args: Optional[str] = None):
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:
@@ -586,20 +620,22 @@ class SubathonComponent(cmds.Component):
# Setting path
await active.subathondata.update(name=args)
await self.dispatch_update(active)
await ctx.reply(f"Updated the subathon name to \"{args}\"")
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'Currently: "{name}" '
f"Example: {ctx.prefix}subathon config name Birthday Subathon"
)
@subathon_config_grp.command(name='cap')
@subathon_config_grp.command(name="cap")
@cmds.is_moderator()
async def subathon_config_cap_cmd(self, ctx: Context, *, args: Optional[str] = None):
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:
@@ -608,11 +644,13 @@ class SubathonComponent(cmds.Component):
if args:
# Setting path
if args.lower() in ('none',):
args = '0'
if args.lower() in ("none",):
args = "0"
if not args.isdigit():
await ctx.reply("Provided timer cap must be an integer number of hours!")
await ctx.reply(
"Provided timer cap must be an integer number of hours!"
)
return
new_cap = int(args)
@@ -620,12 +658,16 @@ class SubathonComponent(cmds.Component):
# 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!")
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.")
await ctx.reply(
f"The subathon timer has been capped to {new_cap} hours."
)
else:
# Display path
current_cap = active.subathondata.timecap or 0
@@ -639,9 +681,11 @@ class SubathonComponent(cmds.Component):
f"Example: {ctx.prefix}subathon config cap 24"
)
@subathon_config_grp.command(name='scores')
@subathon_config_grp.command(name="scores")
@cmds.is_moderator()
async def subathon_config_scores_cmd(self, ctx: Context, *, args: Optional[str] = None):
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:
@@ -669,8 +713,11 @@ class SubathonComponent(cmds.Component):
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))
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) "
@@ -680,9 +727,11 @@ class SubathonComponent(cmds.Component):
f"Example: {ctx.prefix}subathon config scores 5 10 20 0.1"
)
@subathon_config_grp.command(name='timescore')
@subathon_config_grp.command(name="timescore")
@cmds.is_moderator()
async def subathon_config_timescore_cmd(self, ctx: Context, *, args: Optional[str] = None):
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:
@@ -707,7 +756,13 @@ class SubathonComponent(cmds.Component):
f"Example: {ctx.prefix}subathon config timescore 10"
)
@group_subathon.command(name='leaderboard', aliases=('top', 'lb',))
@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.
@@ -722,34 +777,36 @@ class SubathonComponent(cmds.Component):
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 = 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']:
if pid := row["profileid"]:
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:
caller_idx = i
else:
name = 'Anonymous'
score = row['total']
name = "Anonymous"
score = row["total"]
part = f"{name}: {score} points"
parts.append(part)
header = ""
leaderboard = ', '.join(parts[:10])
leaderboard = ", ".join(parts[:10])
footer = ""
if len(parts) > 10:
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:
caller_part = parts[caller_idx]
footer = f" ... {caller_part}"
@@ -764,7 +821,7 @@ class SubathonComponent(cmds.Component):
await ctx.reply("No active subathon to show leaderboard of!")
# Subathon goals
@cmds.group(name='goals', invoke_fallback=True)
@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)
@@ -777,14 +834,14 @@ class SubathonComponent(cmds.Component):
goalstrs.append(line)
if goals:
text = ', '.join(goalstrs)
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',))
@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
@@ -798,7 +855,7 @@ class SubathonComponent(cmds.Component):
goalstrs.append(line)
if goalstrs:
text = ', '.join(goalstrs)
text = ", ".join(goalstrs)
await ctx.reply(f"{active.name} Goals Remaining -- {text}")
elif goals:
await ctx.reply("All goals completed, congratulations!")
@@ -807,7 +864,7 @@ class SubathonComponent(cmds.Component):
else:
await ctx.reply("No active subathon running!")
@group_goals.command(name='add')
@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)
@@ -823,7 +880,7 @@ class SubathonComponent(cmds.Component):
await ctx.reply("No active subathon to add goal to!")
# remove
@group_goals.command(name='remove', aliases=['del', 'delete', 'rm'])
@group_goals.command(name="remove", aliases=["del", "delete", "rm"])
@cmds.is_moderator()
async def cmd_goals_remove(self, ctx: cmds.Context, required: int):
"""
@@ -841,4 +898,3 @@ class SubathonComponent(cmds.Component):
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,10 +1,18 @@
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
from .data import (
SubathonData,
Subathon,
RunningSubathon,
SubathonContribution,
SubathonGoal,
)
class ActiveSubathon:
def __init__(self, subathondata: Subathon, runningdata: RunningSubathon | None):
@@ -17,7 +25,7 @@ class ActiveSubathon:
@property
def name(self):
return self.subathondata.name or 'Subathon'
return self.subathondata.name or "Subathon"
async def check_cap(self):
if not (cap := self.subathondata.timecap):
@@ -32,7 +40,7 @@ class ActiveSubathon:
"""
Return True if the subathon duration has exceeded its earned time.
"""
return (await self.get_remaining() <= 0)
return await self.get_remaining() <= 0
async def pause(self):
"""
@@ -53,10 +61,14 @@ class ActiveSubathon:
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)
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)
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:
@@ -75,7 +87,7 @@ class ActiveSubathon:
# Add running duration if required
if self.runningdata:
now = utc_now()
added = int( (now - self.runningdata.last_started).total_seconds() )
added = int((now - self.runningdata.last_started).total_seconds())
duration += added
earned = await self.get_earned()
@@ -111,23 +123,110 @@ class ActiveSubathon:
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:
return await SubathonContribution.create(
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
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')
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')
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):
@@ -144,5 +243,3 @@ class SubathonRegistry:
running = await RunningSubathon.fetch(subathondata.subathon_id)
subba = ActiveSubathon(subathondata, running)
return subba