Compare commits

...

23 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
6 changed files with 1184 additions and 280 deletions

View File

@@ -6,9 +6,10 @@ 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),
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,
@@ -26,8 +27,8 @@ CREATE TABLE running_subathons(
CREATE TABLE subathon_contributions(
contribution_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
subathon_id INTEGER NOT NULL REFERENCES subathons(subathon_id),
profileid INTEGER REFERENCES user_profiles(profileid),
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()
@@ -35,7 +36,7 @@ CREATE TABLE subathon_contributions(
CREATE TABLE subathon_goals(
goal_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
subathon_id INTEGER NOT NULL REFERENCES subathons(subathon_id),
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,

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)

File diff suppressed because it is too large Load Diff

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()

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