Compare commits
31 Commits
65141312be
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 11c77bcc89 | |||
| 89f9ce3ffa | |||
| 21cf65398c | |||
| 84791e6b91 | |||
| 7a9a47f11f | |||
| 0c5250f09e | |||
| aa87fd6f9f | |||
| 32b84625df | |||
| a19bb8a8cb | |||
| 7853d0c115 | |||
| c495e2387e | |||
| 7a9045d3dc | |||
| 891e121e99 | |||
| 0c4b2bfe32 | |||
| a82b58b2c4 | |||
| 21b1f9c3ab | |||
| 033fc5c1ae | |||
| 899f5e7292 | |||
| a5dd5ce6ad | |||
| 27103366aa | |||
| b10098d28f | |||
| a3a3b36230 | |||
| 0934ee3af4 | |||
| 2c8bc4ae24 | |||
| 37ecb2d7ee | |||
| 18f57d1800 | |||
| 1544e9dbf9 | |||
| 0ea90b5cd2 | |||
| a7b38c20b2 | |||
| 4e9dbeee79 | |||
| d1abe6782e |
@@ -0,0 +1 @@
|
||||
from .subathon import setup as twitch_setup
|
||||
|
||||
46
data/subathon.sql
Normal file
46
data/subathon.sql
Normal file
@@ -0,0 +1,46 @@
|
||||
BEGIN;
|
||||
|
||||
|
||||
INSERT INTO version_history (component, from_version, to_version, author)
|
||||
VALUES ('SUBATHON', 0, 1, 'Initial Creation');
|
||||
|
||||
CREATE TABLE subathons(
|
||||
subathon_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
communityid INTEGER NOT NULL REFERENCES communities(communityid) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
initial_time INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
sub1_score NUMERIC NOT NULL DEFAULT 1,
|
||||
sub2_score NUMERIC NOT NULL DEFAULT 2,
|
||||
sub3_score NUMERIC NOT NULL DEFAULT 6,
|
||||
bit_score NUMERIC NOT NULL,
|
||||
score_time NUMERIC NOT NULL,
|
||||
duration INTEGER NOT NULL DEFAULT 0,
|
||||
timecap INTEGER,
|
||||
ended_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE running_subathons(
|
||||
subathon_id INTEGER PRIMARY KEY REFERENCES subathons(subathon_id),
|
||||
last_started TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE subathon_contributions(
|
||||
contribution_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
subathon_id INTEGER NOT NULL REFERENCES subathons(subathon_id) ON DELETE CASCADE,
|
||||
profileid INTEGER REFERENCES user_profiles(profileid) ON DELETE SET NULL,
|
||||
score NUMERIC NOT NULL,
|
||||
event_id INTEGER REFERENCES events(event_id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE subathon_goals(
|
||||
goal_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
subathon_id INTEGER NOT NULL REFERENCES subathons(subathon_id) ON DELETE CASCADE,
|
||||
required_score NUMERIC NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
notified BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
3
example-config/subathon.conf
Normal file
3
example-config/subathon.conf
Normal file
@@ -0,0 +1,3 @@
|
||||
[SUBATHON]
|
||||
timer_url = https://izashi.thewisewolf.dev/tracker/timer
|
||||
|
||||
261
subathon/channel.py
Normal file
261
subathon/channel.py
Normal file
@@ -0,0 +1,261 @@
|
||||
from typing import Optional, TypeAlias, TypedDict
|
||||
import json
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from dataclasses import dataclass
|
||||
|
||||
from data.queries import JOINTYPE, ORDER
|
||||
from meta.sockets import Channel
|
||||
from utils.lib import utc_now
|
||||
from modules.profiles.profiles.profiles import ProfilesRegistry
|
||||
|
||||
from . import logger
|
||||
from .data import (
|
||||
SubathonData,
|
||||
Subathon,
|
||||
RunningSubathon,
|
||||
SubathonContribution,
|
||||
SubathonGoal,
|
||||
)
|
||||
from .subathon import ActiveSubathon, SubathonRegistry
|
||||
|
||||
|
||||
# ISO formatted timestamp
|
||||
ISOTimestamp: TypeAlias = str
|
||||
|
||||
|
||||
class GoalPayload(TypedDict):
|
||||
required: float
|
||||
name: str
|
||||
|
||||
|
||||
class ContributionPayload(TypedDict):
|
||||
user_name: str
|
||||
user_id: str | None
|
||||
|
||||
amount: float
|
||||
seconds_added: int
|
||||
timestamp: ISOTimestamp
|
||||
|
||||
|
||||
class LeaderboardItemPayload(TypedDict):
|
||||
user_name: str
|
||||
user_id: str | None
|
||||
amount: float
|
||||
|
||||
|
||||
class ScoreTablePayload(TypedDict):
|
||||
bit_score: float # points per bit
|
||||
t1_score: float # points per T1 sub
|
||||
t2_score: float # points per T2 sub
|
||||
t3_score: float # points per T3 sub
|
||||
|
||||
score_time: float # seconds added per point
|
||||
|
||||
|
||||
class SubathonPayload(TypedDict):
|
||||
end_at: ISOTimestamp
|
||||
is_running: bool
|
||||
score_table: ScoreTablePayload
|
||||
name: str
|
||||
|
||||
total_contribution: float
|
||||
total_subpoints: int
|
||||
total_bits: int
|
||||
|
||||
goals_met: int
|
||||
goals_total: int
|
||||
last_goal: GoalPayload | None
|
||||
next_goal: GoalPayload | None
|
||||
goals: list[GoalPayload]
|
||||
|
||||
last_contribution: ContributionPayload | None
|
||||
leaderboard: list[LeaderboardItemPayload]
|
||||
|
||||
|
||||
async def prepare_subathon(
|
||||
profiler: ProfilesRegistry, subathon: ActiveSubathon
|
||||
) -> SubathonPayload:
|
||||
total_score = await subathon.get_score()
|
||||
|
||||
# Build the goal data
|
||||
goals = await subathon.get_goals()
|
||||
goals_met = 0
|
||||
last_goalp = None
|
||||
next_goalp = None
|
||||
goalps = []
|
||||
total_goals = len(goals)
|
||||
for goal in goals:
|
||||
goalp = GoalPayload(
|
||||
required=float(goal.required_score),
|
||||
name=goal.description,
|
||||
)
|
||||
goalps.append(goalp)
|
||||
if goal.required_score <= total_score:
|
||||
last_goalp = goalp
|
||||
goals_met += 1
|
||||
elif next_goalp is None:
|
||||
next_goalp = goalp
|
||||
|
||||
# Build the last contribution information
|
||||
contribs = await subathon.fetch_contributions().limit(1)
|
||||
last_contrib: ContributionPayload | None = None
|
||||
if contribs:
|
||||
contrib = contribs[0]
|
||||
if contrib.profileid:
|
||||
profile = await profiler.get_profile(contrib.profileid)
|
||||
assert profile is not None
|
||||
name = profile.nickname or "Unknown"
|
||||
user_id = str(profile.profileid)
|
||||
else:
|
||||
name = "Anonymous"
|
||||
user_id = None
|
||||
last_contrib = ContributionPayload(
|
||||
user_name=name,
|
||||
user_id=user_id,
|
||||
amount=float(contrib.score),
|
||||
seconds_added=subathon.get_score_time(contrib.score),
|
||||
timestamp=contrib.created_at.isoformat(),
|
||||
)
|
||||
|
||||
# Build the score table
|
||||
score_table = ScoreTablePayload(
|
||||
bit_score=float(subathon.subathondata.bit_score),
|
||||
t1_score=float(subathon.subathondata.sub1_score),
|
||||
t2_score=float(subathon.subathondata.sub2_score),
|
||||
t3_score=float(subathon.subathondata.sub3_score),
|
||||
score_time=int(subathon.subathondata.score_time),
|
||||
)
|
||||
|
||||
# Build the contribution leaderboard
|
||||
query = SubathonContribution.table.select_where(
|
||||
subathon_id=subathon.subathondata.subathon_id
|
||||
)
|
||||
query.join("user_profiles", using=("profileid",), join_type=JOINTYPE.LEFT)
|
||||
query.select("subathon_id", "profileid", total="SUM(score)")
|
||||
query.order_by("total", direction=ORDER.DESC)
|
||||
query.group_by("subathon_id", "profileid")
|
||||
query.with_no_adapter()
|
||||
results = await query
|
||||
|
||||
leaderboard = []
|
||||
for row in results:
|
||||
if pid := row["profileid"]:
|
||||
profile = await profiler.get_profile(pid)
|
||||
name = (profile.nickname if profile else None) or "Unknown"
|
||||
else:
|
||||
name = "Anonymous"
|
||||
score = row["total"]
|
||||
item = LeaderboardItemPayload(
|
||||
user_name=name,
|
||||
user_id=pid,
|
||||
amount=float(score),
|
||||
)
|
||||
leaderboard.append(item)
|
||||
|
||||
# Build the raw totals
|
||||
subpoints = await subathon.get_total_subs()
|
||||
bits = await subathon.get_total_bits()
|
||||
|
||||
# Finally, put together the payload
|
||||
payload = SubathonPayload(
|
||||
name=subathon.subathondata.name,
|
||||
end_at=(await subathon.get_ending()).isoformat(),
|
||||
is_running=(subathon.running),
|
||||
score_table=score_table,
|
||||
total_contribution=float(await subathon.get_score()),
|
||||
total_subpoints=subpoints,
|
||||
total_bits=bits,
|
||||
goals_met=goals_met,
|
||||
goals_total=total_goals,
|
||||
last_goal=last_goalp,
|
||||
next_goal=next_goalp,
|
||||
goals=goalps,
|
||||
last_contribution=last_contrib,
|
||||
leaderboard=leaderboard,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
class TimerChannel(Channel):
|
||||
name = "SubTimer"
|
||||
|
||||
def __init__(
|
||||
self, profiler: ProfilesRegistry, subathons: SubathonRegistry, **kwargs
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.profiler: ProfilesRegistry = profiler
|
||||
self.subathons: SubathonRegistry = subathons
|
||||
|
||||
# Map of communities to webhooks listening for this community
|
||||
self.communities = defaultdict(
|
||||
set
|
||||
) # Map of communityid -> listening websockets
|
||||
|
||||
async def on_connection(self, websocket, event):
|
||||
if not (cidstr := event.get("community")):
|
||||
raise ValueError("Subathon timer connection missing communityid")
|
||||
elif not cidstr.isdigit():
|
||||
raise ValueError("Community id provided is not an integer")
|
||||
cid = int(cidstr)
|
||||
community = await self.profiler.get_community(cid)
|
||||
if community is None:
|
||||
raise ValueError("Unknown community provided.")
|
||||
|
||||
await super().on_connection(websocket, event)
|
||||
self.communities[cid].add(websocket)
|
||||
|
||||
subathon = await self.subathons.get_active_subathon(cid)
|
||||
if subathon:
|
||||
payload = await prepare_subathon(self.profiler, subathon)
|
||||
await self.send_subathon_update(cid, payload, websocket)
|
||||
else:
|
||||
await self.send_no_subathon(cid, websocket)
|
||||
|
||||
async def del_connection(self, websocket):
|
||||
for wss in self.communities.values():
|
||||
wss.discard(websocket)
|
||||
await super().del_connection(websocket)
|
||||
|
||||
async def send_subathon_update(
|
||||
self, communityid: int, payload: SubathonPayload, websocket=None
|
||||
):
|
||||
for ws in (websocket,) if websocket else self.communities[communityid]:
|
||||
await self.send_event(
|
||||
{
|
||||
"type": "DO",
|
||||
"method": "setTimer",
|
||||
"args": payload,
|
||||
},
|
||||
websocket=ws,
|
||||
)
|
||||
|
||||
async def send_subathon_ended(
|
||||
self, communityid: int, payload: SubathonPayload, websocket=None
|
||||
):
|
||||
for ws in (websocket,) if websocket else self.communities[communityid]:
|
||||
await self.send_event(
|
||||
{
|
||||
"type": "DO",
|
||||
"method": "endTimer",
|
||||
"args": payload,
|
||||
},
|
||||
websocket=ws,
|
||||
)
|
||||
|
||||
async def send_no_subathon(self, communityid: int, websocket=None):
|
||||
for ws in (websocket,) if websocket else self.communities[communityid]:
|
||||
await self.send_event(
|
||||
{
|
||||
"type": "DO",
|
||||
"method": "noTimer",
|
||||
"args": {},
|
||||
},
|
||||
websocket=ws,
|
||||
)
|
||||
|
||||
async def send_event(self, event, **kwargs):
|
||||
logger.info(f"Sending websocket event: {json.dumps(event, indent=1)}")
|
||||
await super().send_event(event, **kwargs)
|
||||
900
subathon/component.py
Normal file
900
subathon/component.py
Normal file
@@ -0,0 +1,900 @@
|
||||
import asyncio
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
import twitchio
|
||||
from twitchio import PartialUser, Scopes, eventsub
|
||||
from twitchio.ext import commands as cmds
|
||||
|
||||
from data.queries import JOINTYPE, ORDER
|
||||
from meta import Bot, Context
|
||||
from meta.sockets import Channel, register_channel
|
||||
from utils.lib import utc_now, strfdelta
|
||||
|
||||
from . import logger
|
||||
from .data import (
|
||||
SubathonData,
|
||||
Subathon,
|
||||
RunningSubathon,
|
||||
SubathonContribution,
|
||||
SubathonGoal,
|
||||
)
|
||||
from .subathon import ActiveSubathon, SubathonRegistry
|
||||
from .channel import SubathonPayload, prepare_subathon, TimerChannel
|
||||
|
||||
|
||||
class SubathonComponent(cmds.Component):
|
||||
"""
|
||||
!subathon
|
||||
!subathon setup <initial_hours> <t1score> <t2score> <t3score> <bitscore> <timescore> [cap]
|
||||
!subathon stop
|
||||
|
||||
!subathon link
|
||||
!subathon pause
|
||||
!subathon resume
|
||||
|
||||
!subathon adjust <amount> [@user]
|
||||
|
||||
!subathon config
|
||||
!subathon config name [new name]
|
||||
!subathon config cap [new cap]
|
||||
!subathon config scores [<t1score> <t2score> <t3score> <bitscore>]
|
||||
!subathon config timescore [new score]
|
||||
|
||||
!subathon lb
|
||||
|
||||
!goals
|
||||
!goals remaining
|
||||
!goals add <points> <description>
|
||||
!goals remove <points>
|
||||
|
||||
|
||||
Command:
|
||||
Permissions:
|
||||
Description:
|
||||
Examples:
|
||||
"""
|
||||
|
||||
# TODO: Add explicit dependencies and version checks
|
||||
# for profile and event tracker modules
|
||||
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
self.data = bot.dbconn.load_registry(SubathonData())
|
||||
self.subathons = SubathonRegistry(self.data, bot.profiles.profiles)
|
||||
self.channel = TimerChannel(self.bot.profiles.profiles, self.subathons)
|
||||
|
||||
register_channel("SubTimer", self.channel)
|
||||
|
||||
# ----- API -----
|
||||
async def component_load(self):
|
||||
await self.data.init()
|
||||
await self.subathons.init()
|
||||
await self.bot.version_check(*self.data.VERSION)
|
||||
|
||||
async def component_teardown(self):
|
||||
pass
|
||||
|
||||
async def dispatch_update(self, subathon: ActiveSubathon):
|
||||
# TODO: Fix confusion of responsibility for preparation
|
||||
cid = subathon.subathondata.communityid
|
||||
payload = await prepare_subathon(self.bot.profiles.profiles, subathon)
|
||||
await self.channel.send_subathon_update(cid, payload)
|
||||
|
||||
# ----- Methods -----
|
||||
async def get_active_subathon(self, *args, **kwargs):
|
||||
return await self.subathons.get_active_subathon(*args, **kwargs)
|
||||
|
||||
async def goalcheck(self, active: ActiveSubathon, channel: PartialUser):
|
||||
goals = await active.get_goals()
|
||||
score = await active.get_score()
|
||||
for i, goal in enumerate(goals):
|
||||
if not goal.notified and goal.required_score <= score:
|
||||
# Goal completed, notify channel
|
||||
# TODO: Quick hack to avoid running into ratelimits
|
||||
# Should probably wait on a central messaging slow-lock bucketed by channel instead
|
||||
await asyncio.sleep(1)
|
||||
await channel.send_message(
|
||||
f"We have reached Goal #{i + 1}: {goal.description} !! Thank you everyone for your support <3",
|
||||
sender=self.bot.bot_id,
|
||||
)
|
||||
await goal.update(notified=True)
|
||||
|
||||
# ----- Event Handlers -----
|
||||
# Blerp handler
|
||||
@cmds.Component.listener()
|
||||
async def event_message(self, message: twitchio.ChatMessage):
|
||||
if message.chatter.id not in ("253326823", "1361913054"):
|
||||
return
|
||||
if "bits" not in message.text:
|
||||
return
|
||||
community = await self.bot.profiles.fetch_community(message.broadcaster)
|
||||
cid = community.communityid
|
||||
if (
|
||||
active := await self.get_active_subathon(cid)
|
||||
) is not None and not await active.check_finished():
|
||||
# Extract count and user
|
||||
match = re.match(r"(?P<name>\w+) used (?P<amount>\d+)", message.text)
|
||||
if not match:
|
||||
match = re.match(
|
||||
r"!For (?P<amount>\d+) bits, (?P<name>\w+)", message.text
|
||||
)
|
||||
if match:
|
||||
amount = int(match["amount"])
|
||||
name = match["name"]
|
||||
# This is for giving the contribution to the calling user
|
||||
# user = await self.bot.fetch_user(login=name.lower())
|
||||
user = await message.chatter.user()
|
||||
profile = await self.bot.profiles.fetch_profile(user)
|
||||
pid = profile.profileid
|
||||
|
||||
score = amount * active.subathondata.bit_score
|
||||
contrib = await active.add_contribution(pid, score, None)
|
||||
await self.dispatch_update(active)
|
||||
logger.info(f"Blerp contribution: {contrib!r} from {message!r}")
|
||||
else:
|
||||
logger.warning(f"Unknown blerp bit message: {message!r}")
|
||||
|
||||
@cmds.Component.listener()
|
||||
async def event_safe_bits_use(self, payload):
|
||||
event_row, detail_row, bits_payload = payload
|
||||
if (
|
||||
active := await self.get_active_subathon(event_row["communityid"])
|
||||
) is not None and not await active.check_finished():
|
||||
# In an active subathon
|
||||
pid = event_row["profileid"]
|
||||
uid = event_row["user_id"]
|
||||
score = detail_row["bits"] * active.subathondata.bit_score
|
||||
logger.info(f"Adding subathon contribution from bits payload {payload}")
|
||||
await active.add_contribution(pid, score, event_row["event_id"])
|
||||
|
||||
# Send message to channel
|
||||
sec = active.get_score_time(score)
|
||||
added_min = int(sec // 60)
|
||||
if added_min > 0:
|
||||
added = f"{added_min} minutes"
|
||||
else:
|
||||
added = f"{sec} seconds"
|
||||
name = bits_payload.user.name
|
||||
pl = "s" if bits_payload.bits != 1 else ""
|
||||
|
||||
contrib_str = f"{name} contributed {score} point{pl}"
|
||||
if not await active.check_cap():
|
||||
contrib_str += f" and added {added} to the timer! Thank you <3"
|
||||
else:
|
||||
contrib_str += " towards our subathon! Thank you <3"
|
||||
|
||||
await bits_payload.broadcaster.send_message(
|
||||
contrib_str, sender=self.bot.bot_id
|
||||
)
|
||||
await self.dispatch_update(active)
|
||||
await self.goalcheck(active, bits_payload.broadcaster)
|
||||
# Check goals
|
||||
|
||||
@cmds.Component.listener()
|
||||
async def event_safe_subscription(self, payload):
|
||||
event_row, detail_row, sub_payload = payload
|
||||
if sub_payload.gift:
|
||||
# Ignore gifted here
|
||||
return
|
||||
|
||||
if (
|
||||
active := await self.get_active_subathon(event_row["communityid"])
|
||||
) is not None and not await active.check_finished():
|
||||
data = active.subathondata
|
||||
# In an active subathon
|
||||
pid = event_row["profileid"]
|
||||
tier = int(sub_payload.tier)
|
||||
|
||||
if tier == 1000:
|
||||
mult = data.sub1_score
|
||||
elif tier == 2000:
|
||||
mult = data.sub2_score
|
||||
elif tier == 3000:
|
||||
mult = data.sub3_score
|
||||
else:
|
||||
raise ValueError(f"Unknown sub tier {sub_payload.tier}")
|
||||
|
||||
score = mult * 1
|
||||
|
||||
logger.info(
|
||||
f"Adding subathon contribution from subscription payload {payload}"
|
||||
)
|
||||
await active.add_contribution(pid, score, event_row["event_id"])
|
||||
|
||||
# Send message to channel
|
||||
added_min = int(active.get_score_time(score) // 60)
|
||||
added = f"{added_min} minutes"
|
||||
name = sub_payload.user.name
|
||||
pl = "s" if score > 1 else ""
|
||||
|
||||
contrib_str = f"{name} contributed {score} point{pl}"
|
||||
if not await active.check_cap():
|
||||
contrib_str += f" and added {added} to the timer! Thank you <3"
|
||||
else:
|
||||
contrib_str += " towards our subathon! Thank you <3"
|
||||
|
||||
await sub_payload.broadcaster.send_message(
|
||||
contrib_str, sender=self.bot.bot_id
|
||||
)
|
||||
await self.dispatch_update(active)
|
||||
# Check goals
|
||||
await self.goalcheck(active, sub_payload.broadcaster)
|
||||
|
||||
@cmds.Component.listener()
|
||||
async def event_safe_subscription_gift(self, payload):
|
||||
event_row, detail_row, gift_payload = payload
|
||||
|
||||
if (
|
||||
active := await self.get_active_subathon(event_row["communityid"])
|
||||
) is not None and not await active.check_finished():
|
||||
data = active.subathondata
|
||||
# In an active subathon
|
||||
pid = event_row["profileid"]
|
||||
|
||||
tier = int(gift_payload.tier)
|
||||
if tier == 1000:
|
||||
mult = data.sub1_score
|
||||
elif tier == 2000:
|
||||
mult = data.sub2_score
|
||||
elif tier == 3000:
|
||||
mult = data.sub3_score
|
||||
else:
|
||||
raise ValueError(f"Unknown sub tier {gift_payload.tier}")
|
||||
|
||||
score = mult * gift_payload.total
|
||||
|
||||
logger.info(
|
||||
f"Adding subathon contribution from subscription gift payload {payload}"
|
||||
)
|
||||
await active.add_contribution(pid, score, event_row["event_id"])
|
||||
|
||||
# Send message to channel
|
||||
added_min = int(active.get_score_time(score) // 60)
|
||||
added = f"{added_min} minutes"
|
||||
name = gift_payload.user.name if gift_payload.user else "Anonymous"
|
||||
|
||||
contrib_str = f"{name} contributed {score} points"
|
||||
if not await active.check_cap():
|
||||
contrib_str += f" and added {added} to the timer! Thank you <3"
|
||||
else:
|
||||
contrib_str += " towards our subathon! Thank you <3"
|
||||
|
||||
await gift_payload.broadcaster.send_message(
|
||||
contrib_str, sender=self.bot.bot_id
|
||||
)
|
||||
await self.dispatch_update(active)
|
||||
# Check goals
|
||||
await self.goalcheck(active, gift_payload.broadcaster)
|
||||
|
||||
@cmds.Component.listener()
|
||||
async def event_safe_subscription_message(self, payload):
|
||||
event_row, detail_row, sub_payload = payload
|
||||
|
||||
if (
|
||||
active := await self.get_active_subathon(event_row["communityid"])
|
||||
) is not None and not await active.check_finished():
|
||||
data = active.subathondata
|
||||
# In an active subathon
|
||||
pid = event_row["profileid"]
|
||||
tier = int(sub_payload.tier)
|
||||
|
||||
if tier == 1000:
|
||||
mult = data.sub1_score
|
||||
elif tier == 2000:
|
||||
mult = data.sub2_score
|
||||
elif tier == 3000:
|
||||
mult = data.sub3_score
|
||||
else:
|
||||
raise ValueError(f"Unknown sub tier {sub_payload.tier}")
|
||||
|
||||
score = mult * 1
|
||||
|
||||
logger.info(
|
||||
f"Adding subathon contribution from subscription message payload {payload}"
|
||||
)
|
||||
await active.add_contribution(pid, score, event_row["event_id"])
|
||||
|
||||
# Send message to channel
|
||||
added_min = int(active.get_score_time(score) // 60)
|
||||
added = f"{added_min} minutes"
|
||||
name = sub_payload.user.name
|
||||
pl = "s" if score > 1 else ""
|
||||
|
||||
contrib_str = f"{name} contributed {score} points{pl}"
|
||||
if not await active.check_cap():
|
||||
contrib_str += f" and added {added} to the timer! Thank you <3"
|
||||
else:
|
||||
contrib_str += " towards our subathon! Thank you <3"
|
||||
|
||||
await sub_payload.broadcaster.send_message(
|
||||
contrib_str, sender=self.bot.bot_id
|
||||
)
|
||||
await self.dispatch_update(active)
|
||||
# Check goals
|
||||
await self.goalcheck(active, sub_payload.broadcaster)
|
||||
|
||||
# end stream => Automatically pause the timer
|
||||
@cmds.Component.listener()
|
||||
async def event_stream_offline(self, payload: twitchio.StreamOffline):
|
||||
community = await self.bot.profiles.fetch_community(payload.broadcaster)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
if active.running:
|
||||
logger.info(
|
||||
f"Automatically paused suabathon {active.subathondata!r} from stream offline."
|
||||
)
|
||||
await active.pause()
|
||||
if not await active.check_finished():
|
||||
await payload.broadcaster.send_message(
|
||||
"Paused the subathon timer because the stream went offline!",
|
||||
sender=self.bot.bot_id,
|
||||
)
|
||||
await self.dispatch_update(active)
|
||||
|
||||
# ----- Commands -----
|
||||
|
||||
@cmds.group(name="subathon", aliases=["studython"], invoke_fallback=True)
|
||||
async def group_subathon(self, ctx: cmds.Context):
|
||||
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
score = await active.get_score()
|
||||
goals = await active.get_goals()
|
||||
total_goals = len(goals)
|
||||
donegoals = len([goal for goal in goals if score >= goal.required_score])
|
||||
goalstr = f"{donegoals}/{total_goals} goals achieved"
|
||||
|
||||
dursecs = await active.get_duration()
|
||||
duration = strfdelta(timedelta(seconds=dursecs))
|
||||
|
||||
secs = await active.get_remaining()
|
||||
if secs > 0:
|
||||
remaining = strfdelta(timedelta(seconds=secs))
|
||||
|
||||
text = f"{active.name} running for {duration}! {score} points recieved, {goalstr}, and {remaining} left on the timer"
|
||||
else:
|
||||
text = f"{active.name} completed after {duration} with a total of {score} points, and {goalstr}!"
|
||||
|
||||
await ctx.reply(text)
|
||||
else:
|
||||
await ctx.reply("No active subathon running!")
|
||||
|
||||
# subathon start
|
||||
# TODO: Usage line on command error
|
||||
@group_subathon.command(name="setup", alias="start")
|
||||
@cmds.is_broadcaster()
|
||||
async def cmd_setup(
|
||||
self,
|
||||
ctx: cmds.Context,
|
||||
initial_hours: float,
|
||||
sub1: float,
|
||||
sub2: float,
|
||||
sub3: float,
|
||||
bit: float,
|
||||
timescore: int,
|
||||
timecap: Optional[int] = None,
|
||||
):
|
||||
"""
|
||||
Creates a new subathon.
|
||||
USAGE: {prefix}subathon setup <initial_hours> <sub1_points> <sub2_points> <sub3_points> <bit_points> <timepoints> [timecap]
|
||||
Arguments:
|
||||
initial_hours = number of hours to start the timer with
|
||||
sub1_points = points per T1 sub
|
||||
sub2_points = points per T2 sub
|
||||
sub3_points = points per T3 sub
|
||||
bit_points = points per bit
|
||||
timepoints = seconds to be added to the timer per point
|
||||
timecap (optional) = number of hours to cap the timer at
|
||||
"""
|
||||
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
await ctx.reply(
|
||||
"There is already an active subathon running! Use !subathon stop to stop it!"
|
||||
)
|
||||
return
|
||||
initial_time = initial_hours * 60 * 60
|
||||
timecap_seconds = timecap * 60 * 60 if timecap else None
|
||||
|
||||
subdata = await Subathon.create(
|
||||
name="Subathon",
|
||||
communityid=cid,
|
||||
initial_time=initial_time,
|
||||
sub1_score=sub1,
|
||||
sub2_score=sub2,
|
||||
sub3_score=sub3,
|
||||
bit_score=bit,
|
||||
score_time=timescore,
|
||||
timecap=timecap_seconds,
|
||||
)
|
||||
base_timer_url = self.bot.config.subathon["timer_url"]
|
||||
timer_link = f"{base_timer_url}?community={cid}"
|
||||
await ctx.reply(
|
||||
f"Setup your subathon! "
|
||||
f"Use {ctx.prefix}subathon resume to get the timer running. "
|
||||
f"Use {ctx.prefix}subathon config to see and set options, including the name. "
|
||||
f"Your timer link for OBS: {timer_link}"
|
||||
)
|
||||
active = ActiveSubathon(subdata, None)
|
||||
await self.dispatch_update(active)
|
||||
|
||||
# subathon stop
|
||||
@group_subathon.command(name="stop")
|
||||
@cmds.is_broadcaster()
|
||||
async def cmd_stop(self, ctx: cmds.Context):
|
||||
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
if active.running:
|
||||
await active.pause()
|
||||
await self.dispatch_update(active)
|
||||
await active.subathondata.update(ended_at=utc_now())
|
||||
total = await active.get_score()
|
||||
dursecs = await active.get_duration()
|
||||
dur = strfdelta(timedelta(seconds=dursecs))
|
||||
|
||||
await ctx.reply(
|
||||
f"{active.name} ended after {dur} with a total of {total} points, congratulations!"
|
||||
)
|
||||
else:
|
||||
await ctx.reply("No active subathon to stop.")
|
||||
|
||||
# subathon link
|
||||
@group_subathon.command(name="link")
|
||||
@cmds.is_moderator()
|
||||
async def cmd_link(self, ctx: cmds.Context):
|
||||
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
base_timer_url = self.bot.config.subathon["timer_url"]
|
||||
timer_link = f"{base_timer_url}?community={cid}"
|
||||
await ctx.reply(timer_link)
|
||||
else:
|
||||
await ctx.reply("No active subathon to stop.")
|
||||
|
||||
# subathon pause
|
||||
@group_subathon.command(name="pause")
|
||||
@cmds.is_moderator()
|
||||
async def cmd_pause(self, ctx: cmds.Context):
|
||||
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
if active.running:
|
||||
await active.pause()
|
||||
await ctx.reply(f"{active.name} timer paused!")
|
||||
await self.dispatch_update(active)
|
||||
else:
|
||||
await ctx.reply(f"{active.name} timer already paused!")
|
||||
else:
|
||||
await ctx.reply("No active subathon to pause")
|
||||
|
||||
# subathon resume
|
||||
@group_subathon.command(name="resume")
|
||||
@cmds.is_moderator()
|
||||
async def cmd_resume(self, ctx: cmds.Context):
|
||||
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
if active.running:
|
||||
await ctx.reply(f"{active.name} timer already running!")
|
||||
elif await active.check_finished():
|
||||
await ctx.reply(f"{active.name} has already finished!")
|
||||
else:
|
||||
await active.resume()
|
||||
await ctx.reply(f"{active.name} timer resumed!")
|
||||
await self.dispatch_update(active)
|
||||
else:
|
||||
await ctx.reply("No active subathon to resume")
|
||||
|
||||
# subathon adjust
|
||||
@group_subathon.command(name="adjust")
|
||||
@cmds.is_moderator()
|
||||
async def cmd_subathon_adjust(
|
||||
self, ctx: Context, amount: int, *, user: Optional[twitchio.User] = None
|
||||
):
|
||||
"""
|
||||
Directly add or remove points from the subathon.
|
||||
If a user is provided, will adjust their contributed amount.
|
||||
USAGE:
|
||||
{prefix}subathon adjust <amount> [@user]
|
||||
Arguments:
|
||||
'amount' is an integer number of points to adjust by.
|
||||
It may be negative to remove points.
|
||||
'@user' is an optional user name or mention to adjust for.
|
||||
Examples:
|
||||
'{prefix}subathon adjust 10'
|
||||
'{prefix}subathon adjust -10 @machinestalkerwolfie'
|
||||
"""
|
||||
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
if user is not None:
|
||||
profile = await self.bot.profiles.fetch_profile(user)
|
||||
name = user.display_name or profile.nickname or "Unknown"
|
||||
pid = profile.profileid
|
||||
else:
|
||||
profile = None
|
||||
name = None
|
||||
pid = None
|
||||
await active.add_contribution(pid, amount, None)
|
||||
await self.dispatch_update(active)
|
||||
|
||||
# Build message
|
||||
if amount > 0:
|
||||
amountstr = f"Added {amount} point(s) to the timer"
|
||||
elif amount < 0:
|
||||
amountstr = f"Removed {-amount} point(s) from the timer"
|
||||
else:
|
||||
amountstr = "Did nothing to the timer"
|
||||
|
||||
namestr = f"on behalf of {name}" if profile else ""
|
||||
message = f"{amountstr} {namestr}"
|
||||
await ctx.reply(message)
|
||||
else:
|
||||
await ctx.reply("No active subathon to adjust")
|
||||
|
||||
"""
|
||||
!subathon config
|
||||
name="Birthday Subathon" ; cap=10 (hours) ; scores=5 10 20 0.1 (t1, t2, t3, and bit points) ; timescore=10 (seconds per point); Use !subathon config <option> [value] to see or set individual options
|
||||
!subathon config name [value]
|
||||
!subathon config cap [value]
|
||||
!subathon config scores [value]
|
||||
!subathon config timescore [value]
|
||||
|
||||
!subathon config name
|
||||
Name of the subathon, used whenever the subathon is mentioned.
|
||||
Accepts any string. Example: !subathon config name Birthday Subathon
|
||||
!subathon config cap
|
||||
Duration cap of the subathon, in hours, including the initial time.
|
||||
The subathon may still be contributed to after this time, but contributions
|
||||
will not raise the timer.
|
||||
Accepts an integer, with 0 to unset. Example: !subathon config cap 10
|
||||
!subathon config scores
|
||||
The number of points each type of contribution (t1 sub, t2 sub, t3 sub, 1 bit)
|
||||
will add to the subathon. Not retroactive. Accepts four floats.
|
||||
Example: !subathon config scores 5 10 20 0.1
|
||||
!subathon config timescore
|
||||
The number of seconds each contributed point adds to the timer.
|
||||
WARNING: This setting is retroactive and should generally not be changed.
|
||||
Accepts an integer. Example: !subathon config timecap 10
|
||||
"""
|
||||
|
||||
@group_subathon.group(name="config", aliases=("option",), invoke_fallback=True)
|
||||
@cmds.is_moderator()
|
||||
async def subathon_config_grp(self, ctx: Context, *, args: Optional[str] = None):
|
||||
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is None:
|
||||
await ctx.reply("No active subathon to configure!")
|
||||
return
|
||||
if args:
|
||||
await ctx.reply(
|
||||
f"USAGE: {ctx.prefix}subathon config [option [value]]\n"
|
||||
f"Use '{ctx.prefix}subathon config' to see available options and current values."
|
||||
)
|
||||
return
|
||||
|
||||
parts = []
|
||||
|
||||
sdata = active.subathondata
|
||||
|
||||
# name
|
||||
parts.append(f'name="{sdata.name}"')
|
||||
|
||||
# timecamp
|
||||
cap = sdata.timecap or 0
|
||||
caph = int(cap / 3600)
|
||||
parts.append(f"cap={caph} (hours, 0 means no cap)")
|
||||
|
||||
# scores
|
||||
scores = map(
|
||||
float,
|
||||
(sdata.sub1_score, sdata.sub2_score, sdata.sub3_score, sdata.bit_score),
|
||||
)
|
||||
scorestr = " ".join(map(str, scores))
|
||||
parts.append(f"scores={scorestr} (t1, t2, t3, and 1 bit point scores)")
|
||||
|
||||
# timescore
|
||||
parts.append(f"timescore={sdata.score_time} (seconds per point)")
|
||||
|
||||
# Combine
|
||||
partstr = " ; ".join(parts)
|
||||
await ctx.reply(
|
||||
f"{partstr} ; Use {ctx.prefix}subathon config <option> [value] to see or set each option!"
|
||||
)
|
||||
|
||||
@subathon_config_grp.command(name="name")
|
||||
@cmds.is_moderator()
|
||||
async def subathon_config_name_cmd(
|
||||
self, ctx: Context, *, args: Optional[str] = None
|
||||
):
|
||||
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is None:
|
||||
await ctx.reply("No active subathon to configure!")
|
||||
return
|
||||
|
||||
if args:
|
||||
# Setting path
|
||||
await active.subathondata.update(name=args)
|
||||
await self.dispatch_update(active)
|
||||
await ctx.reply(f'Updated the subathon name to "{args}"')
|
||||
else:
|
||||
# Display path
|
||||
name = active.subathondata.name
|
||||
await ctx.reply(
|
||||
"Name of the subathon, used whenever the subathon is mentioned. "
|
||||
"Accepts any string. "
|
||||
f'Currently: "{name}" '
|
||||
f"Example: {ctx.prefix}subathon config name Birthday Subathon"
|
||||
)
|
||||
|
||||
@subathon_config_grp.command(name="cap")
|
||||
@cmds.is_moderator()
|
||||
async def subathon_config_cap_cmd(
|
||||
self, ctx: Context, *, args: Optional[str] = None
|
||||
):
|
||||
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is None:
|
||||
await ctx.reply("No active subathon to configure!")
|
||||
return
|
||||
|
||||
if args:
|
||||
# Setting path
|
||||
if args.lower() in ("none",):
|
||||
args = "0"
|
||||
|
||||
if not args.isdigit():
|
||||
await ctx.reply(
|
||||
"Provided timer cap must be an integer number of hours!"
|
||||
)
|
||||
return
|
||||
|
||||
new_cap = int(args)
|
||||
if new_cap <= 0:
|
||||
# Unset the cap
|
||||
await active.subathondata.update(timecap=None)
|
||||
await self.dispatch_update(active)
|
||||
await ctx.reply(
|
||||
"The timer cap has been removed! To infinity and beyond!"
|
||||
)
|
||||
else:
|
||||
# Set the cap
|
||||
await active.subathondata.update(timecap=int(new_cap * 3600))
|
||||
await self.dispatch_update(active)
|
||||
await ctx.reply(
|
||||
f"The subathon timer has been capped to {new_cap} hours."
|
||||
)
|
||||
else:
|
||||
# Display path
|
||||
current_cap = active.subathondata.timecap or 0
|
||||
caph = int(current_cap / 3600)
|
||||
await ctx.reply(
|
||||
"Duration cap for this subathon, in hours, including the initial time. "
|
||||
"Contributions given after the cap has been reached will be accepted, "
|
||||
"but will not raise the timer. "
|
||||
"Accepts an integer, with 0 meaning no cap. "
|
||||
f"Currently: {caph} hours. "
|
||||
f"Example: {ctx.prefix}subathon config cap 24"
|
||||
)
|
||||
|
||||
@subathon_config_grp.command(name="scores")
|
||||
@cmds.is_moderator()
|
||||
async def subathon_config_scores_cmd(
|
||||
self, ctx: Context, *, args: Optional[str] = None
|
||||
):
|
||||
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is None:
|
||||
await ctx.reply("No active subathon to configure!")
|
||||
return
|
||||
|
||||
if args:
|
||||
# Setting path
|
||||
# Validate
|
||||
splits = args.split()
|
||||
if not len(splits) == 4 and all(split.isdecimal() for split in splits):
|
||||
await ctx.reply(
|
||||
f"USAGE: {ctx.prefix}subathon config socres [<t1score> <t2score> <t3score> <bitscore>]"
|
||||
)
|
||||
return
|
||||
t1score, t2score, t3score, bitscore = map(float, splits)
|
||||
await active.subathondata.update(
|
||||
sub1_score=t1score,
|
||||
sub2_score=t2score,
|
||||
sub3_score=t3score,
|
||||
bit_score=bitscore,
|
||||
)
|
||||
await self.dispatch_update(active)
|
||||
await ctx.reply("Successfully updated subathon score table.")
|
||||
else:
|
||||
# Display path
|
||||
sdata = active.subathondata
|
||||
scores = map(
|
||||
float,
|
||||
(sdata.sub1_score, sdata.sub2_score, sdata.sub3_score, sdata.bit_score),
|
||||
)
|
||||
scorestr = " ".join(map(str, scores))
|
||||
|
||||
await ctx.reply(
|
||||
"The number of points each type of contribution (t1 sub, t2 sub, t3 sub, and 1 bit) "
|
||||
"will add to the subathon. Not retroactive. "
|
||||
"Accepts four floats. "
|
||||
f"Currently: {scorestr} "
|
||||
f"Example: {ctx.prefix}subathon config scores 5 10 20 0.1"
|
||||
)
|
||||
|
||||
@subathon_config_grp.command(name="timescore")
|
||||
@cmds.is_moderator()
|
||||
async def subathon_config_timescore_cmd(
|
||||
self, ctx: Context, *, args: Optional[str] = None
|
||||
):
|
||||
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is None:
|
||||
await ctx.reply("No active subathon to configure!")
|
||||
return
|
||||
|
||||
if args:
|
||||
# Setting path
|
||||
if not args.isdigit():
|
||||
await ctx.reply("Time score (seconds per point) must be an integer.")
|
||||
return
|
||||
await active.subathondata.update(score_time=int(args))
|
||||
await self.dispatch_update(active)
|
||||
await ctx.reply("Subathon time score updated (NOTE: This is retroactive).")
|
||||
else:
|
||||
# Display path
|
||||
await ctx.reply(
|
||||
"The number of seconds each contributed point adds to the timer. "
|
||||
"WARNING: This setting is retroactive and should generally not be used. "
|
||||
"Accepts an integer. "
|
||||
f"Currently: {active.subathondata.score_time} (seconds per point) "
|
||||
f"Example: {ctx.prefix}subathon config timescore 10"
|
||||
)
|
||||
|
||||
@group_subathon.command(
|
||||
name="leaderboard",
|
||||
aliases=(
|
||||
"top",
|
||||
"lb",
|
||||
),
|
||||
)
|
||||
async def cmd_subathon_lb(self, ctx: Context):
|
||||
"""
|
||||
Display top contributors by points contributed, up to 10.
|
||||
Anonymous contributions are gathered as one user.
|
||||
"""
|
||||
# TODO: Might want to extend stats to select/show last subathon as well
|
||||
# IDEA: We could also offer full export as csv via web
|
||||
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = community.communityid
|
||||
caller_profile = await self.bot.profiles.fetch_profile(ctx.chatter)
|
||||
caller_pid = caller_profile.profileid
|
||||
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
# Get totals for all contributors
|
||||
query = self.data.subathon_contributions.select_where(
|
||||
subathon_id=active.subathondata.subathon_id
|
||||
)
|
||||
query.join("user_profiles", using=("profileid",), join_type=JOINTYPE.LEFT)
|
||||
query.select("subathon_id", "profileid", total="SUM(score)")
|
||||
query.order_by("total", direction=ORDER.DESC)
|
||||
query.group_by("subathon_id", "profileid")
|
||||
query.with_no_adapter()
|
||||
results = await query
|
||||
|
||||
parts = []
|
||||
caller_idx = None
|
||||
for i, row in enumerate(results):
|
||||
if pid := row["profileid"]:
|
||||
profile = await self.bot.profiles.profiles.get_profile(pid)
|
||||
name = (profile.nickname if profile else None) or "Unknown"
|
||||
if pid == caller_pid:
|
||||
caller_idx = i
|
||||
else:
|
||||
name = "Anonymous"
|
||||
score = row["total"]
|
||||
part = f"{name}: {score} points"
|
||||
parts.append(part)
|
||||
|
||||
header = ""
|
||||
leaderboard = ", ".join(parts[:10])
|
||||
footer = ""
|
||||
if len(parts) > 10:
|
||||
header = f"{active.name} top 10 leaderboard: "
|
||||
leaderboard = ", ".join(parts[:10])
|
||||
if caller_idx is not None and caller_idx >= 10:
|
||||
caller_part = parts[caller_idx]
|
||||
footer = f" ... {caller_part}"
|
||||
elif parts:
|
||||
header = f"{active.name} contribution leaderboard: "
|
||||
else:
|
||||
header = "No contributions to show yet!"
|
||||
|
||||
message = f"{header} {leaderboard} {footer}"
|
||||
await ctx.reply(message)
|
||||
else:
|
||||
await ctx.reply("No active subathon to show leaderboard of!")
|
||||
|
||||
# Subathon goals
|
||||
@cmds.group(name="goals", invoke_fallback=True)
|
||||
async def group_goals(self, ctx: cmds.Context):
|
||||
# List the goals
|
||||
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
goals = await active.get_goals()
|
||||
goalstrs = []
|
||||
for i, goal in enumerate(goals, start=1):
|
||||
line = f"{goal.required_score} points: {goal.description}"
|
||||
goalstrs.append(line)
|
||||
|
||||
if goals:
|
||||
text = ", ".join(goalstrs)
|
||||
await ctx.reply(f"{active.name} Goals! -- {text}")
|
||||
else:
|
||||
await ctx.reply("No goals have been configured!")
|
||||
else:
|
||||
await ctx.reply("No active subathon running!")
|
||||
|
||||
@group_goals.command(name="remaining", aliases=("left",))
|
||||
async def cmd_goals_remaining(self, ctx: cmds.Context):
|
||||
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
score = await active.get_score()
|
||||
goals = await active.get_goals()
|
||||
goalstrs = []
|
||||
for i, goal in enumerate(goals, start=1):
|
||||
if goal.required_score > score:
|
||||
line = f"{goal.required_score} points: {goal.description}"
|
||||
goalstrs.append(line)
|
||||
|
||||
if goalstrs:
|
||||
text = ", ".join(goalstrs)
|
||||
await ctx.reply(f"{active.name} Goals Remaining -- {text}")
|
||||
elif goals:
|
||||
await ctx.reply("All goals completed, congratulations!")
|
||||
else:
|
||||
await ctx.reply("No goals have been configured!")
|
||||
else:
|
||||
await ctx.reply("No active subathon running!")
|
||||
|
||||
@group_goals.command(name="add")
|
||||
@cmds.is_moderator()
|
||||
async def cmd_add(self, ctx: cmds.Context, required: int, *, description: str):
|
||||
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
await SubathonGoal.create(
|
||||
subathon_id=active.subathondata.subathon_id,
|
||||
required_score=required,
|
||||
description=description,
|
||||
)
|
||||
await ctx.reply("Goal added!")
|
||||
else:
|
||||
await ctx.reply("No active subathon to add goal to!")
|
||||
|
||||
# remove
|
||||
@group_goals.command(name="remove", aliases=["del", "delete", "rm"])
|
||||
@cmds.is_moderator()
|
||||
async def cmd_goals_remove(self, ctx: cmds.Context, required: int):
|
||||
"""
|
||||
Description:
|
||||
Remove any goal(s) set at the given score.
|
||||
"""
|
||||
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
results = await SubathonGoal.table.delete_where(
|
||||
subathon_id=active.subathondata.subathon_id,
|
||||
required_score=required,
|
||||
)
|
||||
if results:
|
||||
await ctx.reply(f"Score {required} goal(s) removed!")
|
||||
else:
|
||||
await ctx.reply(f"No goal set at {required} score to remove.")
|
||||
@@ -1,8 +1,6 @@
|
||||
from data import Registry, RowModel, Table
|
||||
from data.columns import String, Timestamp, Integer, Bool
|
||||
|
||||
# User contributed {} subs and added {}:{} to the timer! Thank you :love:
|
||||
# We have reached goal #5: Karaoke !! Thank you everyone for your support <3
|
||||
|
||||
class Subathon(RowModel):
|
||||
_tablename_ = 'subathons'
|
||||
@@ -11,6 +9,7 @@ class Subathon(RowModel):
|
||||
subathon_id = Integer(primary=True)
|
||||
communityid = Integer()
|
||||
started_at = Timestamp()
|
||||
name = String()
|
||||
initial_time = Integer() # Initial number of seconds
|
||||
sub1_score = Integer()
|
||||
sub2_score = Integer()
|
||||
@@ -37,7 +36,7 @@ class SubathonContribution(RowModel):
|
||||
profileid = Integer()
|
||||
score = Integer()
|
||||
event_id = Integer()
|
||||
# TODO: Should add a created timestamp here, since not all contributions have event ids
|
||||
created_at = Timestamp()
|
||||
|
||||
class SubathonGoal(RowModel):
|
||||
_tablename_ = 'subathon_goals'
|
||||
@@ -52,6 +51,8 @@ class SubathonGoal(RowModel):
|
||||
|
||||
|
||||
class SubathonData(Registry):
|
||||
VERSION = ('SUBATHON', 1)
|
||||
|
||||
subathons = Subathon.table
|
||||
running_subathons = RunningSubathon.table
|
||||
subathon_contributions = SubathonContribution.table
|
||||
245
subathon/subathon.py
Normal file
245
subathon/subathon.py
Normal 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
|
||||
@@ -1,493 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
import random
|
||||
import twitchio
|
||||
from twitchio import PartialUser, Scopes, eventsub
|
||||
from twitchio.ext import commands as cmds
|
||||
|
||||
from datamodels import BotChannel, Communities, UserProfile
|
||||
from meta import CrocBot
|
||||
from utils.lib import utc_now, strfdelta
|
||||
from sockets import Channel, register_channel
|
||||
|
||||
from . import logger
|
||||
from .data import SubathonData, Subathon, RunningSubathon, SubathonContribution, SubathonGoal
|
||||
|
||||
|
||||
class TimerChannel(Channel):
|
||||
name = 'Timer'
|
||||
|
||||
def __init__(self, cog: 'SubathonComponent', **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.cog = cog
|
||||
|
||||
self.communityid = 1
|
||||
|
||||
async def on_connection(self, websocket, event):
|
||||
await super().on_connection(websocket, event)
|
||||
await self.send_set(
|
||||
**await self.get_args_for(self.communityid),
|
||||
websocket=websocket,
|
||||
)
|
||||
|
||||
async def send_updates(self):
|
||||
await self.send_set(
|
||||
**await self.get_args_for(self.communityid),
|
||||
)
|
||||
|
||||
async def get_args_for(self, channelid):
|
||||
active = await self.cog.get_active_subathon(channelid)
|
||||
if active is not None:
|
||||
ending = utc_now() + timedelta(seconds=await active.get_remaining())
|
||||
return {
|
||||
'end_at': ending,
|
||||
'running': active.running
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'end_at': utc_now,
|
||||
'running': False,
|
||||
}
|
||||
|
||||
async def send_set(self, end_at, running, websocket=None):
|
||||
await self.send_event({
|
||||
'type': "DO",
|
||||
'method': 'setTimer',
|
||||
'args': {
|
||||
'end_at': end_at.isoformat(),
|
||||
'running': running,
|
||||
}
|
||||
}, websocket=websocket)
|
||||
|
||||
|
||||
class ActiveSubathon:
|
||||
def __init__(self, subathondata: Subathon, runningdata: RunningSubathon | None):
|
||||
self.subathondata = subathondata
|
||||
self.runningdata = runningdata
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
return self.runningdata is not None
|
||||
|
||||
async def check_cap(self):
|
||||
if not (cap := self.subathondata.timecap):
|
||||
return False
|
||||
else:
|
||||
score = await self.get_score()
|
||||
time_earned = self.get_score_time(score)
|
||||
total_time = self.subathondata.initial_time + time_earned
|
||||
return total_time >= cap
|
||||
|
||||
async def pause(self):
|
||||
if not self.running:
|
||||
raise ValueError("This subathon is not running!")
|
||||
assert self.runningdata is not None
|
||||
new_duration = self.get_duration()
|
||||
await self.subathondata.update(duration=new_duration)
|
||||
await self.runningdata.delete()
|
||||
|
||||
async def resume(self):
|
||||
if self.running:
|
||||
raise ValueError("This subathon is already running!")
|
||||
self.runningdata = await RunningSubathon.create(subathon_id=self.subathondata.subathon_id)
|
||||
|
||||
async def get_score(self) -> float:
|
||||
rows = await SubathonContribution.fetch_where(subathon_id=self.subathondata.subathon_id)
|
||||
return sum(row.score for row in rows)
|
||||
|
||||
def get_score_time(self, score: float) -> int:
|
||||
# Get time contributed by this score
|
||||
return int(score * self.subathondata.score_time)
|
||||
|
||||
def get_duration(self) -> int:
|
||||
# Get the duration of this subathon so far
|
||||
duration = self.subathondata.duration
|
||||
if self.runningdata:
|
||||
now = utc_now()
|
||||
added = int( (now - self.runningdata.last_started).total_seconds() )
|
||||
duration += added
|
||||
return duration
|
||||
|
||||
async def get_remaining(self) -> int:
|
||||
# Get the remaining time
|
||||
score = await self.get_score()
|
||||
time_earned = self.get_score_time(score)
|
||||
total_time = self.subathondata.initial_time + time_earned
|
||||
if cap := self.subathondata.timecap:
|
||||
total_time = min(total_time, cap)
|
||||
|
||||
return total_time - self.get_duration()
|
||||
|
||||
async def add_contribution(self, profileid: int | None, score: float, event_id: int | None) -> SubathonContribution:
|
||||
return await SubathonContribution.create(
|
||||
subathon_id=self.subathondata.subathon_id,
|
||||
profileid=profileid, score=score, event_id=event_id
|
||||
)
|
||||
|
||||
async def get_goals(self) -> list[SubathonGoal]:
|
||||
goals = await SubathonGoal.fetch_where(subathon_id=self.subathondata.subathon_id).order_by('required_score')
|
||||
return goals
|
||||
|
||||
|
||||
class SubathonComponent(cmds.Component):
|
||||
def __init__(self, bot: CrocBot):
|
||||
self.bot = bot
|
||||
self.data = bot.dbconn.load_registry(SubathonData())
|
||||
self.channel = TimerChannel(self)
|
||||
register_channel('SubTimer', self.channel)
|
||||
|
||||
|
||||
# ----- API -----
|
||||
async def component_load(self):
|
||||
# TODO: Setup the websocket
|
||||
await self.data.init()
|
||||
|
||||
async def component_teardown(self):
|
||||
pass
|
||||
|
||||
# ----- Methods -----
|
||||
async def get_community(self, twitchid: str, name: str | None) -> Communities:
|
||||
return await self.bot.community_fetch(twitchid=twitchid, name=name)
|
||||
|
||||
async def get_profile(self, twitchid: str, name: str | None) -> UserProfile:
|
||||
return await self.bot.profile_fetch(twitchid=twitchid, name=name)
|
||||
|
||||
async def get_active_subathon(self, communityid: int) -> ActiveSubathon | None:
|
||||
rows = await Subathon.fetch_where(communityid=communityid, ended_at=None)
|
||||
if rows:
|
||||
subathondata = rows[0]
|
||||
running = await RunningSubathon.fetch(subathondata.subathon_id)
|
||||
subba = ActiveSubathon(subathondata, running)
|
||||
return subba
|
||||
|
||||
async def goalcheck(self, active: ActiveSubathon, channel: PartialUser):
|
||||
goals = await active.get_goals()
|
||||
score = await active.get_score()
|
||||
for i, goal in enumerate(goals):
|
||||
if not goal.notified and goal.required_score <= score:
|
||||
# Goal completed, notify channel
|
||||
await channel.send_message(
|
||||
f"We have reached Goal #{i+1}: {goal.description} !! Thank you everyone for your support <3",
|
||||
sender=self.bot.bot_id,
|
||||
)
|
||||
await goal.update(notified=True)
|
||||
|
||||
# ----- Event Handlers -----
|
||||
@cmds.Component.listener()
|
||||
async def event_safe_bits_use(self, payload):
|
||||
event_row, detail_row, bits_payload = payload
|
||||
if (active := await self.get_active_subathon(event_row['communityid'])) is not None:
|
||||
# In an active subathon
|
||||
pid = event_row['profileid']
|
||||
uid = event_row['user_id']
|
||||
score = detail_row['bits'] * active.subathondata.bit_score
|
||||
await active.add_contribution(pid, score, event_row['event_id'])
|
||||
|
||||
# Send message to channel
|
||||
sec = active.get_score_time(score)
|
||||
added_min = int(sec // 60)
|
||||
if added_min > 0:
|
||||
added = f"{added_min} minutes"
|
||||
else:
|
||||
added = f"{sec} seconds"
|
||||
name = bits_payload.user.name
|
||||
pl = 's' if bits_payload.bits != 1 else ''
|
||||
|
||||
contrib_str = f"{name} contributed {score} bit{pl}"
|
||||
if not await active.check_cap():
|
||||
contrib_str += f" and added {added} to the timer! Thank you holono1Heart"
|
||||
else:
|
||||
contrib_str += " towards our studython! Thank you holono1Heart"
|
||||
|
||||
await bits_payload.broadcaster.send_message(
|
||||
contrib_str,
|
||||
sender=self.bot.bot_id
|
||||
)
|
||||
await self.channel.send_updates()
|
||||
await self.goalcheck(active, bits_payload.broadcaster)
|
||||
# Check goals
|
||||
|
||||
@cmds.Component.listener()
|
||||
async def event_safe_subscription(self, payload):
|
||||
event_row, detail_row, sub_payload = payload
|
||||
if sub_payload.gift:
|
||||
# Ignore gifted here
|
||||
return
|
||||
|
||||
if (active := await self.get_active_subathon(event_row['communityid'])) is not None:
|
||||
data = active.subathondata
|
||||
# In an active subathon
|
||||
pid = event_row['profileid']
|
||||
tier = int(sub_payload.tier)
|
||||
|
||||
if tier == 1000:
|
||||
mult = data.sub1_score
|
||||
elif tier == 2000:
|
||||
mult = data.sub2_score
|
||||
elif tier == 3000:
|
||||
mult = data.sub3_score
|
||||
else:
|
||||
raise ValueError(f"Unknown sub tier {sub_payload.tier}")
|
||||
|
||||
score = mult * 1
|
||||
|
||||
await active.add_contribution(pid, score, event_row['event_id'])
|
||||
|
||||
# Send message to channel
|
||||
added_min = int(active.get_score_time(score) // 60)
|
||||
added = f"{added_min} minutes"
|
||||
name = sub_payload.user.name
|
||||
pl = 's' if score > 1 else ''
|
||||
|
||||
contrib_str = f"{name} contributed {score} sub{pl}"
|
||||
if not await active.check_cap():
|
||||
contrib_str += f" and added {added} to the timer! Thank you holono1Heart"
|
||||
else:
|
||||
contrib_str += " towards our studython! Thank you holono1Heart"
|
||||
|
||||
await sub_payload.broadcaster.send_message(
|
||||
contrib_str,
|
||||
sender=self.bot.bot_id
|
||||
)
|
||||
await self.channel.send_updates()
|
||||
# Check goals
|
||||
await self.goalcheck(active, sub_payload.broadcaster)
|
||||
|
||||
@cmds.Component.listener()
|
||||
async def event_safe_subscription_gift(self, payload):
|
||||
event_row, detail_row, gift_payload = payload
|
||||
|
||||
if (active := await self.get_active_subathon(event_row['communityid'])) is not None:
|
||||
data = active.subathondata
|
||||
# In an active subathon
|
||||
pid = event_row['profileid']
|
||||
|
||||
tier = int(gift_payload.tier)
|
||||
if tier == 1000:
|
||||
mult = data.sub1_score
|
||||
elif tier == 2000:
|
||||
mult = data.sub2_score
|
||||
elif tier == 3000:
|
||||
mult = data.sub3_score
|
||||
else:
|
||||
raise ValueError(f"Unknown sub tier {gift_payload.tier}")
|
||||
|
||||
score = mult * gift_payload.total
|
||||
|
||||
await active.add_contribution(pid, score, event_row['event_id'])
|
||||
|
||||
# Send message to channel
|
||||
added_min = int(active.get_score_time(score) // 60)
|
||||
added = f"{added_min} minutes"
|
||||
name = gift_payload.user.name if gift_payload.user else 'Anonymous'
|
||||
|
||||
contrib_str = f"{name} contributed {score} subs"
|
||||
if not await active.check_cap():
|
||||
contrib_str += f" and added {added} to the timer! Thank you holono1Heart"
|
||||
else:
|
||||
contrib_str += " towards our studython! Thank you holono1Heart"
|
||||
|
||||
await gift_payload.broadcaster.send_message(
|
||||
contrib_str,
|
||||
sender=self.bot.bot_id
|
||||
)
|
||||
await self.channel.send_updates()
|
||||
# Check goals
|
||||
await self.goalcheck(active, gift_payload.broadcaster)
|
||||
|
||||
@cmds.Component.listener()
|
||||
async def event_safe_subscription_message(self, payload):
|
||||
event_row, detail_row, sub_payload = payload
|
||||
|
||||
if (active := await self.get_active_subathon(event_row['communityid'])) is not None:
|
||||
data = active.subathondata
|
||||
# In an active subathon
|
||||
pid = event_row['profileid']
|
||||
tier = int(sub_payload.tier)
|
||||
|
||||
if tier == 1000:
|
||||
mult = data.sub1_score
|
||||
elif tier == 2000:
|
||||
mult = data.sub2_score
|
||||
elif tier == 3000:
|
||||
mult = data.sub3_score
|
||||
else:
|
||||
raise ValueError(f"Unknown sub tier {sub_payload.tier}")
|
||||
|
||||
score = mult * 1
|
||||
|
||||
await active.add_contribution(pid, score, event_row['event_id'])
|
||||
|
||||
# Send message to channel
|
||||
added_min = int(active.get_score_time(score) // 60)
|
||||
added = f"{added_min} minutes"
|
||||
name = sub_payload.user.name
|
||||
pl = 's' if score > 1 else ''
|
||||
|
||||
contrib_str = f"{name} contributed {score} sub{pl}"
|
||||
if not await active.check_cap():
|
||||
contrib_str += f" and added {added} to the timer! Thank you holono1Heart"
|
||||
else:
|
||||
contrib_str += " towards our studython! Thank you holono1Heart"
|
||||
|
||||
await sub_payload.broadcaster.send_message(
|
||||
contrib_str,
|
||||
sender=self.bot.bot_id
|
||||
)
|
||||
await self.channel.send_updates()
|
||||
# Check goals
|
||||
await self.goalcheck(active, sub_payload.broadcaster)
|
||||
|
||||
# end stream => Automatically pause the timer
|
||||
@cmds.Component.listener()
|
||||
async def event_stream_offline(self, payload: twitchio.StreamOffline):
|
||||
community = await self.bot.community_fetch(twitchid=payload.broadcaster.id, name=payload.broadcaster.name)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
if active.running:
|
||||
await active.pause()
|
||||
await payload.broadcaster.send_message(
|
||||
"Paused the subathon timer because the stream went offline!",
|
||||
sender=self.bot.bot_id
|
||||
)
|
||||
await self.channel.send_updates()
|
||||
|
||||
# ----- Commands -----
|
||||
|
||||
@cmds.group(name='subathon', aliases=['studython'], invoke_fallback=True)
|
||||
async def group_subathon(self, ctx: cmds.Context):
|
||||
# TODO: Status
|
||||
community = await self.bot.community_fetch(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
score = await active.get_score()
|
||||
goals = await active.get_goals()
|
||||
total_goals = len(goals)
|
||||
donegoals = len([goal for goal in goals if score >= goal.required_score])
|
||||
goalstr = f"{donegoals}/{total_goals} goals achieved"
|
||||
|
||||
secs = await active.get_remaining()
|
||||
remaining = strfdelta(timedelta(seconds=secs))
|
||||
|
||||
secs = active.get_duration()
|
||||
duration = strfdelta(timedelta(seconds=secs))
|
||||
|
||||
text = (
|
||||
f"Subathon running for {duration}! {score} (equivalent) subscriptions recieved, {goalstr}, and {remaining} left on the timer"
|
||||
)
|
||||
await ctx.reply(text)
|
||||
else:
|
||||
await ctx.reply("No active subathon running!")
|
||||
|
||||
# subathon start
|
||||
@group_subathon.command(name='setup')
|
||||
async def cmd_setup(self, ctx: cmds.Context, initial_hours: float, sub1: float, sub2: float, sub3: float, bit: float, timescore: int, timecap: Optional[int]=None):
|
||||
if ctx.broadcaster:
|
||||
community = await self.bot.community_fetch(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
await ctx.reply("There is already an active subathon running! Use !subathon stop to stop it!")
|
||||
return
|
||||
initial_time = initial_hours * 60 * 60
|
||||
|
||||
active = await Subathon.create(
|
||||
communityid=cid,
|
||||
initial_time=initial_time,
|
||||
sub1_score=sub1,
|
||||
sub2_score=sub2,
|
||||
sub3_score=sub3,
|
||||
bit_score=bit,
|
||||
score_time=timescore,
|
||||
timecap=timecap
|
||||
)
|
||||
await ctx.reply("Setup a new subathon! Use !subathon resume to get the timer running.")
|
||||
await self.channel.send_updates()
|
||||
|
||||
# subathon stop
|
||||
@group_subathon.command(name='stop')
|
||||
async def cmd_stop(self, ctx: cmds.Context):
|
||||
if ctx.broadcaster:
|
||||
community = await self.bot.community_fetch(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
if active.running:
|
||||
await active.pause()
|
||||
await self.channel.send_updates()
|
||||
await active.subathondata.update(ended_at=utc_now())
|
||||
total = await active.get_score()
|
||||
dursecs = active.get_duration()
|
||||
dur = strfdelta(timedelta(seconds=dursecs))
|
||||
|
||||
await ctx.reply(
|
||||
f"Subathon complete after {dur} with a total of {total} subs, congratulations!"
|
||||
)
|
||||
else:
|
||||
await ctx.reply("No active subathon to stop.")
|
||||
|
||||
# subathon pause
|
||||
@group_subathon.command(name='pause')
|
||||
async def cmd_pause(self, ctx: cmds.Context):
|
||||
if ctx.broadcaster or ctx.author.moderator:
|
||||
community = await self.bot.community_fetch(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
if active.running:
|
||||
await active.pause()
|
||||
await ctx.reply("Subathon timer paused!")
|
||||
await self.channel.send_updates()
|
||||
else:
|
||||
await ctx.reply("Subathon timer already paused!")
|
||||
else:
|
||||
await ctx.reply("No active subathon to pause")
|
||||
|
||||
# subathon resume
|
||||
@group_subathon.command(name='resume')
|
||||
async def cmd_resume(self, ctx: cmds.Context):
|
||||
if ctx.broadcaster or ctx.author.moderator:
|
||||
community = await self.bot.community_fetch(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
if not active.running:
|
||||
await active.resume()
|
||||
await ctx.reply("Subathon timer resumed!")
|
||||
await self.channel.send_updates()
|
||||
else:
|
||||
await ctx.reply("Subathon timer already running!")
|
||||
else:
|
||||
await ctx.reply("No active subathon to resume")
|
||||
|
||||
@cmds.group(name='goals', invoke_fallback=True)
|
||||
async def group_goals(self, ctx: cmds.Context):
|
||||
# List the goals
|
||||
community = await self.bot.community_fetch(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
goals = await active.get_goals()
|
||||
goalstrs = []
|
||||
for i, goal in enumerate(goals, start=1):
|
||||
line = f"{goal.required_score} subs: {goal.description}"
|
||||
goalstrs.append(line)
|
||||
|
||||
if goals:
|
||||
text = ', '.join(goalstrs)
|
||||
await ctx.reply(f"Subathon Goals! -- {text}")
|
||||
else:
|
||||
await ctx.reply("No goals have been configured!")
|
||||
else:
|
||||
await ctx.reply("No active subathon running!")
|
||||
|
||||
@group_goals.command(name='add')
|
||||
async def cmd_add(self, ctx: cmds.Context, required: int, *, description: str):
|
||||
if ctx.broadcaster or ctx.author.moderator:
|
||||
community = await self.bot.community_fetch(twitchid=ctx.broadcaster.id, name=ctx.broadcaster.name)
|
||||
cid = community.communityid
|
||||
if (active := await self.get_active_subathon(cid)) is not None:
|
||||
await SubathonGoal.create(
|
||||
subathon_id=active.subathondata.subathon_id,
|
||||
required_score=required,
|
||||
description=description,
|
||||
)
|
||||
await ctx.reply("Goal added!")
|
||||
else:
|
||||
await ctx.reply("No active subathon to goal!")
|
||||
|
||||
Reference in New Issue
Block a user