Compare commits

...

4 Commits

Author SHA1 Message Date
c114f1a54a Add simple sockets channel implementation. 2025-09-01 23:28:16 +10:00
6667dc7263 Add module setup methods. 2025-09-01 22:15:57 +10:00
aba34799e1 feat: Add custom Context class. 2025-09-01 22:12:49 +10:00
68ce8139a9 Add profiles, tracker, subathon modules. 2025-09-01 22:11:45 +10:00
11 changed files with 124 additions and 9 deletions

9
.gitmodules vendored
View File

@@ -1,3 +1,12 @@
[submodule "src/data"] [submodule "src/data"]
path = src/data path = src/data
url = https://git.thewisewolf.dev/HoloTech/psqlmapper.git url = https://git.thewisewolf.dev/HoloTech/psqlmapper.git
[submodule "src/modules/subathon"]
path = src/modules/subathon
url = https://git.thewisewolf.dev/HoloTech/twitch-subathon-plugin.git
[submodule "src/modules/tracker"]
path = src/modules/tracker
url = https://git.thewisewolf.dev/HoloTech/twitch-eventtracker-plugin.git
[submodule "src/modules/profiles"]
path = src/modules/profiles
url = https://git.thewisewolf.dev/HoloTech/profiles-plugin.git

View File

@@ -2,3 +2,4 @@ twitchio
psycopg[pool] psycopg[pool]
cachetools cachetools
discord.py discord.py
websockets

View File

@@ -4,7 +4,7 @@ import websockets
from twitchio.web import AiohttpAdapter from twitchio.web import AiohttpAdapter
from meta import Bot, conf, setup_main_logger, args from meta import Bot, conf, setup_main_logger, args, sockets
from data import Database from data import Database
from modules import twitch_setup from modules import twitch_setup
@@ -30,6 +30,7 @@ async def main():
setup=twitch_setup, setup=twitch_setup,
) )
async with websockets.serve(sockets.root_handler, '', conf.wserver.getint('port')):
try: try:
await bot.start() await bot.start()
finally: finally:

View File

@@ -1,4 +1,5 @@
from .args import args from .args import args
from .bot import Bot from .bot import Bot
from .context import Context
from .config import Conf, conf from .config import Conf, conf
from .logger import setup_main_logger, log_context, log_action_stack, log_app, set_logging_context, logging_context, with_log_ctx, persist_task from .logger import setup_main_logger, log_context, log_action_stack, log_app, set_logging_context, logging_context, with_log_ctx, persist_task

View File

@@ -1,5 +1,5 @@
import logging import logging
from typing import Optional from typing import TYPE_CHECKING, Any, Literal, Optional, overload
from twitchio.authentication import UserTokenPayload from twitchio.authentication import UserTokenPayload
from twitchio.ext import commands from twitchio.ext import commands
@@ -10,6 +10,10 @@ from botdata import BotData, UserAuth, BotChannel, VersionHistory
from constants import BOTUSER_SCOPES, CHANNEL_SCOPES, SCHEMA_VERSIONS from constants import BOTUSER_SCOPES, CHANNEL_SCOPES, SCHEMA_VERSIONS
from .config import Conf from .config import Conf
from .context import Context
if TYPE_CHECKING:
from modules.profiles.profiles.twitch.component import ProfilesComponent
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -24,6 +28,7 @@ class Bot(commands.Bot):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Whether we should do eventsub via webhooks or websockets
if config.bot.get('eventsub_secret', None): if config.bot.get('eventsub_secret', None):
self.using_webhooks = True self.using_webhooks = True
else: else:
@@ -36,6 +41,28 @@ class Bot(commands.Bot):
self.joined: dict[str, BotChannel] = {} self.joined: dict[str, BotChannel] = {}
# Make the type checker happy about fetching components by name
# TODO: Move to stubs
@property
def profiles(self):
return self.get_component('ProfilesComponent')
@overload
def get_component(self, name: Literal['ProfilesComponent']) -> 'ProfilesComponent':
...
@overload
def get_component(self, name: str) -> Optional[commands.Component]:
...
def get_component(self, name: str) -> Optional[commands.Component]:
return super().get_component(name)
def get_context(self, payload, *, cls: Any = None) -> Context:
cls = cls or Context
return cls(payload, bot=self)
async def event_ready(self): async def event_ready(self):
# logger.info(f"Logged in as {self.nick}. User id is {self.user_id}") # logger.info(f"Logged in as {self.nick}. User id is {self.user_id}")
logger.info("Logged in as %s", self.bot_id) logger.info("Logged in as %s", self.bot_id)

4
src/meta/context.py Normal file
View File

@@ -0,0 +1,4 @@
from twitchio.ext import commands as cmds
class Context(cmds.Context):
...

68
src/meta/sockets.py Normal file
View File

@@ -0,0 +1,68 @@
from abc import ABC
from collections import defaultdict
import json
from typing import Any
import logging
import websockets
logger = logging.getLogger(__name__)
class Channel(ABC):
"""
A channel is a stateful connection handler for a group of connected websockets.
"""
name = "Root Channel"
def __init__(self, **kwargs):
self.connections = set()
@property
def empty(self):
return not self.connections
async def on_connection(self, websocket: websockets.WebSocketServerProtocol, event: dict[str, Any]):
logger.info(f"Channel '{self.name}' attached new connection {websocket=} {event=}")
self.connections.add(websocket)
async def del_connection(self, websocket: websockets.WebSocketServerProtocol):
logger.info(f"Channel '{self.name}' dropped connection {websocket=}")
self.connections.discard(websocket)
async def handle_message(self, websocket: websockets.WebSocketServerProtocol, message):
raise NotImplementedError
async def send_event(self, event, websocket=None):
message = json.dumps(event)
if not websocket:
for ws in self.connections:
await ws.send(message)
else:
await websocket.send(message)
channels = {}
def register_channel(name, channel: Channel):
channels[name] = channel
async def root_handler(websocket: websockets.WebSocketServerProtocol):
message = await websocket.recv()
event = json.loads(message)
if event.get('type', None) != 'init':
raise ValueError("Received Websocket connection with no init.")
if (channel_name := event.get('channel', None)) not in channels:
raise ValueError(f"Received Init for unhandled channel {channel_name=}")
channel = channels[channel_name]
try:
await channel.on_connection(websocket, event)
async for message in websocket:
await channel.handle_message(websocket, message)
finally:
await channel.del_connection(websocket)

View File

@@ -6,6 +6,7 @@ if TYPE_CHECKING:
async def twitch_setup(bot: 'Bot'): async def twitch_setup(bot: 'Bot'):
# Import and run setup methods from each module # Import and run setup methods from each module
# from . import module from . import profiles, tracker, subathon
# await module.twitch_setup(bot) await profiles.twitch_setup(bot)
pass await tracker.twitch_setup(bot)
await subathon.twitch_setup(bot)

1
src/modules/profiles Submodule

Submodule src/modules/profiles added at 543a65b7fb

1
src/modules/subathon Submodule

Submodule src/modules/subathon added at 0ea90b5cd2

1
src/modules/tracker Submodule

Submodule src/modules/tracker added at 094b1e3c57