From 2ea0658c73c3ff9135c1bbcc5d3559ddc1babb63 Mon Sep 17 00:00:00 2001 From: Interitio Date: Fri, 1 Aug 2025 00:41:24 +1000 Subject: [PATCH] Initial Plugin Commit --- .gitignore | 151 ++++++++++++++ README.md | 0 __init__.py | 3 + data/schema.sql | 178 ++++++++++++++++ requirements.txt | 1 + tracker/__init__.py | 7 + tracker/component.py | 477 +++++++++++++++++++++++++++++++++++++++++++ tracker/data.py | 39 ++++ 8 files changed, 856 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 data/schema.sql create mode 100644 requirements.txt create mode 100644 tracker/__init__.py create mode 100644 tracker/component.py create mode 100644 tracker/data.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0cda90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,151 @@ +src/modules/test/* + +pending-rewrite/ +logs/* +notes/* +tmp/* +output/* +locales/domains + +.idea/* + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +config/** diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..8341625 --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +async def twitch_setup(bot): + from .tracker import setup + await setup(bot) diff --git a/data/schema.sql b/data/schema.sql new file mode 100644 index 0000000..88115d1 --- /dev/null +++ b/data/schema.sql @@ -0,0 +1,178 @@ +INSERT INTO version_history (component, from_version, to_version, author) VALUES ('EVENT_TRACKER', 0, 1, 'Initial Creation'); + +-- TODO: Assert profiles data version + +-- Twitch tracked event data {{{ + +CREATE TABLE tracking_channels( + userid TEXT PRIMARY KEY REFERENCES bot_channels(userid) ON DELETE CASCADE, + joined BOOLEAN, + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + _timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TRIGGER tracking_channels_timestamp BEFORE UPDATE ON tracking_channels + FOR EACH ROW EXECUTE FUNCTION update_timestamp_column(); + +CREATE TABLE events( + event_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + event_type TEXT NOT NULL, + communityid INTEGER NOT NULL REFERENCES communities (communityid), + channel_id TEXT NOT NULL, + profileid INTEGER REFERENCES user_profiles (profileid), + user_id TEXT, + occurred_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (event_id, event_type) +); + +CREATE TABLE follow_events( + event_id INTEGER PRIMARY KEY REFERENCES events (event_id), + event_type TEXT NOT NULL DEFAULT 'follow' CHECK (event_type = 'follow'), + follower_count INTEGER NOT NULL, + FOREIGN KEY (event_id, event_type) REFERENCES events (event_id, event_type) +); + +CREATE TABLE bits_events( + event_id INTEGER PRIMARY KEY REFERENCES events (event_id), + event_type TEXT NOT NULL DEFAULT 'bits' CHECK (event_type = 'bits'), + bits INTEGER NOT NULL, + bits_type TEXT NOT NULL, + message TEXT, + powerup_type TEXT, + FOREIGN KEY (event_id, event_type) REFERENCES events (event_id, event_type) +); + +CREATE TABLE subscribe_events( + event_id INTEGER PRIMARY KEY REFERENCES events (event_id), + event_type TEXT NOT NULL DEFAULT 'subscribe' CHECK (event_type = 'subscribe'), + tier INTEGER NOT NULL, + gifted BOOLEAN NOT NULL, + FOREIGN KEY (event_id, event_type) REFERENCES events (event_id, event_type) +); + +CREATE TABLE gift_events( + event_id INTEGER PRIMARY KEY REFERENCES events (event_id), + event_type TEXT NOT NULL DEFAULT 'gift' CHECK (event_type = 'gift'), + tier INTEGER NOT NULL, + gifted_count INTEGER NOT NULL, + FOREIGN KEY (event_id, event_type) REFERENCES events (event_id, event_type) +); + +CREATE TABLE subscribe_message_events( + event_id INTEGER PRIMARY KEY REFERENCES events (event_id), + event_type TEXT NOT NULL DEFAULT 'subscribe_message' CHECK (event_type = 'subscribe_message'), + tier INTEGER NOT NULL, + duration_months INTEGER NOT NULL, + cumulative_months INTEGER NOT NULL, + streak_months INTEGER, + message TEXT, + FOREIGN KEY (event_id, event_type) REFERENCES events (event_id, event_type) +); + +CREATE TABLE cheer_events( + event_id INTEGER PRIMARY KEY REFERENCES events (event_id), + event_type TEXT NOT NULL DEFAULT 'cheer' CHECK (event_type = 'cheer'), + amount INTEGER NOT NULL, + message TEXT, + FOREIGN KEY (event_id, event_type) REFERENCES events (event_id, event_type) +); + +CREATE TABLE redemption_add_events( + event_id INTEGER PRIMARY KEY REFERENCES events (event_id), + event_type TEXT NOT NULL DEFAULT 'redemption_add' CHECK (event_type = 'redemption_add'), + redeem_id TEXT NOT NULL, + redeem_title TEXT NOT NULL, + redeem_cost INTEGER NOT NULL, + redemption_id TEXT NOT NULL, + redemption_status TEXT NOT NULL, + message TEXT, + FOREIGN KEY (event_id, event_type) REFERENCES events (event_id, event_type) +); + +CREATE TABLE redemption_update_events( + event_id INTEGER PRIMARY KEY REFERENCES events (event_id), + event_type TEXT NOT NULL DEFAULT 'redemption_update' CHECK (event_type = 'redemption_update'), + redeem_id TEXT NOT NULL, + redeem_title TEXT NOT NULL, + redeem_cost INTEGER NOT NULL, + redemption_id TEXT NOT NULL, + redemption_status TEXT NOT NULL, + redeemed_at TIMESTAMPTZ NOT NULL, + FOREIGN KEY (event_id, event_type) REFERENCES events (event_id, event_type) +); + +CREATE TABLE poll_end_events( + event_id INTEGER PRIMARY KEY REFERENCES events (event_id), + event_type TEXT NOT NULL DEFAULT 'poll_end' CHECK (event_type = 'poll_end'), + poll_id TEXT NOT NULL, + poll_title TEXT NOT NULL, + poll_choices TEXT NOT NULL, + poll_started TIMESTAMPTZ NOT NULL, + FOREIGN KEY (event_id, event_type) REFERENCES events (event_id, event_type) +); + +CREATE TABLE stream_online_events( + event_id INTEGER PRIMARY KEY REFERENCES events (event_id), + event_type TEXT NOT NULL DEFAULT 'stream_online' CHECK (event_type = 'stream_online'), + stream_id TEXT NOT NULL, + stream_type TEXT NOT NULL, + FOREIGN KEY (event_id, event_type) REFERENCES events (event_id, event_type) +); + +CREATE TABLE stream_offline_events( + event_id INTEGER PRIMARY KEY REFERENCES events (event_id), + event_type TEXT NOT NULL DEFAULT 'stream_offline' CHECK (event_type = 'stream_offline'), + stream_id TEXT, + FOREIGN KEY (event_id, event_type) REFERENCES events (event_id, event_type) +); + +CREATE TABLE channel_update_events( + event_id INTEGER PRIMARY KEY REFERENCES events (event_id), + event_type TEXT NOT NULL DEFAULT 'channel_update' CHECK (event_type = 'channel_update'), + title TEXT, + language TEXT, + category_id TEXT, + category_name TEXT, + FOREIGN KEY (event_id, event_type) REFERENCES events (event_id, event_type) +); + +CREATE TABLE vip_add_events( + event_id INTEGER PRIMARY KEY REFERENCES events (event_id), + event_type TEXT NOT NULL DEFAULT 'vip_add' CHECK (event_type = 'vip_add'), + FOREIGN KEY (event_id, event_type) REFERENCES events (event_id, event_type) +); + +CREATE TABLE vip_remove_events( + event_id INTEGER PRIMARY KEY REFERENCES events (event_id), + event_type TEXT NOT NULL DEFAULT 'vip_remove' CHECK (event_type = 'vip_remove'), + FOREIGN KEY (event_id, event_type) REFERENCES events (event_id, event_type) +); + +CREATE TABLE message_events( + event_id INTEGER PRIMARY KEY REFERENCES events (event_id), + event_type TEXT NOT NULL DEFAULT 'message' CHECK (event_type = 'message'), + message_id TEXT NOT NULL, + message_type TEXT NOT NULL, + content TEXT, + source_channel_id TEXT, + FOREIGN KEY (event_id, event_type) REFERENCES events (event_id, event_type) +); + +CREATE TABLE raid_out_events( + event_id INTEGER PRIMARY KEY REFERENCES events (event_id), + event_type TEXT NOT NULL DEFAULT 'raidout' CHECK (event_type = 'raidout'), + target_id TEXT NOT NULL, + target_name TEXT, + viewer_count INTEGER NOT NULL, + FOREIGN KEY (event_id, event_type) REFERENCES events (event_id, event_type) +); + +CREATE TABLE raid_in_events( + event_id INTEGER PRIMARY KEY REFERENCES events (event_id), + event_type TEXT NOT NULL DEFAULT 'raidin' CHECK (event_type = 'raidin'), + source_id TEXT NOT NULL, + source_name TEXT, + viewer_count INTEGER NOT NULL, + FOREIGN KEY (event_id, event_type) REFERENCES events (event_id, event_type) +); +-- }}} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ecb911a --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +twitchio diff --git a/tracker/__init__.py b/tracker/__init__.py new file mode 100644 index 0000000..288ace4 --- /dev/null +++ b/tracker/__init__.py @@ -0,0 +1,7 @@ +import logging + +logger = logging.getLogger() + +async def setup(bot): + from .component import TrackerComponent + await bot.add_component(TrackerComponent(bot)) diff --git a/tracker/component.py b/tracker/component.py new file mode 100644 index 0000000..f78d13d --- /dev/null +++ b/tracker/component.py @@ -0,0 +1,477 @@ +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 + +from . import logger +from .data import EventData, TrackingChannel + + +class TrackerComponent(cmds.Component): + def __init__(self, bot: CrocBot): + self.bot = bot + self.data = bot.dbconn.load_registry(EventData()) + print(self.__all_listeners__) + + # One command, which sends join link to start tracking + # Utility for fetch_or_create community and profiles and setting names + # Then just a stack of event listeners. + + # ----- API ----- + async def component_load(self): + await self.data.init() + + async def component_teardown(self): + pass + + # ----- Methods ----- + async def start_tracking(self, channel: TrackingChannel): + # TODO: Make sure that we aren't trying to make duplicate subscriptions here + logger.debug( + "Initialising event tracking for %s", + channel.userid + ) + # Get associated auth scopes + rows = await self.bot.data.user_auth_scopes.select_where(userid=channel.userid) + scopes = Scopes([row['scope'] for row in rows]) + + # Build subscription payloads based on available scopes + subs = [] + usersubs = [] + subcls = [] + if Scopes.channel_read_redemptions in scopes or Scopes.channel_manage_redemptions in scopes: + subcls.append(eventsub.ChannelPointsRedeemAddSubscription) + subcls.append(eventsub.ChannelPointsRedeemUpdateSubscription) + if Scopes.bits_read in scopes: + subcls.append(eventsub.ChannelBitsUseSubscription) + subcls.append(eventsub.ChannelCheerSubscription) + if Scopes.channel_read_subscriptions in scopes: + subcls.extend(( + eventsub.ChannelSubscribeSubscription, + eventsub.ChannelSubscribeMessageSubscription, + eventsub.ChannelSubscriptionGiftSubscription, + )) + if Scopes.channel_read_polls in scopes or Scopes.channel_manage_polls in scopes: + subcls.append(eventsub.ChannelPollEndSubscription) + if Scopes.channel_read_vips in scopes or Scopes.channel_manage_vips in scopes: + subcls.extend(( + eventsub.ChannelVIPAddSubscription, + eventsub.ChannelVIPRemoveSubscription, + )) + + subcls.extend(( + eventsub.StreamOnlineSubscription, + eventsub.StreamOfflineSubscription, + eventsub.ChannelUpdateSubscription, + )) + + for subbr in subcls: + subs.append(subbr(broadcaster_user_id=channel.userid)) + + # This isn't needed because joining the channel means we have these + # Including them for completeness though + # if Scopes.chat_read in scopes or Scopes.channel_bot in scopes: + # subs.append( + # eventsub.ChatMessageSubscription( + # broadcaster_user_id=channel.userid, + # user_id=self.bot.bot_id, + # ) + # ) + if Scopes.moderator_read_followers in scopes: + usersubs.append( + eventsub.ChannelFollowSubscription( + broadcaster_user_id=channel.userid, + moderator_user_id=channel.userid, + ) + ) + + subs.extend(( + eventsub.ChannelRaidSubscription(to_broadcaster_user_id=channel.userid), + eventsub.ChannelRaidSubscription(from_broadcaster_user_id=channel.userid), + )) + + responses = [] + for sub in subs: + try: + if self.bot.using_webhooks: + resp = await self.bot.subscribe_webhook(sub) + else: + resp = await self.bot.subscribe_websocket(sub) + responses.append(resp) + except Exception: + logger.exception("Failed to subscribe to %s", str(sub)) + for sub in usersubs: + try: + if self.bot.using_webhooks: + resp = await self.bot.subscribe_webhook(sub) + else: + resp = await self.bot.subscribe_websocket(sub, token_for=channel.userid, as_bot=False) + responses.append(resp) + except Exception: + logger.exception("Failed to subscribe to %s", str(sub)) + + logger.info("Finished tracker subscription to %s: %s", channel.userid, ', '.join(map(str, responses))) + + # ----- Events ----- + + @cmds.Component.listener() + async def event_safe_channel_joined(self, payload: BotChannel): + # Check if the channel is tracked + # If it is, call start_tracking + tracked = await TrackingChannel.fetch(payload.userid) + if tracked and tracked.joined: + await self.start_tracking(tracked) + + @cmds.Component.listener() + async def event_custom_redemption_add(self, payload: twitchio.ChannelPointsRedemptionAdd): + tracked = await TrackingChannel.fetch(payload.broadcaster.id) + if tracked and tracked.joined: + community = await self.bot.community_fetch(twitchid=payload.broadcaster.id, name=payload.broadcaster.name) + cid = community.communityid + profile = await self.bot.profile_fetch( + twitchid=payload.user.id, name=payload.user.name, + ) + pid = profile.profileid + + event_row = await self.data.events.insert( + event_type='redemption_add', + communityid=cid, + channel_id=payload.broadcaster.id, + profileid=pid, + user_id=payload.user.id, + occurred_at=payload.redeemed_at, + ) + detail_row = await self.data.redemption_add_events.insert( + event_id=event_row['event_id'], + redeem_id=payload.reward.id, + redeem_title=payload.reward.title, + redeem_cost=payload.reward.cost, + redemption_id=payload.id, + redemption_status=payload.status, + message=payload.user_input, + ) + + @cmds.Component.listener() + async def event_custom_redemption_update(self, payload: twitchio.ChannelPointsRedemptionUpdate): + tracked = await TrackingChannel.fetch(payload.broadcaster.id) + if tracked and tracked.joined: + community = await self.bot.community_fetch(twitchid=payload.broadcaster.id, name=payload.broadcaster.name) + cid = community.communityid + profile = await self.bot.profile_fetch( + twitchid=payload.user.id, name=payload.user.name, + ) + pid = profile.profileid + + event_row = await self.data.events.insert( + event_type='redemption_update', + communityid=cid, + channel_id=payload.broadcaster.id, + profileid=pid, + user_id=payload.user.id, + ) + detail_row = await self.data.redemption_update_events.insert( + event_id=event_row['event_id'], + redeem_id=payload.reward.id, + redeem_title=payload.reward.title, + redeem_cost=payload.reward.cost, + redemption_id=payload.id, + redemption_status=payload.status, + redeemed_at=utc_now() + ) + + @cmds.Component.listener() + async def event_follow(self, payload: twitchio.ChannelFollow): + tracked = await TrackingChannel.fetch(payload.broadcaster.id) + if tracked and tracked.joined: + community = await self.bot.community_fetch(twitchid=payload.broadcaster.id, name=payload.broadcaster.name) + cid = community.communityid + profile = await self.bot.profile_fetch( + twitchid=payload.user.id, name=payload.user.name, + ) + pid = profile.profileid + + # Computer follower count + followers = await payload.broadcaster.fetch_followers() + + event_row = await self.data.events.insert( + event_type='follow', + communityid=cid, + channel_id=payload.broadcaster.id, + profileid=pid, + user_id=payload.user.id, + ) + detail_row = await self.data.follow_events.insert( + event_id=event_row['event_id'], + follower_count=followers.total + ) + + @cmds.Component.listener() + async def event_bits_use(self, payload: twitchio.ChannelBitsUse): + tracked = await TrackingChannel.fetch(payload.broadcaster.id) + if tracked and tracked.joined: + community = await self.bot.community_fetch(twitchid=payload.broadcaster.id, name=payload.broadcaster.name) + cid = community.communityid + profile = await self.bot.profile_fetch( + twitchid=payload.user.id, name=payload.user.name, + ) + pid = profile.profileid + + event_row = await self.data.events.insert( + event_type='bits', + communityid=cid, + channel_id=payload.broadcaster.id, + profileid=pid, + user_id=payload.user.id, + ) + detail_row = await self.data.bits_events.insert( + event_id=event_row['event_id'], + bits=payload.bits, + bits_type=payload.type, + message=payload.text, + powerup_type=payload.power_up.type if payload.power_up else None + ) + self.bot.safe_dispatch('bits_use', payload=(event_row, detail_row, payload)) + + @cmds.Component.listener() + async def event_subscription(self, payload: twitchio.ChannelSubscribe): + tracked = await TrackingChannel.fetch(payload.broadcaster.id) + if tracked and tracked.joined: + community = await self.bot.community_fetch(twitchid=payload.broadcaster.id, name=payload.broadcaster.name) + cid = community.communityid + profile = await self.bot.profile_fetch( + twitchid=payload.user.id, name=payload.user.name, + ) + pid = profile.profileid + + event_row = await self.data.events.insert( + event_type='subscribe', + communityid=cid, + channel_id=payload.broadcaster.id, + profileid=pid, + user_id=payload.user.id, + ) + detail_row = await self.data.subscribe_events.insert( + event_id=event_row['event_id'], + tier=int(payload.tier), + gifted=payload.gift, + ) + self.bot.safe_dispatch('subscription', payload=(event_row, detail_row, payload)) + + @cmds.Component.listener() + async def event_subscription_gift(self, payload: twitchio.ChannelSubscriptionGift): + tracked = await TrackingChannel.fetch(payload.broadcaster.id) + if tracked and tracked.joined: + community = await self.bot.community_fetch(twitchid=payload.broadcaster.id, name=payload.broadcaster.name) + cid = community.communityid + if payload.user is not None: + profile = await self.bot.profile_fetch( + twitchid=payload.user.id, name=payload.user.name, + ) + pid = profile.profileid + else: + pid = None + + event_row = await self.data.events.insert( + event_type='gift', + communityid=cid, + channel_id=payload.broadcaster.id, + profileid=pid, + user_id=payload.user.id if payload.user else None, + ) + detail_row = await self.data.gift_events.insert( + event_id=event_row['event_id'], + tier=int(payload.tier), + gifted_count=payload.total, + ) + self.bot.safe_dispatch('subscription_gift', payload=(event_row, detail_row, payload)) + + @cmds.Component.listener() + async def event_subscription_message(self, payload: twitchio.ChannelSubscriptionMessage): + tracked = await TrackingChannel.fetch(payload.broadcaster.id) + if tracked and tracked.joined: + community = await self.bot.community_fetch(twitchid=payload.broadcaster.id, name=payload.broadcaster.name) + cid = community.communityid + profile = await self.bot.profile_fetch( + twitchid=payload.user.id, name=payload.user.name, + ) + pid = profile.profileid + + event_row = await self.data.events.insert( + event_type='subscribe_message', + communityid=cid, + channel_id=payload.broadcaster.id, + profileid=pid, + user_id=payload.user.id, + ) + detail_row = await self.data.subscribe_message_events.insert( + event_id=event_row['event_id'], + tier=int(payload.tier), + duration_months=payload.months, + cumulative_months=payload.cumulative_months, + streak_months=payload.streak_months, + message=payload.text, + ) + self.bot.safe_dispatch('subscription_message', payload=(event_row, detail_row, payload)) + + @cmds.Component.listener() + async def event_stream_online(self, payload: twitchio.StreamOnline): + tracked = await TrackingChannel.fetch(payload.broadcaster.id) + if tracked and tracked.joined: + community = await self.bot.community_fetch(twitchid=payload.broadcaster.id, name=payload.broadcaster.name) + cid = community.communityid + + event_row = await self.data.events.insert( + event_type='stream_online', + communityid=cid, + channel_id=payload.broadcaster.id, + occurred_at=payload.started_at, + ) + detail_row = await self.data.stream_online_events.insert( + event_id=event_row['event_id'], + stream_id=payload.id, + stream_type=payload.type, + ) + + @cmds.Component.listener() + async def event_stream_offline(self, payload: twitchio.StreamOffline): + tracked = await TrackingChannel.fetch(payload.broadcaster.id) + if tracked and tracked.joined: + community = await self.bot.community_fetch(twitchid=payload.broadcaster.id, name=payload.broadcaster.name) + cid = community.communityid + + event_row = await self.data.events.insert( + event_type='stream_offline', + communityid=cid, + channel_id=payload.broadcaster.id, + ) + detail_row = await self.data.stream_offline_events.insert( + event_id=event_row['event_id'], + ) + + @cmds.Component.listener() + async def event_raid(self, payload: twitchio.ChannelRaid): + await self._event_raid_out( + payload.from_broadcaster, + payload.to_broadcaster, + payload.viewer_count, + ) + await self._event_raid_in( + payload.to_broadcaster, + payload.from_broadcaster, + payload.viewer_count, + ) + + async def _event_raid_out(self, broadcaster: PartialUser, to_broadcaster: PartialUser, viewer_count: int): + tracked = await TrackingChannel.fetch(broadcaster.id) + if tracked and tracked.joined: + community = await self.bot.community_fetch(twitchid=broadcaster.id, name=broadcaster.name) + cid = community.communityid + + event_row = await self.data.events.insert( + event_type='raidout', + communityid=cid, + channel_id=broadcaster.id, + ) + detail_row = await self.data.raid_out_events.insert( + event_id=event_row['event_id'], + target_id=to_broadcaster.id, + target_name=to_broadcaster.name, + viewer_count=viewer_count + ) + + async def _event_raid_in(self, broadcaster: PartialUser, from_broadcaster: PartialUser, viewer_count: int): + tracked = await TrackingChannel.fetch(broadcaster.id) + if tracked and tracked.joined: + community = await self.bot.community_fetch(twitchid=broadcaster.id, name=broadcaster.name) + cid = community.communityid + + event_row = await self.data.events.insert( + event_type='raidin', + communityid=cid, + channel_id=broadcaster.id, + ) + detail_row = await self.data.raid_in_events.insert( + event_id=event_row['event_id'], + source_id=from_broadcaster.id, + source_name=from_broadcaster.name, + viewer_count=viewer_count + ) + + @cmds.Component.listener() + async def event_message(self, payload: twitchio.ChatMessage): + tracked = await TrackingChannel.fetch(payload.broadcaster.id) + if tracked and tracked.joined: + community = await self.bot.community_fetch(twitchid=payload.broadcaster.id, name=payload.broadcaster.name) + cid = community.communityid + profile = await self.bot.profile_fetch( + twitchid=payload.chatter.id, name=payload.chatter.name, + ) + pid = profile.profileid + + event_row = await self.data.events.insert( + event_type='message', + communityid=cid, + channel_id=payload.broadcaster.id, + profileid=pid, + user_id=payload.chatter.id, + ) + detail_row = await self.data.message_events.insert( + event_id=event_row['event_id'], + message_id=payload.id, + message_type=payload.type, + content=payload.text, + source_channel_id=payload.source_id + ) + + # ----- Commands ----- + @cmds.command(name='starttracking') + async def cmd_starttracking(self, ctx: cmds.Context): + if ctx.broadcaster: + tracking = await TrackingChannel.fetch_or_create(ctx.channel.id, joined=True) + if not tracking.joined: + await tracking.update(joined=True) + + rows = await self.bot.data.user_auth_scopes.select_where(userid=ctx.channel.id) + scopes = Scopes([row['scope'] for row in rows]) + + url = self.bot.get_auth_url( + Scopes({ + Scopes.channel_read_subscriptions, + Scopes.channel_read_redemptions, + Scopes.bits_read, + Scopes.channel_read_polls, + Scopes.channel_read_vips, + Scopes.moderator_read_followers, + *scopes + }) + ) + await ctx.reply(f"Tracking enabled! Please authorise me to track events in this channel: {url}") + else: + await ctx.reply("Only the broadcaster can enable tracking.") + + @cmds.command(name='stoptracking') + async def cmd_stoptracking(self, ctx: cmds.Context): + if ctx.broadcaster: + tracking = await TrackingChannel.fetch(ctx.channel.id) + if tracking and tracking.joined: + await tracking.update(joined=False) + # TODO: Actually disable the subscriptions instead of just on the next restart + # This is tricky because some of the subscriptions may have been requested by other modules + # Requires keeping track of the source of subscriptions, and having a central manager disable them when no-one is listening anymore. + pass + await ctx.reply("Event tracking has been disabled.") + else: + await ctx.reply("Event tracking is not enabled!") + + else: + await ctx.reply("Only the broadcaster can enable tracking.") + + @cmds.command(name='join') + async def cmd_join(self, ctx: cmds.Context): + url = self.bot.get_auth_url() + await ctx.reply(f"Invite me to your channel with: {url}") diff --git a/tracker/data.py b/tracker/data.py new file mode 100644 index 0000000..65bc6b5 --- /dev/null +++ b/tracker/data.py @@ -0,0 +1,39 @@ +from data import Registry, RowModel, Table +from data.columns import String, Timestamp, Integer, Bool + + +class TrackingChannel(RowModel): + _tablename_ = 'tracking_channels' + _cache_ = {} + + userid = String(primary=True) + joined = Bool + joined_at = Timestamp() + _timestamp = Timestamp() + + +class EventData(Registry): + tracking_channels = TrackingChannel.table + + events = Table('events') + follow_events = Table('follow_events') + bits_events = Table('bits_events') + + subscribe_events = Table('subscribe_events') + gift_events = Table('gift_events') + subscribe_message_events = Table('subscribe_message_events') + + cheer_events = Table('cheer_events') + redemption_add_events = Table('redemption_add_events') + redemption_update_events = Table('redemption_update_events') + poll_end_events = Table('poll_end_events') + stream_online_events = Table('stream_online_events') + stream_offline_events = Table('stream_offline_events') + channel_update_events = Table('channel_update_events') + + vip_add_events = Table('vip_add_events') + vip_remove_events = Table('vip_remove_events') + + raid_out_events = Table('raid_out_events') + raid_in_events = Table('raid_in_events') + message_events = Table('message_events')