twitchbot/twitchbot.py

504 lines
20 KiB
Python

'''
twitchbot - A maubot plugin for sending Twitch stream notifications
Copyright (C) 2025 L. Bradley LaBoon
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License version 3 as
published by the Free Software Foundation.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
'''
from aiohttp.web import Request, Response
from asyncio import Task, CancelledError, sleep
from maubot import Plugin, MessageEvent
from maubot.handlers import command, web
from mautrix.types import TextMessageEventContent, MessageType, Format
from mautrix.util import background_task
from mautrix.util.async_db import UpgradeTable, Connection
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
from secrets import token_hex
from typing import Type
import hashlib
import hmac
upgrade_table = UpgradeTable()
@upgrade_table.register(description="Initial version")
async def upgrade_v1(conn: Connection) -> None:
await conn.execute("CREATE TABLE twitchbot_data (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
await conn.execute("CREATE TABLE twitchbot_subs (id TEXT PRIMARY KEY, login TEXT NOT NULL, name TEXT NOT NULL)")
class Config(BaseProxyConfig):
def do_update(self, helper: ConfigUpdateHelper) -> None:
helper.copy("client_id")
helper.copy("client_secret")
helper.copy("notify_channels")
helper.copy("matrix_users")
helper.copy("notify_room")
class TwitchBot(Plugin):
access_check_loop: Task
# On startup: refresh the config, start the access token check loop, and register stream notifications
async def start(self) -> None:
self.config.load_and_update()
self.access_check_loop = background_task.create(self.check_access_token())
background_task.create(self.register_notifications())
# On config update: refresh the config and re-register stream notifications
def on_external_config_update(self) -> None:
self.config.load_and_update()
background_task.create(self.register_notifications())
# On shutdown: stop the access check loop
async def stop(self) -> None:
self.access_check_loop.cancel()
@classmethod
def get_config_class(cls) -> Type[BaseProxyConfig]:
return Config
@classmethod
def get_db_upgrade_table(cls) -> UpgradeTable | None:
return upgrade_table
# Check that we have a valid access token and get a new one if we don't
async def check_access_token(self) -> None:
try:
self.log.debug("Access check loop started.")
while True:
self.log.debug("Performing access token check...")
q = "SELECT value FROM twitchbot_data WHERE key = $1"
row = await self.database.fetchrow(q, "access_token")
if row:
headers = { "Authorization": f"Bearer {row['value']}" }
async with self.http.get("https://id.twitch.tv/oauth2/validate", headers=headers) as response:
result = await response.json()
if (response.status == 401):
self.log.info("Access token is invalid, fetching a new one...")
await self.get_access_token()
else:
self.log.debug("Current access token is valid!")
else:
self.log.info("Access token is missing, fetching a new one...")
await self.get_access_token()
# Sleep for an hour
await sleep(3600)
except CancelledError:
self.log.debug("Access check loop stopped.")
# Get a new access token
async def get_access_token(self) -> str:
data = {
"client_id": self.config["client_id"],
"client_secret": self.config["client_secret"],
"grant_type": "client_credentials"
}
async with self.http.post("https://id.twitch.tv/oauth2/token", data=data) as response:
result = await response.json()
if (response.status >= 400):
self.log.error(f"Error getting access token: {response.status} - {result['message']}")
return ""
# Store new token in the DB
q = "INSERT INTO twitchbot_data (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value=excluded.value"
await self.database.execute(q, "access_token", result["access_token"])
self.log.info("Stored new access token")
return result["access_token"]
# Get or generate webhook secret
async def get_webhook_secret(self) -> str:
q = "SELECT value FROM twitchbot_data WHERE key = $1"
row = await self.database.fetchrow(q, "webhook_secret")
if row:
return row["value"]
else:
self.log.info("No webhook secret found. Generating a new one...")
secret = token_hex(32)
qi = "INSERT INTO twitchbot_data (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value=excluded.value"
await self.database.execute(qi, "webhook_secret", secret)
self.log.info("Stored new webhook secret")
return secret
# Fetch an image URL, upload it to the matrix media store, and return the mcx:// address
async def upload_img(self, link: str) -> str:
image_req = await self.http.get(link)
if (image_req.status < 400):
image = await image_req.read()
mxc = await self.client.upload_media(image, filename=image_req.url.name)
return mxc
return ""
# Make a Twitch API call and return the data
async def twitch_api(self, method: str, endpoint: str, params: dict | None, data: dict | None, evt: MessageEvent | None) -> dict | None:
# Get access token from DB
q = "SELECT value FROM twitchbot_data WHERE key = $1"
row = await self.database.fetchrow(q, "access_token")
if row:
access_token = row["value"]
else:
self.log.info("Access token is missing, fetching a new one...")
access_token = await self.get_access_token()
if (len(access_token) < 1):
if isinstance(evt, MessageEvent):
await evt.respond("I seem to be having trouble authenticating to the Twitch API. Check the logs for more details.")
return None
if (method == "GET"):
httpfunc = self.http.get
elif (method == "POST"):
httpfunc = self.http.post
elif (method == "DELETE"):
httpfunc = self.http.delete
else:
self.log.error(f"Unknown method: {method}")
return None
headers = {
"Authorization": f"Bearer {access_token}",
"Client-Id": self.config["client_id"]
}
retry = 2
while (retry > 0):
async with httpfunc("https://api.twitch.tv/helix" + endpoint, headers=headers, params=params, json=data) as response:
if (response.status == 204):
rdata = {}
else:
rdata = await response.json()
if (response.status == 401):
self.log.info("Access token is invalid, fetching a new one...")
access_token = await self.get_access_token()
if (len(access_token) < 1):
if isinstance(evt, MessageEvent):
await evt.respond("I seem to be having trouble authenticating to the Twitch API. Check the logs for more details.")
return None
headers["Authorization"] = f"Bearer {access_token}"
retry = retry - 1
continue
elif (response.status >= 400):
# For some reason the /streams endpoint returns 400 for nonexistent or deactivated channels
if (endpoint == "/streams" and rdata["message"] == "Malformed query params."):
if isinstance(evt, MessageEvent):
await evt.respond("Sorry, I can't seem to find that channel.")
else:
self.log.warning(f"Channel '{params['user_login']}' does not exist.")
return None
self.log.error(f"{method} {str(response.url)}: {response.status} {rdata['error']} - {rdata['message']}")
if isinstance(evt, MessageEvent):
await evt.respond(f"{rdata['error']} - {rdata['message']}")
return None
return rdata
return None
# Main !twitch command handler
@command.new(name="twitch", help="Get the status of a Twitch channel")
@command.argument("channel", required=True, pass_raw=True)
async def twitch(self, evt: MessageEvent, channel: str) -> None:
await self.get_status(evt, channel)
# Get the status of a Twitch channel
async def get_status(self, evt: MessageEvent | None, channel: str) -> None:
if (len(channel) < 1):
if isinstance(evt, MessageEvent):
await evt.respond("You forgot to give me a channel name")
return
# Get stream info
params = { "user_login": channel, "type": "live" }
streams = await self.twitch_api("GET", "/streams", params, None, evt)
if not streams:
return
# If there are no live streams, return channel info instead
if (len(streams["data"]) < 1):
params = { "login": channel }
users = await self.twitch_api("GET", "/users", params, None, evt)
if not users:
return
if (len(users["data"]) < 1):
if isinstance(evt, MessageEvent):
await evt.respond(f"Could not find user: {channel}")
return
user = users["data"][0]
# Upload channel image
if (len(user["offline_image_url"]) > 0):
mxc = await self.upload_img(user["offline_image_url"])
elif (len(user["profile_image_url"]) > 0):
mxc = await self.upload_img(user["profile_image_url"])
else:
mxc = ""
if (len(mxc) > 0):
img_html = f"<img width=400 src='{mxc}' />"
else:
img_html = ""
# Mention room user if defined
if user["login"] in self.config["matrix_users"]:
user_html = f"<a href='https://matrix.to/#/{self.config['matrix_users'][user['login']]}'>{user['display_name']}</a>"
else:
user_html = user["display_name"]
# Construct and send response
content = TextMessageEventContent(
msgtype=MessageType.NOTICE,
format=Format.HTML,
body=f"> {user['display_name']} is offline.\n> **[{user['display_name']}](https://twitch.tv/{user['login']})**\n> {user['description']}\n> **Total Views:** {user['view_count']}",
formatted_body=f"<h5><span data-mx-color='#FFFFFF'>{user_html} is offline.</span></h5><blockquote><h4><a href='https://twitch.tv/{user['login']}'>{user['display_name']}</a></h4><p>{user['description']}</p>{img_html}</blockquote>"
)
if isinstance(evt, MessageEvent):
await evt.respond(content)
else:
await self.client.send_message(self.config["notify_room"], content)
return
# Use game name as title if no title is provided
stream = streams["data"][0]
if (len(stream["title"]) < 1):
stream["title"] = stream["game_name"]
# Upload thumbnail
mxc = await self.upload_img(stream["thumbnail_url"].format(width="400", height="225"))
if (len(mxc) > 0):
img_html = f"<img src='{mxc}' />"
else:
img_html = ""
# Mention room user if defined
if stream["user_login"] in self.config["matrix_users"]:
user_html = f"<a href='https://matrix.to/#/{self.config['matrix_users'][stream['user_login']]}'>{stream['user_name']}</a>"
else:
user_html = stream["user_name"]
# Construct and send response
content = TextMessageEventContent(
msgtype=MessageType.NOTICE,
format=Format.HTML,
body=f"> {stream['user_name']} is now live on Twitch!\n> **[{stream['title']}](https://twitch.tv/{stream['user_login']})**\n> **Game:** {stream['game_name']}\n> **Viewers:** {stream['viewer_count']}",
formatted_body=f"<h5><span data-mx-color='#FFFFFF'>{user_html} is now live on Twitch!</span></h5><blockquote><h4><a href='https://twitch.tv/{stream['user_login']}'>{stream['title']}</a></h4><p><b><span data-mx-color='#FFFFFF'>Game:</span></b> {stream['game_name']}<br><b><span data-mx-color='#FFFFFF'>Viewers:</span></b> {stream['viewer_count']}</p>{img_html}</blockquote>"
)
if isinstance(evt, MessageEvent):
await evt.respond(content)
else:
await self.client.send_message(self.config["notify_room"], content)
# Register channel notifications and/or unregister old subscriptions
async def register_notifications(self) -> None:
self.log.debug("Registering notifications...")
# Exit immediately if client_id and client_secret aren't set
if ("client_id" not in self.config or "client_secret" not in self.config or len(self.config["client_id"]) < 1 or len(self.config["client_secret"]) < 1):
return
# Get sub list from config
config_subs = self.config["notify_channels"].copy()
# Get subs from DB
db_subs = await self.database.fetch("SELECT * FROM twitchbot_subs")
# Get current sub list from API
register_error = TextMessageEventContent(
msgtype=MessageType.NOTICE,
body="I encountered an issue while registering Twitch notifications. Check the logs for more details."
)
response = await self.twitch_api("GET", "/eventsub/subscriptions", None, None, None)
if not response:
await self.client.send_message(self.config["notify_room"], register_error)
return
api_subs = response["data"]
# Handle paginated results
while (response["pagination"] and response["pagination"]["cursor"]):
params = { "after": response["pagination"]["cursor"] }
response = await self.twitch_api("GET", "/eventsub/subscriptions", params, None, None)
if not response:
await self.client.send_message(self.config["notify_room"], register_error)
return
api_subs = api_subs + response["data"]
# Check current subs for ones that need removal
for sub in api_subs:
# Ignore subs that aren't for our instance
if (sub["transport"]["callback"] != str(self.webapp_url) + "stream-notify"):
continue
# Locate corresponding sub from DB
db_sub = None
for i in db_subs:
if (i["id"] == sub["id"]):
db_sub = i
break
# Unsubscribe from the notification if we have no record of it, or if it's no longer in the config list
if (db_sub is None or db_sub["login"] not in config_subs):
params = { "id": sub["id"] }
await self.twitch_api("DELETE", "/eventsub/subscriptions", params, None, None)
# Remove record from the DB
if db_sub:
self.log.info(f"Unsubscribed from channel {db_sub['login']}")
db_subs.remove(db_sub)
qd = "DELETE FROM twitchbot_subs WHERE id = $1"
await self.database.execute(qd, sub["id"])
else:
self.log.info(f"Unsubscribed from user ID {sub['condition']['broadcaster_user_id']}")
continue
# Otherwise, this sub checks out so remove it from the list of subs that need processing
config_subs.remove(db_sub["login"])
db_subs.remove(db_sub)
webhook_secret = await self.get_webhook_secret()
# Register remaining subs from the config
for sub in config_subs:
# Check if there is a corresponding DB entry
db_sub = None
for i in db_subs:
if (i["login"] == sub):
db_sub = i
break
# Lookup channel name
params = { "login": sub }
users = await self.twitch_api("GET", "/users", params, None, None)
if not users or len(users["data"]) < 1:
if db_sub:
if (sub in self.config["matrix_users"]):
user_html = f"<a href='https://matrix.to/#/{self.config['matrix_users'][sub]}'>{db_sub['name']}</a>"
else:
user_html = db_sub["name"]
content = TextMessageEventContent(
msgtype=MessageType.NOTICE,
format=Format.HTML,
body=f"Lost Twitch channel notification subscription for {db_sub['name']}. Check the logs for more details.",
formatted_body=f"<p>Lost Twitch channel notification subscription for {user_html}. Check the logs for more details.</p>"
)
await self.client.send_message(self.config["notify_room"], content)
else:
content = TextMessageEventContent(
msgtype=MessageType.NOTICE,
body=f"Could not subscribe to Twitch channel '{sub}'. Are you sure it exists?"
)
await self.client.send_message(self.config["notify_room"], content)
continue
user = users["data"][0]
# Register subscription
req = {
"type": "stream.online",
"version": "1",
"condition": {
"broadcaster_user_id": user["id"]
},
"transport": {
"method": "webhook",
"callback": str(self.webapp_url) + "stream-notify",
"secret": webhook_secret
}
}
response = await self.twitch_api("POST", "/eventsub/subscriptions", None, req, None)
if not response:
content = TextMessageEventContent(
msgtype=MessageType.NOTICE,
body=f"I encountered an issue subscribing to channel '{sub}'. Check the logs for more details."
)
await self.client.send_message(self.config["notify_room"], content)
continue
# Add new subscription to DB
self.log.info(f"Storing new subscription for channel {sub}")
q = "INSERT INTO twitchbot_subs (id, login, name) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET login=excluded.login, name=excluded.name"
await self.database.execute(q, response["data"][0]["id"], sub, user["display_name"])
# Clean up remaining stale DB entries
for sub in db_subs:
self.log.info(f"Cleaning stale DB entry for channel {sub['login']}")
qd = "DELETE FROM twitchbot_subs WHERE id = $1"
await self.database.execute(qd, sub["id"])
# Webhook handler for live stream notifications
@web.post("/stream-notify")
async def stream_notify(self, req: Request) -> Response:
self.log.debug("Handling webhook event...")
# Check for necessary headers
id = req.headers.get("Twitch-Eventsub-Message-Id")
timestamp = req.headers.get("Twitch-Eventsub-Message-Timestamp")
signature = req.headers.get("Twitch-Eventsub-Message-Signature")
type = req.headers.get("Twitch-Eventsub-Message-Type")
if (id is None or timestamp is None or signature is None or type is None):
self.log.info("Webhook event is missing required headers")
return Response(status=403, text="Missing required headers")
# Compute HMAC and validate against the one supplied
body = await req.text()
data = await req.json()
secret = await self.get_webhook_secret()
my_hmac = hmac.new(secret.encode(), id.encode(), hashlib.sha256)
my_hmac.update(timestamp.encode())
my_hmac.update(body.encode())
if (hmac.compare_digest("sha256=" + my_hmac.hexdigest(), signature) == False):
self.log.info("Webhook event failed validation")
return Response(status=403, text="Invalid HMAC signature")
# Handle challenge requests
if (type == "webhook_callback_verification"):
self.log.info("Responded to webhook challenge")
return Response(status=200, text=data["challenge"])
# Handle subscription revocations
if (type == "revocation"):
reasons = {
"user_removed": "Channel no longer exists",
"authorization_revoked": "API access has been revoked (try resetting the bot)",
"notification_failures_exceeded": "Notification responses have been too slow (consider a hardware upgrade?)",
"version_removed": "This type of subscription is no longer available - please file a bug report"
}
if (data["subscription"]["status"] in reasons):
reason = reasons[data["subscription"]["status"]]
else:
reason = data["subscription"]["status"]
# Get subscription data from DB and send channel notification
q = "SELECT * FROM twitchbot_subs WHERE id = $1"
row = await self.database.fetchrow(q, data["subscription"]["id"])
if row:
self.log.warning(f"Subscription to channel '{row['login']}' revoked.")
user_name = row["name"]
if (row["login"] in self.config["matrix_users"]):
user_html = f"<a href='https://matrix.to/#/{self.config['matrix_users'][row['login']]}'>{row['name']}</a>"
else:
user_html = row["name"]
# Delete subscription from DB
qd = "DELETE FROM twitchbot_subs WHERE id = $1"
await self.database.execute(qd, data["subscription"]["id"])
else:
self.log.warning(f"Subscription to user ID {data['subscription']['condition']['broadcaster_user_id']} revoked.")
user_name = f"ID {data['subscription']['condition']['broadcaster_user_id']}"
user_html = user_name
content = TextMessageEventContent(
msgtype=MessageType.NOTICE,
format=Format.HTML,
body=f"Lost Twitch channel notification subscription for {user_name}: {reason}",
formatted_body=f"<p>Lost Twitch channel notification subscription for {user_html}: {reason}</p>"
)
await self.client.send_message(self.config["notify_room"], content)
# Handle notifications
if (type == "notification"):
self.log.info(f"Sending channel notification for {data['event']['broadcaster_user_login']}")
await self.get_status(None, data["event"]["broadcaster_user_login"])
return Response(status=204)